Skip to content
Open
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
93 changes: 92 additions & 1 deletion __tests__/soroban/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ function diagnostic(event: xdr.ContractEvent): xdr.DiagnosticEvent {
return new xdr.DiagnosticEvent({ inSuccessfulContractCall: true, event })
}

function contractStruct(fields: Record<string, xdr.ScVal>): xdr.ScVal {
return xdr.ScVal.scvMap(
Object.entries(fields).map(
([key, val]) =>
new xdr.ScMapEntry({
key: nativeToScVal(key, { type: 'symbol' }),
val,
})
)
)
}

function invoked(tx: Transaction): { method: string; argCount: number } {
const operation = tx.operations[0] as {
func: { value(): { functionName(): { toString(): string }; args(): xdr.ScVal[] } }
Expand All @@ -39,14 +51,15 @@ function client(overrides: {
simulate?: (tx: Transaction) => Promise<Record<string, unknown>>
sign?: (xdr: string) => Promise<string>
getTransaction?: () => Promise<Record<string, unknown>>
withSigner?: boolean
} = {}) {
const sign = vi.fn(overrides.sign ?? (async (xdr: string) => xdr))
const seen: Transaction[] = []
const sdk = new SorobanContractClient(
{
contractId: CONTRACT_ID,
sourcePublicKey: SOURCE,
signTransaction: sign,
signTransaction: overrides.withSigner === false ? undefined : sign,
parties: {
admin: SOURCE,
client: SOURCE,
Expand Down Expand Up @@ -158,4 +171,82 @@ describe('soroban contract sdk', () => {
sdk.createEscrow('project-1', 100, [{ id: 1, amount: 40 }])
).rejects.toBeInstanceOf(SorobanSdkError)
})

it('reads escrow state through simulation without a signer', async () => {
const { sdk, sign, seen } = client({
withSigner: false,
simulate: async () => ({
result: {
retval: contractStruct({
client: nativeToScVal(SOURCE, { type: 'address' }),
freelancer: nativeToScVal(SOURCE, { type: 'address' }),
token: nativeToScVal(CONTRACT_ID, { type: 'address' }),
funded: nativeToScVal(true),
allocated: nativeToScVal(BigInt(1000), { type: 'i128' }),
released: nativeToScVal(BigInt(250), { type: 'i128' }),
remaining: nativeToScVal(BigInt(750), { type: 'i128' }),
token_balance: nativeToScVal(BigInt(750), { type: 'i128' }),
status: nativeToScVal(1, { type: 'u32' }),
}),
},
}),
})

await expect(sdk.getEscrowState(CONTRACT_ID)).resolves.toEqual({
client: SOURCE,
freelancer: SOURCE,
token: CONTRACT_ID,
funded: true,
allocated: '1000',
released: '250',
remaining: '750',
tokenBalance: '750',
status: 'active',
})
expect(invoked(seen[0])).toEqual({ method: 'get_escrow_state', argCount: 0 })
expect(sign).not.toHaveBeenCalled()
})

it('returns milestone status and completion details from a read-only query', async () => {
const { sdk, seen } = client({
simulate: async () => ({
result: {
retval: contractStruct({
id: nativeToScVal(3, { type: 'u32' }),
amount: nativeToScVal(BigInt(1000), { type: 'i128' }),
released: nativeToScVal(BigInt(1000), { type: 'i128' }),
remaining: nativeToScVal(BigInt(0), { type: 'i128' }),
deadline: nativeToScVal(BigInt(1234), { type: 'u64' }),
description: nativeToScVal('Delivery'),
status: nativeToScVal(5, { type: 'u32' }),
client_approved: nativeToScVal(true),
freelancer_approved: nativeToScVal(false),
completed: nativeToScVal(true),
}),
},
}),
})

await expect(sdk.getMilestone(CONTRACT_ID, '3')).resolves.toMatchObject({
id: 3,
amount: '1000',
deadline: '1234',
released: '1000',
remaining: '0',
status: 5,
statusName: 'released',
completed: true,
})
expect(invoked(seen[0])).toEqual({ method: 'get_milestone', argCount: 1 })
})

it('surfaces a missing escrow query as a standardized contract error', async () => {
const { sdk } = client({
simulate: async () => ({ error: 'HostError: Error(Contract, #2)' }),
})
await expect(sdk.getEscrowState(CONTRACT_ID)).rejects.toMatchObject({
code: 'ERR_NOT_INITIALIZED',
contractCode: 2,
})
})
})
76 changes: 76 additions & 0 deletions app/api/proposals/[id]/review/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from "next/server";
import { readAccessToken, verifyAccessToken } from "@/lib/auth/session";
import { UUIDSchema } from "@/lib/proposals";
import { db as sql } from "@/lib/proposals/db";
import { updateProposalStatus } from "@/lib/proposals/service";
import type { ProposalStatus } from "@/lib/proposals/types";

type RouteContext = { params: Promise<{ id: string }> };
const REVIEW_STATUSES = ["pending", "shortlisted", "accepted", "rejected"] as const;

export async function PATCH(request: NextRequest, context: RouteContext) {
const token = readAccessToken(request);
const verified = token ? verifyAccessToken(token) : null;
if (!verified) {
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
}

const { id } = await context.params;
if (!UUIDSchema.safeParse(id).success) {
return NextResponse.json({ error: "Invalid proposal ID" }, { status: 400 });
}

let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
}
if (
!body ||
typeof body !== "object" ||
!("status" in body) ||
typeof body.status !== "string" ||
!REVIEW_STATUSES.includes(body.status as (typeof REVIEW_STATUSES)[number])
) {
return NextResponse.json({ error: "A valid review status is required" }, { status: 422 });
}

try {
const users = (await sql`
SELECT id, role FROM users
WHERE wallet_address = ${verified.walletAddress}
LIMIT 1
`) as { id: string; role: string }[];
const user = users[0];
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
if (user.role !== "client" && user.role !== "admin") {
return NextResponse.json({ error: "Only clients can review proposals" }, { status: 403 });
}

const proposal = await updateProposalStatus(
id,
user.id,
body.status as Extract<ProposalStatus, "pending" | "shortlisted" | "accepted" | "rejected">
);
return NextResponse.json({ proposal });
} catch (error) {
if (error instanceof Error && error.message === "NOT_FOUND") {
return NextResponse.json({ error: "Proposal not found" }, { status: 404 });
}
if (error instanceof Error && error.message === "INVALID_TRANSITION") {
return NextResponse.json(
{ error: "Only pending or shortlisted proposals can be changed" },
{ status: 409 }
);
}
if (error instanceof Error && error.message === "PROPOSAL_CHANGED") {
return NextResponse.json(
{ error: "Proposal changed concurrently; reload and retry" },
{ status: 409 }
);
}
console.error(`[PATCH /api/proposals/${id}/review]`, error);
return NextResponse.json({ error: "Failed to update proposal" }, { status: 500 });
}
}
23 changes: 22 additions & 1 deletion components/proposals/proposal-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ interface Props {
readOnly?: boolean;
onAccept?: (p: Proposal) => void;
onReject?: (p: Proposal) => void;
onShortlist?: (p: Proposal) => void;
onCompare?: (p: Proposal) => void;
comparing?: boolean;
onViewProfile?: (p: Proposal) => void;
}

Expand All @@ -18,6 +21,9 @@ export function ProposalCard({
readOnly,
onAccept,
onReject,
onShortlist,
onCompare,
comparing = false,
onViewProfile,
}: Props) {
const ISO = /^[A-Z]{3}$/;
Expand Down Expand Up @@ -82,8 +88,23 @@ export function ProposalCard({
>
View profile
</Button>
{!readOnly && proposal.status === "pending" && (
{!readOnly && (proposal.status === "pending" || proposal.status === "shortlisted") && (
<>
<Button
size="sm"
variant="outline"
aria-pressed={comparing}
onClick={() => onCompare?.(proposal)}
>
{comparing ? "Remove comparison" : "Compare"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onShortlist?.(proposal)}
>
{proposal.status === "shortlisted" ? "Remove from shortlist" : "Shortlist"}
</Button>
<Button size="sm" onClick={() => onAccept?.(proposal)}>
Accept
</Button>
Expand Down
1 change: 1 addition & 0 deletions components/proposals/proposal-filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export function ProposalFiltersBar({ value, onChange }: Props) {
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="shortlisted">Shortlisted</SelectItem>
<SelectItem value="accepted">Accepted</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="updated">Updated</SelectItem>
Expand Down
Loading
Loading