Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ without exporting one another's symbols. Contract crates are pulled in only as
## Testing

```bash
# Contracts: 86 tests — every state transition and failure path + 3 integration
# Contracts: 98 tests — every state transition and failure path + 3 integration
cd contracts
cargo test
cargo test -p escrow # one contract
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Honest snapshot of what works today (see [`docs/TESTNET.md`](./docs/TESTNET.md)
| `escrow` contract | ✅ Complete — milestones (editable before funding, partial releases), expiry refunds, validated inputs, TTL, events |
| `dispute` contract | ✅ Complete — verified raise, M-of-N arbitrator panel, resolution that calls back into escrow, dispute timelock |
| `reputation` contract | ✅ Complete — escrow-verified 1–5 ratings, amount-weighted aggregates, per-pair anti-spam (`docs/REPUTATION.md`) |
| Contract tests | ✅ 86 tests: 83 unit tests (escrow 49, dispute 17, reputation 17) covering every state transition and failure path, plus 3 end-to-end tests in `contracts/dispute/tests/full_flow.rs` that drive escrow + dispute + reputation together |
| Contract tests | ✅ 98 tests: 95 unit tests (escrow 57, dispute 21, reputation 17) covering every state transition and failure path, plus 3 end-to-end tests in `contracts/dispute/tests/full_flow.rs` that drive escrow + dispute + reputation together |
| Backend tests | ✅ 138 tests |
| Frontend tests | ✅ 156 tests (Vitest + Testing Library) — pages, components and lib helpers |
| Testnet deployment | ✅ Deployed and initialized (ids in [`deployments/testnet.json`](./deployments/testnet.json)) |
Expand Down Expand Up @@ -174,7 +174,7 @@ return an **unsigned XDR** for the client to sign, and never a key.
| `GET` | `/api/users/challenge` | — | Issue a single-use nonce for SEP-53 signing |
| `POST` | `/api/users/auth` | — | Verify the signed message, return a JWT |
| `GET` | `/api/users/:address` | — | Public profile |
| `GET` | `/api/users/:address/transactions` | — | Indexed on-chain history for the address's escrows, with explorer links |
| `GET` | `/api/users/:address/transactions?page=1&limit=20` | — | Paginated indexed on-chain history for the address's escrows, with explorer links and totals |
| `GET` | `/api/users/:address/reminders` | — | Milestone deadlines that are overdue or due soon (`?withinHours=`) |
| `PUT` | `/api/users/me` | ✅ | Update your own profile |
| `GET` | `/api/jobs` | — | List jobs with status + `tag` filters and pagination |
Expand Down Expand Up @@ -457,7 +457,7 @@ not just a restart.
## Testing

```bash
# Contracts — 86 tests (83 unit + 3 integration)
# Contracts — 98 tests (95 unit + 3 integration)
cd contracts
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
Expand Down
31 changes: 30 additions & 1 deletion backend/src/__tests__/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import type { Server } from "node:http";
import { Keypair } from "@stellar/stellar-sdk";
import { isValidStellarAddress } from "../lib/stellarAddress";
import { createJobSchema } from "../lib/schemas";
import { createDeliverableSchema, createJobSchema } from "../lib/schemas";
import { jsonReplacer } from "../lib/json";
import { MAX_JOB_TAGS, SKILL_TAG_SLUGS } from "../lib/tags";

Expand Down Expand Up @@ -186,6 +186,35 @@ describe("POST /api/jobs skill tags", () => {
});
});

describe("POST /api/milestones/:id/deliverables request validation", () => {
const valid = {
fileName: "work.zip",
fileUrl: "https://example.com/work.zip",
};

it("accepts a valid upload payload with optional metadata", () => {
expect(
createDeliverableSchema.parse({
...valid,
fileSize: 1024,
fileType: "application/zip",
description: "Completed deliverables",
}),
).toEqual({
...valid,
fileSize: 1024,
fileType: "application/zip",
description: "Completed deliverables",
});
});

it("rejects malformed URLs, non-positive sizes, and oversized fields", () => {
expect(createDeliverableSchema.safeParse({ ...valid, fileUrl: "not-a-url" }).success).toBe(false);
expect(createDeliverableSchema.safeParse({ ...valid, fileSize: 0 }).success).toBe(false);
expect(createDeliverableSchema.safeParse({ ...valid, description: "x".repeat(1001) }).success).toBe(false);
});
});

describe("jsonReplacer", () => {
it("serializes a BigInt column as a string instead of throwing", () => {
// Prisma returns escrowId as a BigInt; JSON.stringify would otherwise throw
Expand Down
4 changes: 2 additions & 2 deletions backend/src/__tests__/contractErrors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ describe("friendlyContractError", () => {
});

it("covers every DisputeError discriminant", () => {
// contracts/dispute/src/lib.rs declares 1..=13.
for (let code = 1; code <= 13; code++) {
// contracts/dispute/src/lib.rs declares 1..=14.
for (let code = 1; code <= 14; code++) {
expect(DISPUTE_ERROR_MESSAGES[code]).toBeTruthy();
}
});
Expand Down
13 changes: 11 additions & 2 deletions backend/src/__tests__/milestones.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,14 @@ describe("Milestone Deliverables API (Issue #65)", () => {

describe("GET /api/milestones/:id/deliverables", () => {
it("returns deliverables for a milestone", async () => {
h.milestone.findUnique.mockResolvedValue({ id: "m_1" });
const token = await tokenFor(FREELANCER);
h.milestone.findUnique.mockResolvedValue({
id: "m_1",
job: {
client: { stellarAddress: CLIENT },
freelancer: { stellarAddress: FREELANCER },
},
});
h.deliverable.findMany.mockResolvedValue([
{
id: "deliv_1",
Expand All @@ -228,7 +235,9 @@ describe("Milestone Deliverables API (Issue #65)", () => {
},
]);

const res = await fetch(`${baseUrl}/api/milestones/m_1/deliverables`);
const res = await fetch(`${baseUrl}/api/milestones/m_1/deliverables`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status).toBe(200);
const json = (await res.json()) as any;
expect(json).toHaveLength(1);
Expand Down
73 changes: 55 additions & 18 deletions backend/src/__tests__/usersRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { Server } from "node:http";

const h = vi.hoisted(() => ({
job: { findMany: vi.fn() },
chainEvent: { findMany: vi.fn() },
chainEvent: { findMany: vi.fn(), count: vi.fn() },
}));

vi.mock("../db", () => ({
Expand Down Expand Up @@ -57,6 +57,8 @@ beforeEach(() => {
h.job.findMany.mockResolvedValue([]);
h.chainEvent.findMany.mockReset();
h.chainEvent.findMany.mockResolvedValue([]);
h.chainEvent.count.mockReset();
h.chainEvent.count.mockResolvedValue(0);
});

async function get(path: string) {
Expand All @@ -78,6 +80,7 @@ describe("GET /api/users/:address/transactions", () => {
createdAt: new Date("2026-01-02T03:04:05.000Z"),
},
]);
h.chainEvent.count.mockResolvedValue(1);

const res = await get(`/${USER}/transactions`);

Expand All @@ -95,22 +98,26 @@ describe("GET /api/users/:address/transactions", () => {
});
expect(h.chainEvent.findMany).toHaveBeenCalledWith({
where: { escrowId: { in: [7n] } },
orderBy: { ledger: "desc" },
take: 200,
orderBy: [{ ledger: "desc" }, { id: "desc" }],
skip: 0,
take: 20,
});
expect(res.body).toEqual({
data: [
{
id: "evt-42",
escrowId: "7",
action: "milestone_released",
// 3000000 base units of a 7-decimal asset, not the raw figure.
amount: "0.3",
asset: "USDC",
txHash: "abc123",
ledger: 42,
createdAt: "2026-01-02T03:04:05.000Z",
},
],
pagination: { page: 1, limit: 20, total: 1, totalPages: 1 },
});
expect(res.body).toEqual([
{
id: "evt-42",
escrowId: "7",
action: "milestone_released",
// 3000000 base units of a 7-decimal asset, not the raw figure.
amount: "0.3",
asset: "USDC",
txHash: "abc123",
ledger: 42,
createdAt: "2026-01-02T03:04:05.000Z",
},
]);
});

it("skips events that move nothing, leaving the amount null", async () => {
Expand All @@ -129,7 +136,7 @@ describe("GET /api/users/:address/transactions", () => {

const res = await get(`/${USER}/transactions`);

const [row] = res.body as Array<{ amount: string | null; asset: string | null }>;
const [row] = (res.body as { data: Array<{ amount: string | null; asset: string | null }> }).data;
expect(row.amount).toBeNull();
expect(row.asset).toBe("XLM");
});
Expand All @@ -142,9 +149,39 @@ describe("GET /api/users/:address/transactions", () => {
const res = await get(`/${USER}/transactions`);

expect(res.status).toBe(200);
expect(res.body).toEqual([]);
expect(res.body).toEqual({
data: [],
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
});
expect(h.chainEvent.findMany).not.toHaveBeenCalled();
});

it("pages past the first 200 events and reports totals", async () => {
h.job.findMany.mockResolvedValue([{ escrowId: 7n, asset: "USDC" }]);
h.chainEvent.count.mockResolvedValue(205);
h.chainEvent.findMany.mockResolvedValue([]);

const res = await get(`/${USER}/transactions?page=11&limit=20`);

expect(res.status).toBe(200);
expect(h.chainEvent.findMany).toHaveBeenCalledWith({
where: { escrowId: { in: [7n] } },
orderBy: [{ ledger: "desc" }, { id: "desc" }],
skip: 200,
take: 20,
});
expect(res.body).toEqual({
data: [],
pagination: { page: 11, limit: 20, total: 205, totalPages: 11 },
});
});

it("rejects invalid pagination values", async () => {
const res = await get(`/${USER}/transactions?page=0&limit=101`);

expect(res.status).toBe(400);
expect(h.job.findMany).not.toHaveBeenCalled();
});
});

describe("GET /api/users/:address/reminders (#74)", () => {
Expand Down
1 change: 1 addition & 0 deletions backend/src/lib/contractErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export const DISPUTE_ERROR_MESSAGES: Record<number, string> = {
11: "The arbitrator panel is invalid.",
12: "The approval threshold is invalid.",
13: "The panel hasn't approved that exact split enough times yet.",
14: "This contract is paused.",
};

/** `ReputationError` discriminants from `contracts/reputation/src/lib.rs`. */
Expand Down
12 changes: 8 additions & 4 deletions backend/src/lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,16 +96,20 @@ export const listJobsQuerySchema = z.object({
limit: z.coerce.number().int().positive().max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
});

export const transactionHistoryQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
});

export const createProposalSchema = z.object({
coverLetter: z.string().min(5).max(3000).optional(),
proposedRate: amountSchema.optional(),
});

export const createDeliverableSchema = z.object({
fileName: z.string().min(1).max(255),
fileUrl: z.string().min(1),
fileSize: z.number().int().nonnegative().optional(),
fileUrl: z.string().url(),
fileSize: z.number().int().positive().optional(),
fileType: z.string().max(100).optional(),
description: z.string().max(2000).optional(),
description: z.string().max(1000).optional(),
});

38 changes: 25 additions & 13 deletions backend/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
createDeliverableSchema,
createJobSchema,
createProposalSchema,
transactionHistoryQuerySchema,
} from "./lib/schemas";
import { authSchema, updateSchema } from "./routes/users";
import {
Expand Down Expand Up @@ -335,22 +336,33 @@ registry.registerPath({
path: "/api/users/{address}/transactions",
tags: ["Users"],
summary: "On-chain history for the user's escrows",
request: { params: z.object({ address: z.string() }) },
request: {
params: z.object({ address: z.string() }),
query: transactionHistoryQuerySchema,
},
responses: {
200: json(
z.array(
z.object({
id: z.string(),
escrowId: z.string(),
action: z.string(),
amount: z.string().nullable(),
asset: z.string().nullable(),
txHash: z.string().nullable(),
ledger: z.number().int(),
createdAt: z.string().datetime(),
z.object({
data: z.array(
z.object({
id: z.string(),
escrowId: z.string(),
action: z.string(),
amount: z.string().nullable(),
asset: z.string().nullable(),
txHash: z.string().nullable(),
ledger: z.number().int(),
createdAt: z.string().datetime(),
}),
),
pagination: z.object({
page: z.number().int(),
limit: z.number().int(),
total: z.number().int(),
totalPages: z.number().int(),
}),
),
"Transaction history events",
}),
"Paginated transaction history",
),
},
});
Expand Down
38 changes: 29 additions & 9 deletions backend/src/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { verifySignedMessage } from "../lib/signature";
import { isValidStellarAddress } from "../lib/stellarAddress";
import { formatBaseUnitsForAsset } from "../lib/assets";
import { buildMilestoneReminders, parseHorizonHours } from "../lib/reminders";
import { transactionHistoryQuerySchema } from "../lib/schemas";

export const usersRouter = Router();

Expand Down Expand Up @@ -217,6 +218,11 @@ usersRouter.get("/", getFreelancersHandler);
// `txHash` is what the client turns into an explorer link.
usersRouter.get("/:address/transactions", async (req, res, next) => {
try {
const parsed = transactionHistoryQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw new AppError(400, parsed.error.issues.map((issue) => issue.message).join(", "));
}
const { page, limit } = parsed.data;
const address = req.params.address;
const jobs = await prisma.job.findMany({
where: {
Expand All @@ -234,18 +240,26 @@ usersRouter.get("/:address/transactions", async (req, res, next) => {
}
const escrowIds = [...assetByEscrow.keys()].map((id) => BigInt(id));
if (escrowIds.length === 0) {
res.json([]);
res.json({
data: [],
pagination: { page, limit, total: 0, totalPages: 0 },
});
return;
}

const events = await prisma.chainEvent.findMany({
where: { escrowId: { in: escrowIds } },
orderBy: { ledger: "desc" },
take: 200,
});
const where = { escrowId: { in: escrowIds } };
const [total, events] = await Promise.all([
prisma.chainEvent.count({ where }),
prisma.chainEvent.findMany({
where,
orderBy: [{ ledger: "desc" }, { id: "desc" }],
skip: (page - 1) * limit,
take: limit,
}),
]);

res.json(
events.map((event) => {
res.json({
data: events.map((event) => {
const symbol = assetByEscrow.get(event.escrowId.toString()) ?? null;
return {
id: event.id,
Expand All @@ -258,7 +272,13 @@ usersRouter.get("/:address/transactions", async (req, res, next) => {
createdAt: event.createdAt,
};
}),
);
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
} catch (err) {
next(err);
}
Expand Down
Loading
Loading