diff --git a/docs/backend-api-reference.md b/docs/backend-api-reference.md index cbc29b63..1b8aa0e9 100644 --- a/docs/backend-api-reference.md +++ b/docs/backend-api-reference.md @@ -166,6 +166,39 @@ curl -X POST http://localhost:3000/api/commitments/abc123/settle \ --- +## `POST /api/commitments/[id]/fund` + +Funds an existing commitment that was previously created but not yet funded. The route validates ownership, enforces `CREATED` state, and submits the on-chain `fund_escrow` transaction. + +- **Path parameter**: `id` (string) +- **Headers**: + - `Idempotency-Key`: (Optional) A unique string to identify the request and prevent duplicate processing. Replayed requests within the 24-hour replay window return the original prior result. +- **Request body**: + - `callerAddress` (string, optional) — Stellar address of the funding wallet. If omitted, the commitment owner is used. +- **Response**: confirmation of the funded commitment with `txHash` and `reference`. + +### Example + +```bash +curl -X POST http://localhost:3000/api/commitments/abc123/fund \ + -H 'Content-Type: application/json' \ + -d '{"callerAddress":"GOWNER..."}' +``` + +```json +{ + "success": true, + "data": { + "commitmentId": "abc123", + "txHash": "tx-abc123", + "reference": "funded", + "fundedAt": "2026-05-27T00:00:00.000Z" + } +} +``` + +--- + ## `POST /api/commitments/[id]/early-exit` Triggers an early exit (with penalty) for the named commitment. Emits `CommitmentEarlyExit` events. diff --git a/docs/backend-security-checklist.md b/docs/backend-security-checklist.md index 9cd134cb..afc8073d 100644 --- a/docs/backend-security-checklist.md +++ b/docs/backend-security-checklist.md @@ -149,7 +149,7 @@ npm audit ## 12. Rate Limiting -- [ ] Write-heavy routes (`POST /api/commitments`, `POST /api/commitments/[id]/settle`, `POST /api/commitments/[id]/early-exit`) are protected by per-IP rate limiting +- [ ] Write-heavy routes (`POST /api/commitments`, `POST /api/commitments/[id]/fund`, `POST /api/commitments/[id]/settle`, `POST /api/commitments/[id]/early-exit`) are protected by per-IP rate limiting - [ ] Rate limits are applied via `checkRateLimit(ip, routeId)` using `getClientIp` for key derivation - [ ] 429 responses include a `Retry-After` header populated from `getRateLimitWindowSeconds(routeId)` - [ ] Rate limit thresholds are configurable via env vars (`RATE_LIMIT_WRITE_MAX_REQUESTS`, `RATE_LIMIT_WRITE_WINDOW_SECONDS`, etc.) — not hardcoded diff --git a/docs/backend-session-csrf.md b/docs/backend-session-csrf.md index 949ce83e..979c1d41 100644 --- a/docs/backend-session-csrf.md +++ b/docs/backend-session-csrf.md @@ -41,6 +41,7 @@ Requests with `Authorization: Bearer ` **skip** CSRF enforcement (int | `GET /api/auth/csrf` | Requires `cl_session`; returns current `csrfToken` | N/A | | `POST /api/commitments` | — | Yes, when cookie present | | `POST /api/commitments/[id]/settle` | — | Yes | +| `POST /api/commitments/[id]/fund` | — | Yes | | `POST /api/commitments/[id]/early-exit` | — | Yes | | `POST /api/attestations` | — | Yes | | `POST /api/marketplace/listings` | — | Yes | diff --git a/openapi.yaml b/openapi.yaml index 81417c6a..dd50a4f2 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -71,6 +71,12 @@ components: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + Forbidden: + description: The request is understood but refused due to policy or ownership. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' NotFound: description: The requested resource was not found. content: @@ -177,6 +183,44 @@ paths: description: > Search and filter commitments by asset, status, and risk type with stable sorting and pagination. Mirrors the marketplace filtering contract. + /api/commitments/{id}/fund: + post: + summary: Fund a Created Commitment + description: Submit the on-chain `fund_escrow` transaction for a previously created commitment. + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Commitment identifier. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + callerAddress: + type: string + example: "GOWNER..." + responses: + '200': + description: Commitment funded successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessEnvelope' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/BadRequest' + '429': + $ref: '#/components/responses/RateLimited' parameters: - name: ownerAddress in: query diff --git a/src/app/api/commitments/[id]/fund/route.ts b/src/app/api/commitments/[id]/fund/route.ts new file mode 100644 index 00000000..1bdbd36e --- /dev/null +++ b/src/app/api/commitments/[id]/fund/route.ts @@ -0,0 +1,119 @@ +import { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { ok, methodNotAllowed } from '@/lib/backend/apiResponse'; +import { assertMutationCsrf } from '@/lib/backend/csrf'; +import { createCorsOptionsHandler, type CorsRoutePolicy } from '@/lib/backend/cors'; +import { + ConflictError, + ForbiddenError, + NotFoundError, + TooManyRequestsError, + ValidationError, +} from '@/lib/backend/errors'; +import { getClientIp } from '@/lib/backend/getClientIp'; +import { fundEscrowOnChain, getCommitmentFromChain } from '@/lib/backend/services/contracts'; +import { checkRateLimit, getRateLimitWindowSeconds } from '@/lib/backend/rateLimit'; +import { withApiHandler } from '@/lib/backend/withApiHandler'; +import { idempotencyService } from '@/lib/backend/idempotency'; + +const FundRequestSchema = z.object({ + callerAddress: z.string().optional(), +}); + +const COMMITMENT_FUND_CORS_POLICY = { + POST: { access: 'first-party' }, +} satisfies CorsRoutePolicy; + +export const OPTIONS = createCorsOptionsHandler(COMMITMENT_FUND_CORS_POLICY); + +export const POST = withApiHandler( + async (req: NextRequest, { params }, correlationId) => { + assertMutationCsrf(req); + + const ip = getClientIp(req); + if (!(await checkRateLimit(ip, 'api/commitments/fund'))) { + throw new TooManyRequestsError( + 'Too many requests. Please try again later.', + undefined, + getRateLimitWindowSeconds('api/commitments/fund'), + ); + } + + const id = params.id; + if (!id?.trim()) { + throw new ValidationError('Commitment ID is required'); + } + + const idempotencyKey = req.headers.get('idempotency-key'); + if (idempotencyKey) { + const record = await idempotencyService.getRecord(idempotencyKey); + if (record) { + if (record.status === 'COMPLETED') { + return ok(record.response, undefined, record.statusCode, correlationId); + } else if (record.status === 'STARTED') { + throw new ConflictError('A request with this Idempotency-Key is currently processing'); + } + } + await idempotencyService.start(idempotencyKey); + } + + try { + let body: unknown; + try { + body = await req.json(); + } catch { + throw new ValidationError('Invalid JSON in request body'); + } + + const validation = FundRequestSchema.safeParse(body); + if (!validation.success) { + throw new ValidationError('Invalid request data', validation.error.issues); + } + + const callerAddress = validation.data.callerAddress; + const commitment = await getCommitmentFromChain(id); + + if (!commitment) { + throw new NotFoundError('Commitment', { commitmentId: id }); + } + + if (commitment.status !== 'CREATED') { + throw new ConflictError('Only created commitments can be funded'); + } + + if (callerAddress && callerAddress !== commitment.ownerAddress) { + throw new ForbiddenError( + 'Only the commitment owner may fund this commitment', + { commitmentId: id }, + ); + } + + const funded = await fundEscrowOnChain({ + commitmentId: id, + callerAddress, + }); + + const responseData = { + commitmentId: id, + txHash: funded.txHash, + reference: funded.reference, + fundedAt: new Date().toISOString(), + }; + + if (idempotencyKey) { + await idempotencyService.complete(idempotencyKey, responseData, 200); + } + + return ok(responseData, undefined, 200, correlationId); + } catch (error) { + if (idempotencyKey) { + await idempotencyService.fail(idempotencyKey); + } + throw error; + } + }, + { cors: COMMITMENT_FUND_CORS_POLICY }, +); + +const _405 = methodNotAllowed(['POST']); +export { _405 as GET, _405 as PUT, _405 as PATCH, _405 as DELETE }; diff --git a/src/app/api/commitments/search/route.ts b/src/app/api/commitments/search/route.ts index 10ab76c2..2c50a834 100644 --- a/src/app/api/commitments/search/route.ts +++ b/src/app/api/commitments/search/route.ts @@ -36,6 +36,7 @@ import { createHash } from "crypto"; * Maps user-facing values to the on-chain `ChainCommitmentStatus` type. */ const COMMITMENT_STATUS_VALUES = [ + "CREATED", "ACTIVE", "SETTLED", "VIOLATED", @@ -68,7 +69,7 @@ const CommitmentSearchQuerySchema = z.object({ /** * Filter by commitment status. - * Accepted values: ACTIVE, SETTLED, VIOLATED, EARLY_EXIT. + * Accepted values: CREATED, ACTIVE, SETTLED, VIOLATED, EARLY_EXIT. */ status: z .enum(COMMITMENT_STATUS_VALUES) diff --git a/src/lib/backend/services/contracts.ts b/src/lib/backend/services/contracts.ts index 13e56273..c9bbe9fe 100644 --- a/src/lib/backend/services/contracts.ts +++ b/src/lib/backend/services/contracts.ts @@ -21,6 +21,7 @@ import { CacheKey, CacheTTL } from "@/lib/backend/cache/index"; import { getCountersAdapter } from "@/lib/backend/counters/provider"; export type ChainCommitmentStatus = + | "CREATED" | "ACTIVE" | "SETTLED" | "VIOLATED" @@ -92,6 +93,18 @@ export interface SettleCommitmentOnChainResult { contractVersion?: string; } +export interface FundEscrowOnChainParams { + commitmentId: string; + callerAddress?: string; +} + +export interface FundEscrowOnChainResult { + commitmentId: string; + txHash?: string; + reference?: string; + contractVersion?: string; +} + type ContractCallMode = "read" | "write"; interface ContractInvocationResult { value: unknown; @@ -226,6 +239,7 @@ function asNumber(value: unknown, fallback = 0): number { function normalizeStatus(value: unknown): ChainCommitmentStatus { const raw = asString(value, "UNKNOWN").toUpperCase(); if ( + raw === "CREATED" || raw === "ACTIVE" || raw === "SETTLED" || raw === "VIOLATED" || @@ -926,6 +940,92 @@ export async function settleCommitmentOnChain( } } +export async function fundEscrowOnChain( + params: FundEscrowOnChainParams, +): Promise { + try { + if (!params.commitmentId) { + throw new BackendError({ + code: "BAD_REQUEST", + message: "Missing commitment id for funding.", + status: 400, + }); + } + + if (params.callerAddress) { + validateOwnerAddress(params.callerAddress); + } + + const commitment = await getCommitmentFromChain(params.commitmentId); + + if (!commitment) { + throw new BackendError({ + code: "NOT_FOUND", + message: "Commitment not found.", + status: 404, + details: { commitmentId: params.commitmentId }, + }); + } + + if (commitment.status !== "CREATED") { + throw new BackendError({ + code: "CONFLICT", + message: "Only created commitments can be funded.", + status: 409, + details: { commitmentId: params.commitmentId, status: commitment.status }, + }); + } + + const callerAddress = params.callerAddress ?? commitment.ownerAddress; + if (!callerAddress || callerAddress !== commitment.ownerAddress) { + throw new BackendError({ + code: "FORBIDDEN", + message: "Only the commitment owner may fund this commitment.", + status: 403, + details: { commitmentId: params.commitmentId, callerAddress }, + }); + } + + const invocation = await invokeContractMethod( + getContractId("commitmentCore"), + "fund_escrow", + [ + nativeToScVal(params.commitmentId), + new Address(callerAddress).toScVal(), + ], + "write", + ); + + const countersAdapter = getCountersAdapter(); + void countersAdapter.incrementSuccessfulActions(); + + void cache.delete(CacheKey.commitment(params.commitmentId)); + if (commitment.ownerAddress) { + void cache.delete(CacheKey.userCommitments(commitment.ownerAddress)); + } + + return { + commitmentId: params.commitmentId, + txHash: invocation.txHash, + contractVersion: invocation.version, + reference: invocation.txHash ? undefined : "TODO_CHAIN_CALL_FUND_ESCROW", + }; + } catch (error) { + const countersAdapter = getCountersAdapter(); + void countersAdapter.incrementChainFailures(); + + throw normalizeBackendError(error, { + code: "BLOCKCHAIN_CALL_FAILED", + message: "Unable to fund escrow on chain.", + status: 502, + details: { + method: "fund_escrow", + commitmentId: params.commitmentId, + }, + }); + } +} + export async function earlyExitCommitmentOnChain( params: EarlyExitCommitmentOnChainParams ): Promise { diff --git a/tests/api/commitments-fund.test.ts b/tests/api/commitments-fund.test.ts new file mode 100644 index 00000000..d04463e7 --- /dev/null +++ b/tests/api/commitments-fund.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockRequest, createMockRouteContext, parseResponse } from './helpers'; + +vi.mock('@/lib/backend/rateLimit', () => ({ + checkRateLimit: vi.fn(), + getRateLimitWindowSeconds: vi.fn().mockReturnValue(60), +})); + +vi.mock('@/lib/backend/getClientIp', () => ({ + getClientIp: vi.fn().mockReturnValue('1.2.3.4'), +})); + +vi.mock('@/lib/backend/services/contracts', () => ({ + getCommitmentFromChain: vi.fn(), + fundEscrowOnChain: vi.fn(), +})); + +vi.mock('@/lib/backend/csrf', () => ({ + assertMutationCsrf: vi.fn(), +})); + +import { checkRateLimit } from '@/lib/backend/rateLimit'; + +const mockedCheckRateLimit = vi.mocked(checkRateLimit); + +function postRequest(url: string, body?: unknown) { + return createMockRequest(url, { method: 'POST', body }); +} + +describe('POST /api/commitments/[id]/fund', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedCheckRateLimit.mockResolvedValue(true); + }); + + it('rejects funding when commitment status is not CREATED', async () => { + const { getCommitmentFromChain } = await import('@/lib/backend/services/contracts'); + vi.mocked(getCommitmentFromChain).mockResolvedValue({ + id: 'c-1', + ownerAddress: 'GOWNER', + status: 'ACTIVE', + } as any); + + const { POST } = await import('@/app/api/commitments/[id]/fund/route'); + const req = postRequest('http://localhost:3000/api/commitments/c-1/fund', { + callerAddress: 'GOWNER', + }); + const response = await POST(req, createMockRouteContext({ id: 'c-1' })); + const result = await parseResponse(response); + + expect(response.status).toBe(409); + expect(result.data.error.code).toBe('CONFLICT'); + expect(result.data.error.message).toContain('created'); + }); + + it('rejects funding when the caller address does not own the commitment', async () => { + const { getCommitmentFromChain } = await import('@/lib/backend/services/contracts'); + vi.mocked(getCommitmentFromChain).mockResolvedValue({ + id: 'c-2', + ownerAddress: 'GOWNER', + status: 'CREATED', + } as any); + + const { POST } = await import('@/app/api/commitments/[id]/fund/route'); + const req = postRequest('http://localhost:3000/api/commitments/c-2/fund', { + callerAddress: 'GBADADDR', + }); + const response = await POST(req, createMockRouteContext({ id: 'c-2' })); + const result = await parseResponse(response); + + expect(response.status).toBe(403); + expect(result.data.error.code).toBe('FORBIDDEN'); + expect(result.data.error.message).toContain('owner'); + }); + + it('funds a created commitment when the owner submits the request', async () => { + const { getCommitmentFromChain, fundEscrowOnChain } = await import('@/lib/backend/services/contracts'); + vi.mocked(getCommitmentFromChain).mockResolvedValue({ + id: 'c-3', + ownerAddress: 'GOWNER', + status: 'CREATED', + } as any); + vi.mocked(fundEscrowOnChain).mockResolvedValue({ + commitmentId: 'c-3', + txHash: 'tx-123', + reference: 'funded', + } as any); + + const { POST } = await import('@/app/api/commitments/[id]/fund/route'); + const req = postRequest('http://localhost:3000/api/commitments/c-3/fund', { + callerAddress: 'GOWNER', + }); + const response = await POST(req, createMockRouteContext({ id: 'c-3' })); + const result = await parseResponse(response); + + expect(response.status).toBe(200); + expect(result.data.success).toBe(true); + expect(result.data.data.commitmentId).toBe('c-3'); + expect(result.data.data.txHash).toBe('tx-123'); + expect(vi.mocked(fundEscrowOnChain)).toHaveBeenCalledWith({ + commitmentId: 'c-3', + callerAddress: 'GOWNER', + }); + }); +});