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
822 changes: 330 additions & 492 deletions src/app/api/commitments/[id]/fund/route.test.ts

Large diffs are not rendered by default.

171 changes: 130 additions & 41 deletions src/app/api/commitments/[id]/fund/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { NextRequest, NextResponse } from 'next/server';
/**
* POST /api/commitments/[id]/fund
*
Expand All @@ -13,6 +12,16 @@ import { NextRequest, NextResponse } from 'next/server';
* - No state regression: state never reverts from FUNDED to CREATED
* - Ownership is immutable: only ownerAddress can fund
*
* ### Authorization Invariants
* - Caller identity is derived from the server-side session token, never
* trusted from the request body alone.
* - When callerAddress is supplied in the body it is cross-checked against the
* session identity to prevent tampered-body spoofing.
* - Address format is validated against the canonical Stellar public-key regex
* before reaching any business logic.
* - Network passphrase (when supplied) must match the server configuration to
* catch wrong-network wallet submissions.
*
* ### Concurrent Request Bounds
* - Max 100 concurrent funding operations per route
* - Exceeding bound returns 503 with degraded telemetry
Expand All @@ -23,9 +32,12 @@ import { NextRequest, NextResponse } from 'next/server';
* - COMPLETED records are cached for 24 hours (default TTL)
* - FAILED records are deleted (allow immediate retry)
* - Network failures expose via X-Telemetry-Status header
*
* ### Idempotency Key Bounds
* - Keys are capped at MAX_IDEMPOTENCY_KEY_LENGTH characters to prevent
* storage inflation from hostile oversized values.
*/

import { NextRequest } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { ok, methodNotAllowed } from '@/lib/backend/apiResponse';
import { assertMutationCsrf } from '@/lib/backend/csrf';
Expand All @@ -45,44 +57,74 @@ import { checkRateLimit, getRateLimitWindowSeconds } from '@/lib/backend/rateLim
import { withApiHandler } from '@/lib/backend/withApiHandler';
import { idempotencyService } from '@/lib/backend/idempotency';
import { diagnosticsService } from '@/lib/backend/diagnostics';
import { verifyAuth } from '@/lib/backend/requireAuth';
import { getBackendConfig } from '@/lib/backend/config';
import { validateStellarAddress, validateCommitmentId } from '@/lib/backend/validation';
import { randomUUID } from 'crypto';

const FundRequestSchema = z.object({
callerAddress: z.string().min(1, 'callerAddress is required'),
});
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

/**
* Maximum byte length for an idempotency key. Unbounded keys could be used
* to inflate in-memory/KV storage without meaningful semantic value.
*/
const MAX_IDEMPOTENCY_KEY_LENGTH = 128;

/**
* Bound for concurrent funding operations.
* Prevents resource exhaustion during high load or DDoS.
* Monitor via diagnosticsService.getOperationStats('fund').maxConcurrentOps
*/
const MAX_CONCURRENT_FUNDING_OPS = 100;

/**
* Maximum duration for fund operation before considered slow/degraded.
* Used for SLO tracking and alerting in production.
*/
const FUND_OPERATION_SLOW_THRESHOLD_MS = 30000; // 30 seconds
const FUND_OPERATION_SLOW_THRESHOLD_MS = 30000;

// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------

/**
* `callerAddress` is optional: when omitted the route falls back to the
* address extracted from the verified server-side session token. When
* provided it must be a syntactically-valid Stellar public key; the route
* then additionally checks it matches the session identity.
*
* `network` is optional: when supplied it must equal the server-configured
* network passphrase, catching wrong-network submissions before any on-chain
* call is attempted.
*/
const FundRequestSchema = z.object({
callerAddress: z.string().min(1).optional(),
network: z.string().optional(),
});

// ---------------------------------------------------------------------------
// CORS
// ---------------------------------------------------------------------------

const COMMITMENT_FUND_CORS_POLICY = {
POST: { access: 'first-party' },
} satisfies CorsRoutePolicy;

export const OPTIONS = createCorsOptionsHandler(COMMITMENT_FUND_CORS_POLICY);

// ---------------------------------------------------------------------------
// Handler
// ---------------------------------------------------------------------------

export const POST = withApiHandler(
async (req: NextRequest, { params }, correlationId) => {
// Generate unique operation ID for telemetry tracking
const operationId = randomUUID();

// Start operation telemetry (includes concurrent ops tracking)
const telemetry = diagnosticsService.startOperation(
operationId,
'fund_commitment',
MAX_CONCURRENT_FUNDING_OPS,
);

// Check if we're at capacity
if (telemetry.status === 'degraded') {
diagnosticsService.completeOperation(operationId, 'degraded', telemetry.failureReason);
const response = new Response(
Expand All @@ -100,9 +142,15 @@ export const POST = withApiHandler(
return response;
}

// Hoist idempotencyKey so the catch block can call fail() regardless of
// where in the try block the error was thrown.
const idempotencyKey = req.headers.get('idempotency-key');

try {
// --- CSRF ---------------------------------------------------------------
assertMutationCsrf(req);

// --- Rate limit ---------------------------------------------------------
const ip = getClientIp(req);
if (!(await checkRateLimit(ip, 'api/commitments/fund'))) {
throw new TooManyRequestsError(
Expand All @@ -112,22 +160,22 @@ export const POST = withApiHandler(
);
}

const id = params.id;
if (!id?.trim()) {
throw new ValidationError('Commitment ID is required');
}
// --- Route parameter validation -----------------------------------------
const id = validateCommitmentId(params.id);

// ─── Idempotency Check & Protection ────────────────────────────────────
// Ensures repeated requests with same key don't create duplicate funding txs
const idempotencyKey = req.headers.get('idempotency-key');
// --- Idempotency key validation ------------------------------------------
let isIdempotentRetry = false;

if (idempotencyKey) {
if (idempotencyKey.length > MAX_IDEMPOTENCY_KEY_LENGTH) {
throw new ValidationError(
`Idempotency-Key must not exceed ${MAX_IDEMPOTENCY_KEY_LENGTH} characters`,
);
}
const record = await idempotencyService.getRecord(idempotencyKey);
if (record) {
isIdempotentRetry = true;
if (record.status === 'COMPLETED') {
// Cache hit - return saved response immediately
diagnosticsService.completeOperation(operationId, 'success', undefined, {
cacheHit: true,
idempotent: true,
Expand All @@ -136,7 +184,6 @@ export const POST = withApiHandler(
response.headers.set('X-Idempotent-Replay', 'true');
return response;
} else if (record.status === 'STARTED') {
// Another request with same key is in progress - block to prevent duplicates
diagnosticsService.completeOperation(
operationId,
'degraded',
Expand All @@ -148,11 +195,10 @@ export const POST = withApiHandler(
);
}
}
// Begin tracking this idempotency key
await idempotencyService.start(idempotencyKey);
}

// ─── Request Validation ───────────────────────────────────────────────────
// --- Body parsing -------------------------------------------------------
let body: unknown;
try {
body = await req.json();
Expand All @@ -165,9 +211,41 @@ export const POST = withApiHandler(
throw new ValidationError('Invalid request data', validation.error.issues);
}

const callerAddress = validation.data.callerAddress;
const { callerAddress: bodyAddress, network: clientNetwork } = validation.data;

// --- Stellar address format validation ----------------------------------
if (bodyAddress !== undefined) {
validateStellarAddress(bodyAddress, 'callerAddress');
}

// --- Network passphrase check -------------------------------------------
if (clientNetwork !== undefined) {
const { networkPassphrase } = getBackendConfig();
if (!clientNetwork || clientNetwork !== networkPassphrase) {
throw new ValidationError(
'Client network passphrase does not match server configuration',
{ expected: networkPassphrase, received: clientNetwork },
);
}
}

// --- Session-based authorization ----------------------------------------
// Derive the authenticated wallet identity from the server-side session
// token (Bearer header or session cookie). We do NOT rely solely on the
// client-supplied callerAddress to establish identity.
const auth = verifyAuth(req);
const sessionAddress = auth.address;

if (bodyAddress !== undefined && bodyAddress !== sessionAddress) {
throw new ForbiddenError(
'callerAddress in request body does not match the authenticated session identity',
{ commitmentId: id },
);
}

const callerAddress = sessionAddress;

// ─── Commitment State Check (Precondition Invariant) ───────────────────────
// --- Commitment state validation ----------------------------------------
const commitment = await getCommitmentFromChain(id);

if (!commitment) {
Expand All @@ -189,8 +267,10 @@ export const POST = withApiHandler(
throw statusError;
}

// INVARIANT: Ownership immutability - only owner can fund
if (callerAddress && callerAddress !== commitment.ownerAddress) {
// --- Ownership check (server-side) --------------------------------------
// Ownership is verified against the on-chain record, not inferred from
// client state.
if (callerAddress !== commitment.ownerAddress) {
const authError = new ForbiddenError(
'Only the commitment owner may fund this commitment',
{ commitmentId: id },
Expand All @@ -204,19 +284,34 @@ export const POST = withApiHandler(
throw authError;
}

// ─── Execute Funding on Chain ──────────────────────────────────────────────
// This is the critical operation - any failure here should not create ledger effects
// --- Numeric commitment amount sanity check -----------------------------
const numericAmount = Number(commitment.amount);
if (!Number.isFinite(numericAmount) || numericAmount <= 0) {
throw new ValidationError('Commitment amount from chain is invalid or non-positive', {
amount: commitment.amount,
commitmentId: id,
});
}

// --- On-chain funding ---------------------------------------------------
const funded = await fundEscrowOnChain({
commitmentId: id,
callerAddress,
});

// Capture fundedAt once so the idempotency cache stores the exact
// same timestamp that is returned in the response body — a retry with
// the same Idempotency-Key will replay this stable value.
// --- Server response shape validation -----------------------------------
if (funded.commitmentId !== id) {
throw new ValidationError('Chain service returned mismatched commitmentId', {
expected: id,
received: funded.commitmentId,
});
}
if (funded.txHash !== undefined && typeof funded.txHash !== 'string') {
throw new ValidationError('Chain service returned invalid txHash type');
}

const fundedAt = new Date().toISOString();

// ─── Success Response & Idempotency Caching ───────────────────────────────
const responseData = {
commitmentId: id,
txHash: funded.txHash,
Expand Down Expand Up @@ -249,20 +344,14 @@ export const POST = withApiHandler(
}
return response;
} catch (error) {
// Clean up idempotency record on failure to allow retry
const idempotencyKey = req.headers.get('idempotency-key');
if (idempotencyKey) {
await idempotencyService.fail(idempotencyKey);
}
// BackendError is thrown by the contracts layer (e.g. blockchain 502).
// It is not an ApiError, so withApiHandler would otherwise swallow
// the status code and return 500. Return the structured error response
// directly so callers receive the correct HTTP status (e.g. 502).

if (error instanceof BackendError) {
return NextResponse.json(toBackendErrorResponse(error), { status: error.status });
}

// Record failure in diagnostics for observability
const errorMessage =
error instanceof Error ? error.message : 'Unknown error during funding operation';
diagnosticsService.completeOperation(operationId, 'failure', errorMessage, {
Expand Down
Loading