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
33 changes: 33 additions & 0 deletions docs/backend-api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/backend-security-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/backend-session-csrf.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Requests with `Authorization: Bearer <non-empty>` **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 |
Expand Down
44 changes: 44 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions src/app/api/commitments/[id]/fund/route.ts
Original file line number Diff line number Diff line change
@@ -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 };
3 changes: 2 additions & 1 deletion src/app/api/commitments/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
100 changes: 100 additions & 0 deletions src/lib/backend/services/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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" ||
Expand Down Expand Up @@ -926,6 +940,92 @@ export async function settleCommitmentOnChain(
}
}

export async function fundEscrowOnChain(
params: FundEscrowOnChainParams,
): Promise<FundEscrowOnChainResult> {
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<EarlyExitCommitmentOnChainResult> {
Expand Down
Loading