diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index b33bacaa..26f19bb4 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -20,10 +20,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' @@ -37,7 +37,7 @@ jobs: CI: true - name: Upload coverage report - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v4 if: always() with: name: coverage-report @@ -50,10 +50,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' @@ -62,7 +62,7 @@ jobs: run: npm ci - name: Validate OpenAPI spec - run: bash validate-openapi.sh + run: bash scripts/validate-openapi.sh - name: Check API route coverage run: bash scripts/check-route-coverage.sh diff --git a/src/app/api/commitments/route.ts b/src/app/api/commitments/route.ts index 53185314..6d3ed610 100644 --- a/src/app/api/commitments/route.ts +++ b/src/app/api/commitments/route.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto'; import { NextRequest } from 'next/server'; import { z } from 'zod'; import { fail, ok, methodNotAllowed } from '@/lib/backend/apiResponse'; @@ -10,97 +11,170 @@ import { logInfo, logWarn } from '@/lib/backend/logger'; import { MAX_PAGE_SIZE } from '@/lib/backend/pagination'; import { checkRateLimit, getRateLimitWindowSeconds } from '@/lib/backend/rateLimit'; import { requireAuth } from '@/lib/backend/requireAuth'; -import { - getUserCommitmentsFromChain, - createCommitmentOnChain, -} from '@/lib/backend/services/contracts'; +import { getUserCommitmentsFromChain, createCommitmentOnChain } from '@/lib/backend/services/contracts'; import { validateSupportedAsset, validateStellarAddress } from '@/lib/backend/validation'; import { withApiHandler } from '@/lib/backend/withApiHandler'; -const CommitmentsQuerySchema = z.object({ - ownerAddress: z.string().min(1, 'ownerAddress is required'), - page: z.coerce.number().min(1).default(1), - pageSize: z.coerce.number().min(1).max(MAX_PAGE_SIZE).default(10), +const MAX_CHAIN_COMMITMENTS_PROCESSED = 5000; +const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000; +const PENDING_TTL_MS = 10 * 60 * 1000; +const UNKNOWN_TTL_MS = IDEMPOTENCY_TTL_MS; +type IdempotencyStatus = 'PENDING' | 'SUCCESS' | 'FAILED' | 'UNKNOWN'; +type IdempotencyEntry = { + status: IdempotencyStatus; + requestHash: string; + result?: unknown; + error?: { code: string; message: string; status: number }; + expiresAt: number; +}; +const idempotencyStore = new Map(); +const idempotencyLocks = new Map>(); + +function withIdempotencyLock(key: string, action: () => T | Promise): Promise { + const previous = idempotencyLocks.get(key) ?? Promise.resolve(); + const current = previous.then(action, action); + idempotencyLocks.set(key, current); + const cleanup = () => { + if (idempotencyLocks.get(key) === current) idempotencyLocks.delete(key); + }; + void current.then(cleanup, cleanup); + return current; +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'; + if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`; + const obj = value as Record; + return `{${Object.keys(obj).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`).join(',')}}`; +} + +function hashRequestPayload(payload: unknown): string { + return createHash('sha256').update(stableStringify(payload ?? {})).digest('hex'); +} + +function getIdempotency(key: string): IdempotencyEntry | undefined { + const now = Date.now(); + const entry = idempotencyStore.get(key); + if (!entry) return undefined; + if (entry.expiresAt <= now) { + if (entry.status === 'PENDING') { + const unknownEntry: IdempotencyEntry = { + ...entry, + status: 'UNKNOWN', + expiresAt: now + UNKNOWN_TTL_MS, + }; + idempotencyStore.set(key, unknownEntry); + return unknownEntry; + } + idempotencyStore.delete(key); + return undefined; + } + return entry; +} +function setPending(key: string, requestHash: string) { + idempotencyStore.set(key, { status: 'PENDING', requestHash, expiresAt: Date.now() + PENDING_TTL_MS }); +} +function setSuccess(key: string, requestHash: string, result: unknown) { + idempotencyStore.set(key, { status: 'SUCCESS', requestHash, result, expiresAt: Date.now() + IDEMPOTENCY_TTL_MS }); +} +function setFailure(key: string, requestHash: string, error: { code: string; message: string; status: number }) { + idempotencyStore.set(key, { status: 'FAILED', requestHash, error, expiresAt: Date.now() + IDEMPOTENCY_TTL_MS }); +} +function setUnknown(key: string, requestHash: string, error: { code: string; message: string; status: number }) { + idempotencyStore.set(key, { status: 'UNKNOWN', requestHash, error, expiresAt: Date.now() + UNKNOWN_TTL_MS }); +} +function isAmbiguousCommitmentError(error: unknown): boolean { + if (!(error instanceof Error)) return true; + const message = error.message.toLowerCase(); + return /timeout|network|connection|socket|abort|unavailable|nonce|sequence|broadcast|unknown/i.test(message); +} + +const QuerySchema = z.object({ + ownerAddress: z.string().min(1), + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(10), status: z.enum(['ACTIVE', 'SETTLED', 'VIOLATED', 'EARLY_EXIT', 'UNKNOWN']).optional(), type: z.string().optional(), minCompliance: z.coerce.number().min(0).max(100).optional(), }); +const CreateSchema = z.object({ + ownerAddress: z.string().min(1), + asset: z.string().min(1), + amount: z.string().min(1).regex(/^\d+(?:\.\d+)?$/, 'Invalid amount').refine((v) => Number(v) > 0, 'Invalid amount'), + durationDays: z.number().int().positive().max(36500, 'Invalid durationDays'), + maxLossBps: z.number().int().min(0).max(10000, 'Invalid maxLossBps'), + metadata: z.record(z.unknown()).optional(), +}); + +type CommitmentPreparation = + | { kind: 'PROCEED'; data: { ownerAddress: string; asset: string; amount: string; durationDays: number; maxLossBps: number; metadata?: Record } } + | { kind: 'PENDING' } + | { kind: 'SUCCESS'; result: unknown } + | { kind: 'UNKNOWN' } + | { kind: 'CONFLICT' }; + /** - * Defensive upper bound on how many raw commitments from the chain a single - * request will map/filter/paginate over. `getUserCommitmentsFromChain` - * already caches and rate-limits the actual chain read, but this bounds the - * in-memory CPU/allocation cost of *this route's own* per-request work - * (map -> filter -> slice) regardless of how large a single owner's - * commitment set grows to. Exceeding it is logged (see `logWarn` below) so - * an unexpectedly large account is observable rather than just slow. + * Idempotency state machine for commitment creation: + * - No entry -> PENDING when a create attempt is claimed. + * - PENDING -> SUCCESS after an unambiguous on-chain success. + * - PENDING -> FAILED after a non-ambiguous on-chain failure. + * - PENDING -> UNKNOWN when the pending lease expires or an ambiguous error occurs. + * - FAILED -> PENDING when the same payload is retried. + * - UNKNOWN and SUCCESS are not retryable until TTL expiry. + * - Validation failures occur before PENDING is written and leave no idempotency state. + * - Client cancellation does not cancel the on-chain attempt; it settles later. */ -const MAX_CHAIN_COMMITMENTS_PROCESSED = 5000; - -interface CreateCommitmentRequestBody { - ownerAddress: string; - asset: string; - amount: string; - durationDays: number; - maxLossBps: number; - metadata?: Record; +function prepareCommitmentCreation( + idempotencyKey: string, + requestHash: string, + parsedBody: unknown, +): Promise { + return withIdempotencyLock(idempotencyKey, () => { + const existing = getIdempotency(idempotencyKey); + if (existing) { + if (existing.requestHash !== requestHash) return { kind: 'CONFLICT' }; + if (existing.status === 'PENDING') return { kind: 'PENDING' }; + if (existing.status === 'SUCCESS') return { kind: 'SUCCESS', result: existing.result }; + if (existing.status === 'UNKNOWN') return { kind: 'UNKNOWN' }; + // FAILED entries are definitive, non-ambiguous failures; retrying the + // same payload under the same key is therefore safe. + } + const bodyResult = CreateSchema.safeParse(parsedBody); + if (!bodyResult.success) throw new ValidationError('Invalid request body', bodyResult.error.issues); + const { ownerAddress, asset, amount, durationDays, maxLossBps, metadata } = bodyResult.data; + try { validateSupportedAsset(asset, 'asset'); } catch { throw new ValidationError('Asset is not supported. Supported assets: XLM, USDC.'); } + try { validateStellarAddress(ownerAddress, 'ownerAddress'); } catch { throw new ValidationError('Invalid ownerAddress: must be a valid Stellar address (G... format).'); } + setPending(idempotencyKey, requestHash); + return { + kind: 'PROCEED', + data: { ownerAddress, asset, amount, durationDays, maxLossBps, ...(metadata !== undefined ? { metadata } : {}) }, + }; + }); } -const COMMITMENTS_CORS_POLICY = { - GET: { access: 'first-party' }, - POST: { access: 'first-party' }, -} satisfies CorsRoutePolicy; - +const COMMITMENTS_CORS_POLICY = { GET: { access: 'first-party' as const }, POST: { access: 'first-party' as const } } satisfies CorsRoutePolicy; export const OPTIONS = createCorsOptionsHandler(COMMITMENTS_CORS_POLICY); export const GET = withApiHandler( async (req: NextRequest, _context, correlationId) => { - const startedAt = Date.now(); - - // Authorization before any query parsing or chain work: a request with - // no valid session is rejected immediately, not after we've already - // paid for parsing/rate-limit/chain-read work on its behalf. requireAuth(req); - const { searchParams } = new URL(req.url); - const queryResult = CommitmentsQuerySchema.safeParse( - Object.fromEntries(searchParams.entries()), - ); - - if (!queryResult.success) { - throw new ValidationError('Invalid query parameters', queryResult.error.issues); - } - + const queryResult = QuerySchema.safeParse(Object.fromEntries(searchParams.entries())); + if (!queryResult.success) throw new ValidationError('Invalid query parameters', queryResult.error.issues); const { ownerAddress, page, pageSize, status, type, minCompliance } = queryResult.data; + validateStellarAddress(ownerAddress, 'ownerAddress'); const ip = getClientIp(req); - if (!(await checkRateLimit(ip, 'api/commitments'))) { - throw new TooManyRequestsError( - 'Too many requests. Please try again later.', - undefined, - getRateLimitWindowSeconds('api/commitments'), - ); - } - - const chainStartedAt = Date.now(); - const commitments = await getUserCommitmentsFromChain(ownerAddress, { - requestId: correlationId, - }); - const chainDurationMs = Date.now() - chainStartedAt; - + if (!(await checkRateLimit(ip, 'api/commitments'))) throw new TooManyRequestsError('Too many requests. Please try again later.', undefined, getRateLimitWindowSeconds('api/commitments')); + const commitments = await getUserCommitmentsFromChain(ownerAddress, { requestId: correlationId }); + let source = commitments; let truncated = false; - let sourceCommitments = commitments; - if (commitments.length > MAX_CHAIN_COMMITMENTS_PROCESSED) { + if (source.length > MAX_CHAIN_COMMITMENTS_PROCESSED) { + source = source.slice(0, MAX_CHAIN_COMMITMENTS_PROCESSED); truncated = true; - sourceCommitments = commitments.slice(0, MAX_CHAIN_COMMITMENTS_PROCESSED); - logWarn(req, '[api/commitments] chain result exceeded processing bound, truncating', { - correlationId, - ownerAddress, - rawCount: commitments.length, - boundApplied: MAX_CHAIN_COMMITMENTS_PROCESSED, - }); + logWarn(req, '[api/commitments] chain result exceeded processing bound, truncating', { correlationId, ownerAddress, rawCount: commitments.length, boundApplied: MAX_CHAIN_COMMITMENTS_PROCESSED }); } - - let mapped = sourceCommitments.map((c: any) => ({ + let mapped = source.map((c: any) => ({ commitmentId: String(c.id ?? c.commitmentId), ownerAddress: c.ownerAddress, asset: c.asset, @@ -115,102 +189,50 @@ export const GET = withApiHandler( expiresAt: c.expiresAt, contractVersion: c.contractVersion, })); - if (status) mapped = mapped.filter((c) => c.status === status); if (type) mapped = mapped.filter((c) => c.type.toLowerCase() === type.toLowerCase()); - if (minCompliance !== undefined) - mapped = mapped.filter((c) => c.complianceScore >= minCompliance); - + if (minCompliance !== undefined) mapped = mapped.filter((c) => c.complianceScore >= minCompliance); const total = mapped.length; const start = (page - 1) * pageSize; - const items = mapped.slice(start, start + pageSize); - - logInfo(req, '[api/commitments] list served', { - correlationId, - ownerAddress, - durationMs: Date.now() - startedAt, - chainDurationMs, - rawCount: commitments.length, - filteredCount: total, - returnedCount: items.length, - page, - pageSize, - filters: { status: status ?? null, type: type ?? null, minCompliance: minCompliance ?? null }, - truncated, - }); - - return ok({ items, page, pageSize, total }, undefined, 200, correlationId); + logInfo(req, '[api/commitments] list served', { correlationId, ownerAddress, rawCount: commitments.length, filteredCount: total, returnedCount: mapped.slice(start, start + pageSize).length, page, pageSize, truncated }); + return ok({ items: mapped.slice(start, start + pageSize), page, pageSize, total }, undefined, 200, correlationId); }, { cors: COMMITMENTS_CORS_POLICY, enableETag: true }, ); export const POST = withApiHandler( async (req: NextRequest, _context, correlationId) => { + requireAuth(req); assertMutationCsrf(req); - const ip = getClientIp(req); - // Use the dedicated write-route key so tighter limits apply - if (!(await checkRateLimit(ip, 'api/commitments/create'))) { - throw new TooManyRequestsError( - 'Too many requests. Please try again later.', - undefined, - getRateLimitWindowSeconds('api/commitments/create'), - ); - } - - const parsed = await parseJsonWithLimit(req, { - limitBytes: JSON_BODY_LIMITS.commitmentsCreate, - }); - const body = (parsed ?? {}) as Partial; - const { ownerAddress, asset, amount, durationDays, maxLossBps, metadata } = body; - - if (!ownerAddress || typeof ownerAddress !== 'string') { - return fail('BAD_REQUEST', 'Invalid ownerAddress', undefined, 400, correlationId); - } - if (!asset || typeof asset !== 'string') { - return fail('BAD_REQUEST', 'Invalid asset', undefined, 400, correlationId); - } + if (!(await checkRateLimit(ip, 'api/commitments/create'))) throw new TooManyRequestsError('Too many requests. Please try again later.', undefined, getRateLimitWindowSeconds('api/commitments/create')); + const idempotencyKey = req.headers.get('Idempotency-Key'); + if (!idempotencyKey || idempotencyKey.length < 8 || idempotencyKey.length > 255) return fail('BAD_REQUEST', 'Idempotency-Key header is required and must be between 8 and 255 characters', undefined, 400, correlationId); + const parsed = await parseJsonWithLimit(req, { limitBytes: JSON_BODY_LIMITS.commitmentsCreate }); + const requestHash = hashRequestPayload(parsed ?? {}); + const preparation = await prepareCommitmentCreation(idempotencyKey, requestHash, parsed ?? {}); + if (preparation.kind === 'PENDING') return fail('CONFLICT', 'A request with this Idempotency-Key is already being processed. Retry after a moment.', undefined, 409, correlationId); + if (preparation.kind === 'CONFLICT') return fail('CONFLICT', 'Idempotency-Key was already used with a different request payload', undefined, 409, correlationId); + if (preparation.kind === 'SUCCESS') return ok(preparation.result, undefined, 200, correlationId); + if (preparation.kind === 'UNKNOWN') return fail('CONFLICT', 'The previous request outcome is unknown. Review on-chain state before retrying.', undefined, 409, correlationId); + const { ownerAddress, asset, amount, durationDays, maxLossBps, metadata } = preparation.data; + let result; try { - validateSupportedAsset(asset, 'asset'); - } catch { - throw new ValidationError('Asset is not supported. Supported assets: XLM, USDC.'); + result = await createCommitmentOnChain({ ownerAddress, asset, amount, durationDays, maxLossBps, ...(metadata !== undefined ? { metadata } : {}) }, { requestId: correlationId }); + } catch (error) { + const errorResponse = { code: 'INTERNAL', message: error instanceof Error ? error.message : 'Commitment creation failed', status: 500 }; + if (isAmbiguousCommitmentError(error)) { + setUnknown(idempotencyKey, requestHash, errorResponse); + } else { + setFailure(idempotencyKey, requestHash, errorResponse); + } + throw error; } - try { - validateStellarAddress(ownerAddress, 'ownerAddress'); - } catch { - return fail( - 'BAD_REQUEST', - 'Invalid ownerAddress: must be a valid Stellar address (G... format).', - undefined, - 400, - correlationId, - ); - } - if (!amount || isNaN(Number(amount))) { - return fail('BAD_REQUEST', 'Invalid amount', undefined, 400, correlationId); - } - if (!durationDays || durationDays <= 0) { - return fail('BAD_REQUEST', 'Invalid durationDays', undefined, 400, correlationId); - } - if (maxLossBps == null || maxLossBps < 0) { - return fail('BAD_REQUEST', 'Invalid maxLossBps', undefined, 400, correlationId); - } - const result = await createCommitmentOnChain( - { - ownerAddress, - asset, - amount, - durationDays, - maxLossBps, - ...(metadata !== undefined ? { metadata } : {}), - }, - { requestId: correlationId }, - ); - + setSuccess(idempotencyKey, requestHash, result); return ok(result, undefined, 201, correlationId); }, { cors: COMMITMENTS_CORS_POLICY }, ); -const _405 = methodNotAllowed(['GET', 'POST']); -export { _405 as PUT, _405 as PATCH, _405 as DELETE }; +const _305 = methodNotAllowed(['GET', 'POST']); +export { _305 as PUT, _305 as PATCH, _305 as DELETE }; diff --git a/src/app/api/commitments/search/route.ts b/src/app/api/commitments/search/route.ts index 25302a87..1e08d6d9 100644 --- a/src/app/api/commitments/search/route.ts +++ b/src/app/api/commitments/search/route.ts @@ -80,10 +80,17 @@ import { createHash } from 'crypto'; */ const MAX_CHAIN_COMMITMENTS_PROCESSED = 5000; +/** + * Maximum page size for search results. + * + * Invariant I3: requests with pageSize > MAX_PAGE_SIZE return 400. + */ +const MAX_PAGE_SIZE = 100; + /** * Maximum number of concurrent in-flight search requests permitted across * the server process. Requests that arrive while the ceiling is reached - * are rejected with 429 rather than queuing unboundedly. + * are rejected with 429 rather than queueing unboundedly. * * Invariant I9: enforced at the start of the handler before any expensive * work (chain read, cache lookup). @@ -97,6 +104,17 @@ const MAX_CONCURRENT_SEARCH_REQUESTS = 50; */ let currentSearchRequests = 0; +type ChainCommitments = Awaited>; + +/** + * Module-level single-flight table for on-chain commitment reads. + * + * Duplicate search requests for the same wallet and mode share one chain + * read. The promise is removed when the read settles so a later retry + * re-attempts the read instead of replaying a settled success or failure. + */ +const inflightChainReads = new Map>(); + /** * Allowed `CommitmentStatus` filter values. * Maps user-facing values to the on-chain `ChainCommitmentStatus` type. @@ -151,12 +169,16 @@ const CommitmentSearchQuerySchema = z.object({ // Pagination params are parsed separately by pagination.ts utilities, // but we accept them in the same query string. - page: z.coerce.number().min(1).default(1).optional(), - pageSize: z.coerce.number().min(1).max(100).default(10).optional(), + page: z.coerce.number().min(1).default(1), + pageSize: z.coerce.number().min(1).max(MAX_PAGE_SIZE).default(10), + + // Sorting params are also parsed separately, but we validate allowed + // values here to fail fast and enforce Invariant I4. + sortBy: z.enum(SORTABLE_FIELDS).optional(), + sortOrder: z.enum(['asc', 'desc']).optional(), - // Sorting params are also parsed separately. - sortBy: z.string().optional(), - sortOrder: z.string().optional(), + /** Bypass cache and fetch a fresh chain snapshot. */ + refresh: z.enum(['true', 'false']).optional(), }); // ─── Mapped search result shape ─────────────────────────────────────────────── @@ -194,6 +216,64 @@ interface SearchSnapshot { truncated: boolean; } +interface SearchCacheValue { + data: CommitmentSearchItem[]; + meta: Record; + filters: Record; + snapshot?: SearchSnapshot; + invariants?: SearchInvariants; + _telemetry?: { + returnedCount: number; + total: number; + filteredCount: number; + truncated: boolean; + }; +} + +// ─── Cache payload schema ───────────────────────────────────────────────────── + +const SearchCacheValueSchema = z.object({ + data: z.array( + z.object({ + commitmentId: z.string().min(1), + ownerAddress: z.string().min(1), + asset: z.string().min(1), + amount: z.string(), + status: z.enum(COMMITMENT_STATUS_VALUES), + riskType: z.string(), + complianceScore: z.number().min(0).max(100), + currentValue: z.string(), + feeEarned: z.string(), + violationCount: z.number().int().min(0), + createdAt: z.string(), + expiresAt: z.string(), + }), + ), + meta: z.record(z.string(), z.any()), + filters: z.record(z.string(), z.any()), + snapshot: z + .object({ + queryKey: z.string(), + generatedAt: z.string(), + source: z.enum(['cache', 'chain']), + rawCount: z.number().int().min(0), + processedCount: z.number().int().min(0), + rejectedRecords: z.number().int().min(0), + duplicateRecords: z.number().int().min(0), + truncated: z.boolean(), + }) + .optional(), + invariants: z.any().optional(), + _telemetry: z + .object({ + returnedCount: z.number().int().min(0), + total: z.number().int().min(0), + filteredCount: z.number().int().min(0), + truncated: z.boolean(), + }) + .optional(), +}); + // ─── Helpers ────────────────────────────────────────────────────────────────── /** @@ -220,13 +300,15 @@ function buildSearchCacheKey( ownerAddress: string, filters: Record, ): string { + const normalizedOwner = normalizeAddress(ownerAddress); const orderedFilters = Object.keys(filters) .sort() .reduce>((acc, key) => { - acc[key] = filters[key]; + const value = filters[key]; + acc[key] = key === 'asset' && typeof value === 'string' ? value.trim().toUpperCase() : value; return acc; }, {}); - const payload = JSON.stringify({ ownerAddress, ...orderedFilters }); + const payload = JSON.stringify({ ownerAddress: normalizedOwner, ...orderedFilters }); const hash = createHash('sha256').update(payload).digest('hex').slice(0, 16); return CacheKey.commitmentSearch(hash); } @@ -235,6 +317,40 @@ function normalizeAddress(address: string): string { return address.trim().toUpperCase(); } +/** + * Returns a single shared promise for the wallet's on-chain commitment read. + * + * If a read for the same owner and mode (normal or refresh) is already in + * flight, all search requests await the same promise. The entry is removed + * when the read settles so a later retry issues a fresh chain read rather + * than replaying a settled success or failure. + */ +function fetchCommitmentsWithSingleFlight( + ownerAddress: string, + mode: 'normal' | 'refresh' = 'normal', +): Promise { + const normalizedOwner = normalizeAddress(ownerAddress); + const operationKey = `${normalizedOwner}:${mode}`; + const existing = inflightChainReads.get(operationKey); + if (existing) return existing; + + const read = getUserCommitmentsFromChain(normalizedOwner); + inflightChainReads.set(operationKey, read); + + read + .finally(() => { + if (inflightChainReads.get(operationKey) === read) { + inflightChainReads.delete(operationKey); + } + }) + .catch(() => { + // Cleanup-only derived promise; the original `read` rejection is + // observed by the caller awaiting `fetchCommitmentsWithSingleFlight`. + }); + + return read; +} + function parseFiniteNumber(value: unknown, fallback = 0): number { const parsed = typeof value === 'string' ? Number(value.replace(/,/g, '')) : Number(value); return Number.isFinite(parsed) ? parsed : fallback; @@ -247,6 +363,7 @@ function normalizeSearchItem(raw: any): CommitmentSearchItem | null { const amount = parseFiniteNumber(raw.amount); const complianceScore = parseFiniteNumber(raw.complianceScore); const violationCount = parseFiniteNumber(raw.violationCount); + const status = raw.status as ChainCommitmentStatus; if ( !commitmentId || @@ -256,7 +373,8 @@ function normalizeSearchItem(raw: any): CommitmentSearchItem | null { complianceScore < 0 || complianceScore > 100 || violationCount < 0 || - !Number.isInteger(violationCount) + !Number.isInteger(violationCount) || + !COMMITMENT_STATUS_VALUES.includes(status) ) { return null; } @@ -266,7 +384,7 @@ function normalizeSearchItem(raw: any): CommitmentSearchItem | null { ownerAddress, asset, amount: String(amount), - status: raw.status as ChainCommitmentStatus, + status, riskType: inferRiskType(raw), complianceScore, currentValue: String(parseFiniteNumber(raw.currentValue)), @@ -385,6 +503,8 @@ export const OPTIONS = createCorsOptionsHandler(SEARCH_CORS_POLICY); export const GET = withApiHandler( async (req: NextRequest, _context, correlationId) => { const startedAt = Date.now(); + let counted = false; + try { // Authorization before any query parsing, cache lookup, or chain work. const authenticatedReq = requireAuth(req); @@ -394,7 +514,11 @@ export const GET = withApiHandler( if (!(await checkRateLimit(ip, 'api/commitments/search'))) { throw new TooManyRequestsError(); } + if (currentSearchRequests >= MAX_CONCURRENT_SEARCH_REQUESTS) { + throw new TooManyRequestsError(); + } currentSearchRequests++; + counted = true; // 2. Parse & validate query params with Zod const { searchParams } = new URL(req.url); @@ -412,15 +536,6 @@ export const GET = withApiHandler( throw new ForbiddenError('Cannot search commitments for another wallet'); } - // ── Scope enforcement ──────────────────────────────────────────────────── - // The authenticated user may only query their own commitments. - // This prevents one wallet from enumerating another wallet's positions. - if (authedReq.user.address !== ownerAddress) { - throw new ForbiddenError( - 'ownerAddress does not match the authenticated wallet address.', - ); - } - // 3. Parse pagination & sort via pagination.ts helpers let paginationParams; let sortParams; @@ -431,6 +546,8 @@ export const GET = withApiHandler( if (err instanceof PaginationParseError) { return paginationErrorResponse(err, correlationId); } + throw err; + } // 4. Build cache key and check cache const cacheKey = buildSearchCacheKey(normalizedOwnerAddress, { @@ -445,26 +562,55 @@ export const GET = withApiHandler( pageSize: paginationParams.pageSize, }); - const cached = await cache.get<{ - data: CommitmentSearchItem[]; - meta: Record; - filters: Record; - diagnostics: Record; - }>(cacheKey); + let cached: SearchCacheValue | null = null; + + if (queryResult.data.refresh === 'true') { + logInfo(req, '[api/commitments/search] refresh requested; bypassing search cache', { + correlationId, + ownerAddress: normalizedOwnerAddress, + }); + } else { + try { + cached = await cache.get(cacheKey); + } catch (cacheError) { + logWarn(req, '[api/commitments/search] cache read failed; falling through to chain', { + correlationId, + ownerAddress: normalizedOwnerAddress, + error: cacheError instanceof Error ? cacheError.message : String(cacheError), + }); + } + } if (cached !== null) { - const totalDurationMs = Date.now() - startedAt; + const validation = SearchCacheValueSchema.safeParse(cached); + if (!validation.success) { + logWarn( + req, + '[api/commitments/search] cached value failed validation; treating as cache miss', + { + correlationId, + ownerAddress: normalizedOwnerAddress, + issues: validation.error.issues, + }, + ); + cached = null; + } + } + + if (cached !== null) { + const durationMs = Date.now() - startedAt; logInfo(req, '[api/commitments/search] served from cache', { correlationId, ownerAddress: normalizedOwnerAddress, - durationMs: Date.now() - startedAt, + durationMs, cacheHit: true, }); - return ok( + const { _telemetry, ...responseData } = cached; + const response = ok( { - ...cached, + ...responseData, snapshot: { - ...(cached as { snapshot?: SearchSnapshot }).snapshot, + ...(cached.snapshot ?? { generatedAt: new Date().toISOString() }), source: 'cache', }, }, @@ -472,11 +618,35 @@ export const GET = withApiHandler( 200, correlationId, ); + + attachTelemetryHeaders(response, { + durationMs, + cacheHit: true, + returnedCount: _telemetry?.returnedCount ?? 0, + total: _telemetry?.total ?? 0, + filteredCount: _telemetry?.filteredCount ?? 0, + truncated: _telemetry?.truncated ?? false, + }); + + return response; } // 5. Fetch from chain const chainStartedAt = Date.now(); - const commitments = await getUserCommitmentsFromChain(normalizedOwnerAddress); + const readMode = queryResult.data.refresh === 'true' ? 'refresh' : 'normal'; + let commitments: ChainCommitments; + try { + commitments = await fetchCommitmentsWithSingleFlight(normalizedOwnerAddress, readMode); + } catch (chainError) { + logWarn(req, '[api/commitments/search] chain read failed; retrying once', { + correlationId, + ownerAddress: normalizedOwnerAddress, + error: chainError instanceof Error ? chainError.message : String(chainError), + }); + // The failed read has already been evicted from the single-flight table; + // retry with a fresh chain read so we do not replay a settled failure. + commitments = await fetchCommitmentsWithSingleFlight(normalizedOwnerAddress, readMode); + } const chainDurationMs = Date.now() - chainStartedAt; let truncated = false; @@ -499,6 +669,7 @@ export const GET = withApiHandler( normalizedItems.filter((item): item is CommitmentSearchItem => item !== null), ); let items = dedupedItems; + const filterStartedAt = Date.now(); // 7. Apply filters if (asset) { @@ -566,8 +737,27 @@ export const GET = withApiHandler( invariants, }; + const telemetryPayload = { + returnedCount: result.data.length, + total: result.meta.total, + filteredCount: items.length, + truncated, + }; + // 12. Cache for short TTL - await cache.set(cacheKey, responsePayload, CacheTTL.COMMITMENT_SEARCH); + try { + await cache.set( + cacheKey, + { ...responsePayload, _telemetry: telemetryPayload }, + CacheTTL.COMMITMENT_SEARCH, + ); + } catch (cacheError) { + logWarn(req, '[api/commitments/search] cache write failed; returning fresh chain result', { + correlationId, + ownerAddress: normalizedOwnerAddress, + error: cacheError instanceof Error ? cacheError.message : String(cacheError), + }); + } logInfo(req, '[api/commitments/search] served from chain', { correlationId, @@ -583,154 +773,25 @@ export const GET = withApiHandler( truncated, }); - // Strip internal _telemetry from the payload before returning - const { _telemetry, ...responseData } = cached; - const response = ok(responseData, undefined, 200, correlationId); - - // ── Invariant I7: telemetry headers on cache hit ──────────────────── - attachTelemetryHeaders(response, { - durationMs, - cacheHit: true, - returnedCount: _telemetry?.returnedCount ?? 0, - total: _telemetry?.total ?? 0, - filteredCount: _telemetry?.filteredCount ?? 0, - truncated: _telemetry?.truncated ?? false, - }); - - return response; - } - - // 5. Fetch from chain - const chainStartedAt = Date.now(); - const commitments = await getUserCommitmentsFromChain(ownerAddress); - const chainDurationMs = Date.now() - chainStartedAt; - - // ── Invariant I5: memory bound ──────────────────────────────────────── - let truncated = false; - let sourceCommitments = commitments; - if (commitments.length > MAX_CHAIN_COMMITMENTS_PROCESSED) { - truncated = true; - sourceCommitments = commitments.slice(0, MAX_CHAIN_COMMITMENTS_PROCESSED); - logWarn(req, '[api/commitments/search] chain result exceeded processing bound, truncating', { - correlationId, - ownerAddress, - rawCount: commitments.length, - boundApplied: MAX_CHAIN_COMMITMENTS_PROCESSED, - }); - } - - // 6. Map to search items - let items: CommitmentSearchItem[] = sourceCommitments.map((c: any) => ({ - commitmentId: String(c.id ?? c.commitmentId), - ownerAddress: c.ownerAddress, - asset: c.asset, - amount: typeof c.amount === 'bigint' ? String(c.amount) : String(c.amount), - status: c.status as ChainCommitmentStatus, - riskType: inferRiskType(c), - complianceScore: c.complianceScore ?? 0, - currentValue: - typeof c.currentValue === 'bigint' - ? String(c.currentValue) - : String(c.currentValue ?? '0'), - feeEarned: String(c.feeEarned ?? '0'), - violationCount: c.violationCount ?? 0, - createdAt: c.createdAt ?? new Date().toISOString(), - expiresAt: c.expiresAt ?? new Date().toISOString(), - })); - - // 7. Apply filters - if (asset) { - const normalizedAsset = asset.toUpperCase(); - items = items.filter((c) => c.asset.toUpperCase() === normalizedAsset); - } - - if (commitmentId) { - const normalizedQuery = commitmentId.toUpperCase(); - items = items.filter((c) => c.commitmentId.toUpperCase().includes(normalizedQuery)); - } - - if (status) { - items = items.filter((c) => c.status === status); - } - - if (riskType) { - items = items.filter((c) => c.riskType.toLowerCase() === riskType.toLowerCase()); - } + const response = ok(responsePayload, undefined, 200, correlationId); - if (minCompliance !== undefined) { - items = items.filter((c) => c.complianceScore >= minCompliance); - } - - const filteredCount = items.length; - - // 8. Sort with stable ordering - items.sort((a, b) => compareItems(a, b, sortParams.sortBy, sortParams.sortOrder)); - - // 9. Paginate - const result = paginateArray(items, paginationParams); - - // 10. Build response with applied filter metadata - const telemetryPayload = { - returnedCount: result.data.length, - total: result.meta.total, - filteredCount, - truncated, - }; - - const responsePayload = { - data: result.data, - meta: result.meta, - filters: { - asset: asset ?? null, - commitmentId: commitmentId ?? null, - status: status ?? null, - riskType: riskType ?? null, - minCompliance: minCompliance ?? null, - sortBy: sortParams.sortBy, - sortOrder: sortParams.sortOrder, - }, - }; - - // ── Invariant I6: only cache successful responses ───────────────────── - // The cache.set call happens after the response is built. If chain read - // throws, execution never reaches here and nothing is cached. - await cache.set( - cacheKey, - { ...responsePayload, _telemetry: telemetryPayload }, - CacheTTL.COMMITMENT_SEARCH, - ); - - const durationMs = Date.now() - startedAt; - logInfo(req, '[api/commitments/search] served from chain', { - correlationId, - ownerAddress, - durationMs, - chainDurationMs, - rawCount: commitments.length, - filteredCount, - returnedCount: result.data.length, - total: result.meta.total, - cacheHit: false, - truncated, - }); - - const response = ok(responsePayload, undefined, 200, correlationId); - - // ── Invariant I7: telemetry headers on chain response ───────────────── - attachTelemetryHeaders(response, { - durationMs, - chainDurationMs, - cacheHit: false, - returnedCount: result.data.length, - total: result.meta.total, - filteredCount, - truncated, - }); + // ── Invariant I7: telemetry headers on chain response ───────────────── + attachTelemetryHeaders(response, { + durationMs: Date.now() - startedAt, + chainDurationMs, + cacheHit: false, + returnedCount: result.data.length, + total: result.meta.total, + filteredCount: items.length, + truncated, + }); - return response; + return response; } finally { // Always decrement the semaphore regardless of success or error. - currentSearchRequests--; + if (counted) { + currentSearchRequests--; + } } }, { cors: SEARCH_CORS_POLICY }, @@ -739,4 +800,4 @@ export const GET = withApiHandler( // ─── Disallow other methods ─────────────────────────────────────────────────── const _405 = methodNotAllowed(['GET']); -export { _405 as POST, _405 as PUT, _405 as PATCH, _405 as DELETE }; +export { _405 as POST, _405 as PUT, _405 as PATCH, _405 as DELETE }; \ No newline at end of file diff --git a/src/components/CommitmentDetailActions.test.tsx b/src/components/CommitmentDetailActions.test.tsx index acdff579..768676e1 100644 --- a/src/components/CommitmentDetailActions.test.tsx +++ b/src/components/CommitmentDetailActions.test.tsx @@ -5,8 +5,8 @@ // reason surfaced from an authoritative ownership check in the parent page, // not left permanently clickable regardless of who is viewing the page. -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { afterEach, describe, it, expect, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { CommitmentDetailActions } from './CommitmentDetailActions'; @@ -19,6 +19,8 @@ vi.mock('@/components/settlement/SettlementEligibilityChecklist', () => ({ function noop() {} +afterEach(cleanup); + describe('CommitmentDetailActions — Report Issue authorization gating', () => { it('renders Report Issue as enabled when no disabled reason is given (backward compatible default)', () => { render( @@ -32,7 +34,6 @@ describe('CommitmentDetailActions — Report Issue authorization gating', () => ); const button = screen.getByRole('button', { name: 'Report an Issue' }); expect(button).not.toBeDisabled(); - expect(button).toHaveAttribute('aria-disabled', 'false'); }); it('disables Report Issue and exposes the reason when reportIssueDisabledReason is set', () => { @@ -48,7 +49,6 @@ describe('CommitmentDetailActions — Report Issue authorization gating', () => ); const button = screen.getByRole('button', { name: 'Report an Issue' }); expect(button).toBeDisabled(); - expect(button).toHaveAttribute('aria-disabled', 'true'); expect(button).toHaveAttribute('title', 'Connect your wallet to manage this commitment.'); }); diff --git a/src/hooks/marketplace-state.test.ts b/src/hooks/marketplace-state.test.ts index 5a7e2888..252efce4 100644 --- a/src/hooks/marketplace-state.test.ts +++ b/src/hooks/marketplace-state.test.ts @@ -7,27 +7,12 @@ import { useMarketplaceStats } from '@/hooks/useMarketplaceStats'; // Fetch mock plumbing (shared) // ─────────────────────────────────────────────────────────────────────── -interface FetchCall { - url: string; - res: () => { status: number; json?: unknown; headers?: Record } | Promise<{ status: number; json?: unknown; headers?: Record }>; -} - -const fetchCalls: FetchCall[] = []; - -function registerFetch(matcher: (url: string) => boolean, res: FetchCall['res']) { - fetchCalls.push({ url: matcher.toString(), res }); -} - let fetchMock: ReturnType; beforeEach(() => { vi.useFakeTimers(); - fetchCalls.length = 0; fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - for (let i = fetchCalls.length - 1; i >= 0; i--) { - // Fall through: try last registered first - } if (url.includes('/api/marketplace/listings')) { const qs = new URLSearchParams(url.split('?')[1] ?? ''); const page = Number(qs.get('page') || 1); @@ -134,7 +119,7 @@ describe('usePaginatedListings — state machine transitions', () => { expect(result.current.listings.map((l) => l.id)).toEqual(['A', 'B']); await act(() => result.current.loadMore()); - await waitFor(() => expect(fetchMock ?? dupFetch).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(dupFetch).toHaveBeenCalledTimes(2)); expect(result.current.listings.map((l) => l.id)).toEqual(['A', 'B', 'C']); }); diff --git a/src/lib/commitments/repository.ts b/src/lib/commitments/repository.ts new file mode 100644 index 00000000..a0717d15 --- /dev/null +++ b/src/lib/commitments/repository.ts @@ -0,0 +1,230 @@ +import type { Commitment } from "./state-machine"; +import type { CommitmentType } from "../../types/commitment"; + +export interface CommitmentRecord extends Commitment { + userId: string; + version: number; + type?: CommitmentType; + asset?: string; + amount?: string; +} + +export class OptimisticLockError extends Error { + constructor( + public readonly id: string, + public readonly expectedVersion: number, + public readonly actualVersion: number, + ) { + super(`Commitment ${id} version conflict: expected ${expectedVersion}, actual ${actualVersion}`); + this.name = "OptimisticLockError"; + } +} + +export class DuplicateIdempotencyError extends Error { + constructor( + public readonly idempotencyKey: string, + public readonly userId: string, + ) { + super(`Commitment with idempotence key ${idempotencyKey} already exists for user ${userId}`); + this.name = "DuplicateIdempotencyError"; + } +} + +export class InvalidStateTransitionError extends Error { + constructor( + public readonly id: string, + public readonly fromState: string, + public readonly toState: string, + ) { + super(`Commitment ${id} cannot transition from ${fromState} to ${toState}`); + this.name = "InvalidStateTransitionError"; + } +} + +const VALID_STATES: ReadonlySet = new Set([ + "pending", + "submitted", + "confirmed", + "rejected", + "cancelled", +]); + +const ALLOWED_TRANSITIONS: Readonly> = { + pending: new Set(["submitted", "cancelled"]), + submitted: new Set(["confirmed", "rejected", "cancelled"]), + confirmed: new Set(["cancelled"]), + rejected: new Set(["submitted"]), + cancelled: new Set(), +}; + +const INITIAL_STATE = "pending"; +const MAX_SEARCH_RESULTS = 100; + +export interface CommitmentRepository { + findById(id: string): Promise; + findByIdempotencyKey(idempotencyKey: string, userId: string): Promise; + create(record: CommitmentRecord): Promise; + update(record: CommitmentRecord, expectedVersion: number): Promise; + listByUser(userId: string): Promise; + search(userId: string, query: string, state?: string): Promise; +} + +export class InMemoryCommitmentRepository implements CommitmentRepository { + private readonly store = new Map(); + + async findById(id: string): Promise { + return this.store.get(id) ?? null; + } + + async findByIdempotencyKey(idempotencyKey: string, userId: string): Promise { + for (const record of this.store.values()) { + if (record.userId === userId && record.idempotencyKey === idempotencyKey) { + return record; + } + } + return null; + } + + private assertValidRecord(record: CommitmentRecord): void { + if (!record.id || typeof record.id !== "string") { + throw new Error("Commitment id is required"); + } + if (!record.userId || typeof record.userId !== "string") { + throw new Error("Commitment userId is required"); + } + if (!record.idempotencyKey || typeof record.idempotencyKey !== "string") { + throw new Error("Commitment idempotencyKey is required"); + } + if (!record.state || typeof record.state !== "string") { + throw new Error("Commitment state is required"); + } + if (!VALID_STATES.has(record.state)) { + throw new InvalidStateTransitionError(record.id, "", record.state); + } + } + + private assertValidTransition(id: string, fromState: string, toState: string): void { + if (fromState === toState) { + return; + } + const allowed = ALLOWED_TRANSITIONS[fromState]; + if (!allowed || !allowed.has(toState)) { + throw new InvalidStateTransitionError(id, fromState, toState); + } + } + + private assertImmutableFields(current: CommitmentRecord, next: CommitmentRecord): void { + for (const field of ["type", "asset", "amount"] as const) { + if (!Object.is(current[field], next[field])) { + throw new Error(`Commitment ${current.id} ${field} cannot be changed`); + } + } + } + + private assertIdempotentPayload(existing: CommitmentRecord, incoming: CommitmentRecord): void { + for (const field of ["type", "asset", "amount"] as const) { + if (!Object.is(existing[field], incoming[field])) { + throw new DuplicateIdempotencyError(existing.idempotencyKey, existing.userId); + } + } + } + + async create(record: CommitmentRecord): Promise { + this.assertValidRecord(record); + + if (record.state !== INITIAL_STATE) { + throw new InvalidStateTransitionError(record.id, "", record.state); + } + + // Idempotent creation: if the same user retries with the same idempotency key, + // return the existing commitment instead of creating a duplicate. + const existing = await this.findByIdempotencyKey(record.idempotencyKey, record.userId); + if (existing) { + this.assertIdempotentPayload(existing, record); + return existing; + } + + if (this.store.has(record.id)) { + throw new Error(`Commitment ${record.id} already exists`); + } + + const now = new Date(); + const created: CommitmentRecord = { + ...record, + state: INITIAL_STATE, + version: 1, + createdAt: record.createdAt instanceof Date ? record.createdAt : now, + updatedAt: record.updatedAt instanceof Date ? record.updatedAt : now, + }; + this.store.set(created.id, created); + return created; + } + + async update(record: CommitmentRecord, expectedVersion: number): Promise { + this.assertValidRecord(record); + + const current = this.store.get(record.id); + if (!current) { + throw new Error(`Commitment ${record.id} not found`); + } + + if (current.idempotencyKey !== record.idempotencyKey) { + throw new Error(`Commitment ${record.id} idempotencyKey cannot be changed`); + } + + if (current.version !== expectedVersion) { + throw new OptimisticLockError(record.id, expectedVersion, current.version); + } + + this.assertValidTransition(current.id, current.state, record.state); + this.assertImmutableFields(current, record); + + // Idempotent no-op: re-applying the same state with the same terms must not + // produce a new version or invalidate concurrent readers. + if (current.state === record.state) { + return current; + } + + const updated: CommitmentRecord = { + ...current, + ...record, + id: current.id, + userId: current.userId, + idempotencyKey: current.idempotencyKey, + state: record.state, + version: current.version + 1, + updatedAt: new Date(), + }; + this.store.set(updated.id, updated); + return updated; + } + + async listByUser(userId: string): Promise { + return [...this.store.values()] + .filter((record) => record.userId === userId) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + } + + async search(userId: string, query: string, state?: string): Promise { + if (typeof query !== "string") { + throw new Error("Search query must be a string"); + } + const normalized = query.trim().toLowerCase(); + if (state !== undefined && !VALID_STATES.has(state)) { + throw new Error(`Invalid state filter: ${state}`); + } + return [...this.store.values()] + .filter((record) => { + if (record.userId !== userId) return false; + if (state && record.state !== state) return false; + if (!normalized) return true; + return ( + record.id.toLowerCase().includes(normalized) || + (record.asset?.toLowerCase().includes(normalized) ?? false) || + (record.amount?.toLowerCase().includes(normalized) ?? false) + ); + }) + .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) + .slice(0, MAX_SEARCH_RESULTS); + } +} diff --git a/src/lib/commitments/service.ts b/src/lib/commitments/service.ts new file mode 100644 index 00000000..238a029e --- /dev/null +++ b/src/lib/commitments/service.ts @@ -0,0 +1,219 @@ +import { + CommitmentEvent, + createCommitment, + transition, + CommitmentStateError, + StaleSubmissionError, + TERMINAL_STATES, + SUBMITTABLE_STATES, +} from "./state-machine"; +import { + CommitmentRecord, + CommitmentRepository, + InMemoryCommitmentRepository, + OptimisticLockError, +} from "./repository"; +import type { CreateCommitmentInput } from "./validation"; + +export class CommitmentNotFoundError extends Error { + constructor(id: string) { + super(`Commitment ${id} not found`); + this.name = "CommitmentNotFoundError"; + } +} + +export class CommitmentForbiddenError extends Error { + constructor(message = "Forbidden") { + super(message); + this.name = "CommitmentForbiddenError"; + } +} + +export class CommitmentConflictError extends Error { + constructor(message: string) { + super(message); + this.name = "CommitmentConflictError"; + } +} + +export type ResolveOutcome = + | { type: "success"; txHash: string } + | { type: "reject"; reason?: string } + | { type: "error"; error: string }; + +let defaultRepository: CommitmentRepository | null = null; +let defaultService: CommitmentService | null = null; + +export function getDefaultRepository(): CommitmentRepository { + if (!defaultRepository) { + defaultRepository = new InMemoryCommitmentRepository(); + } + return defaultRepository; +} + +export function getCommitmentService(): CommitmentService { + if (!defaultService) { + defaultService = new CommitmentService(getDefaultRepository()); + } + return defaultService; +} + +export class CommitmentService { + constructor(private readonly repository: CommitmentRepository) {} + + async create(input: CreateCommitmentInput & { userId: string }): Promise { + const { userId, id, idempotencyKey } = input; + + const existingById = await this.repository.findById(id); + if (existingById) { + if (existingById.userId !== userId) { + throw new CommitmentNotFoundError(id); + } + if (existingById.idempotencyKey !== idempotencyKey) { + throw new CommitmentConflictError(`Commitment "${id}" already exists with a different idempotency key`); + } + return existingById; + } + + const existingByKey = await this.repository.findByIdempotencyKey(idempotencyKey, userId); + if (existingByKey) { + if (existingByKey.id !== id) { + throw new CommitmentConflictError(`Idempotency key "${idempotencyKey}" is already used by commitment "${existingByKey.id}"`); + } + return existingByKey; + } + + const commitment = createCommitment({ + id, + idempotencyKey, + expiresAt: input.expiresAt, + }); + + const record: CommitmentRecord = { + ...commitment, + userId, + version: 1, + type: input.type, + asset: input.asset, + amount: input.amount, + }; + + return this.repository.create(record); + } + + async beginSubmission(id: string, userId: string, submissionId: string): Promise { + let record = await this.getOwnedRecord(id, userId); + record = await this.maybeExpire(record); + + if (record.state === "submitting") { + if (record.currentSubmissionId === submissionId) { + return record; + } + throw new CommitmentConflictError(`Submission already in progress with submissionId "${record.currentSubmissionId}"`); + } + + if (!SUBMITTABLE_STATES.has(record.state)) { + throw new CommitmentConflictError(`Cannot start submission from state "${record.state}"`); + } + + const next = this.applyTransition(record, { type: "submit", submissionId }); + return this.optimisticUpdate(record, next); + } + + async resolve( + id: string, + userId: string, + submissionId: string, + outcome: ResolveOutcome, + ): Promise { + const record = await this.getOwnedRecord(id, userId); + + if (record.currentSubmissionId === submissionId && record.state === "submitting") { + // active submission, process outcome + } else if (record.lastSubmissionId === submissionId && TERMINAL_STATES.has(record.state)) { + // idempotent duplicate callback + return record; + } else { + throw new CommitmentConflictError("No active submission matching the provided submissionId"); + } + + const event: CommitmentEvent = + outcome.type === "success" + ? { type: "success", submissionId, transactionHash: outcome.txHash } + : outcome.type === "reject" + ? { type: "reject", submissionId, reason: outcome.reason } + : { type: "error", submissionId, error: outcome.error }; + + const next = this.applyTransition(record, event); + return this.optimisticUpdate(record, next); + } + + async cancel(id: string, userId: string, reason?: string): Promise { + const record = await this.getOwnedRecord(id, userId); + const next = this.applyTransition(record, { type: "cancel", reason }); + return this.optimisticUpdate(record, next); + } + + async listByUser(userId: string): Promise { + return this.repository.listByUser(userId); + } + + async search(userId: string, query: string, state?: string): Promise { + return this.repository.search(userId, query, state); + } + + private async maybeExpire(record: CommitmentRecord): Promise { + if (record.expiresAt && record.expiresAt.getTime() <= Date.now() && !TERMINAL_STATES.has(record.state) && record.state !== "submitting") { + const next = this.applyTransition(record, { type: "expire" }); + return this.optimisticUpdate(record, next); + } + return record; + } + + private async getOwnedRecord(id: string, userId: string): Promise { + const record = await this.repository.findById(id); + if (!record) { + throw new CommitmentNotFoundError(id); + } + if (record.userId !== userId) { + throw new CommitmentForbiddenError(); + } + return record; + } + + private applyTransition(record: CommitmentRecord, event: CommitmentEvent): CommitmentRecord { + try { + const next = transition(record, event); + return { + ...next, + userId: record.userId, + version: record.version, + type: record.type, + asset: record.asset, + amount: record.amount, + }; + } catch (error) { + if (error instanceof StaleSubmissionError) { + throw new CommitmentConflictError(error.message); + } + throw error; + } + } + + private async optimisticUpdate( + current: CommitmentRecord, + next: CommitmentRecord, + ): Promise { + try { + return await this.repository.update( + { ...next, version: current.version }, + current.version, + ); + } catch (error) { + if (error instanceof OptimisticLockError) { + throw new CommitmentConflictError(error.message); + } + throw error; + } + } +} diff --git a/src/lib/commitments/state-machine.ts b/src/lib/commitments/state-machine.ts new file mode 100644 index 00000000..cfd9a490 --- /dev/null +++ b/src/lib/commitments/state-machine.ts @@ -0,0 +1,267 @@ +/** + * Commitment state machine for the Commitments API. + * + * This module defines the core domain invariants for commitment lifecycle + * transitions. It provides pure functions to create and transition + * commitment records, enforcing: + * + * - Deterministic state transitions (a single valid target per event). + * - No transitions out of terminal states (confirmed, rejected, cancelled, expired). + * - A single in-flight submission per commitment (currentSubmissionId). + * - Stale responses (wrong submissionId) are rejected. + * - Duplicate-submission protection via idempotency key at creation time. + * - Explicit retry from failed state; never automatic/on-chain repeated action. + */ + +export type CommitmentState = + | 'pending' + | 'submitting' + | 'failed' + | 'confirmed' + | 'rejected' + | 'cancelled' + | 'expired'; + +export interface Commitment { + /** Unique identifier for the commitment. */ + id: string; + /** Client-provided idempotency key used to dedupe create requests. */ + idempotencyKey: string; + /** Current lifecycle state. */ + state: CommitmentState; + /** Number of submission attempts (0 before first submit). */ + attempt: number; + /** Identifier of the current in-flight submission attempt, if any. */ + currentSubmissionId: string | null; + /** Identifier of the last completed submission attempt, for duplicate callback protection. */ + lastSubmissionId?: string; + /** Hash of the successful on-chain transaction, if confirmed. */ + transactionHash?: string; + /** Human-readable error/reason from the last failed/rejected attempt. */ + lastError?: string; + /** Creation timestamp. */ + createdAt: Date; + /** Last update timestamp. */ + updatedAt: Date; + /** Optional deadline after which the commitment may expire. */ + expiresAt?: Date; +} + +export type CommitmentEvent = + | { type: 'submit'; submissionId: string } + | { type: 'success'; submissionId: string; transactionHash: string } + | { type: 'reject'; submissionId: string; reason?: string } + | { type: 'error'; submissionId: string; error: string } + | { type: 'cancel'; reason?: string } + | { type: 'expire' }; + +/** States from which no further transitions are allowed. */ +export const TERMINAL_STATES: ReadonlySet = new Set([ + 'confirmed', + 'rejected', + 'cancelled', + 'expired', +]); + +/** States from which a new submission attempt may be started. */ +export const SUBMITTAMLE_STATES: ReadonlySet = new Set([ + 'pending', + 'failed', +]); + +export class CommitmentStateError extends Error { + constructor(message: string) { + super(message); + this.name = 'CommitmentStateError'; + } +} + +export class StaleSubmissionError extends CommitmentStateError { + constructor(message: string) { + super(message); + this.name = 'StaleSubmissionError'; + } +} + +/** Returns true if the state is terminal and cannot accept any further events. */ +export function isTerminalState(state: CommitmentState): boolean { + return TERMINAL_STATES.has(state); +} + +/** Returns true if, at a high level, the event may be allowed from the given state. */ +export function canTransition(state: CommitmentState, event: CommitmentEvent): boolean { + switch (state) { + case 'pending': + return event.type === 'submit' || event.type === 'cancel' || event.type === 'expire'; + case 'submitting': + return ( + event.type === 'success' || + event.type === 'reject' || + event.type === 'error' || + event.type === 'cancel' + ); + case 'failed': + return event.type === 'submit' || event.type === 'cancel' || event.type === 'expire'; + default: + return false; + } +} + +/** + * Creates a new commitment in the 'pending' state. + * + * @throws CommitmentStateError if required fields are missing. + */ +export function createCommitment(input: { + id: string; + idempotencyKey: string; + expiresAt?: Date; +}): Commitment { + if (!input.id || !input.idempotencyKey) { + throw new CommitmentStateError('id and idempotencyKey are required'); + } + const now = new Date(); + return { + id: input.id, + idempotencyKey: input.idempotencyKey, + state: 'pending', + attempt: 0, + currentSubmissionId: null, + createdAt: now, + updatedAt: now, + expiresAt: input.expiresAt, + }; +} + +/** + * Applies an event to a commitment and returns a new commitment representing + * the next state. The original commitment is not mutated. + * + * @param commitment The current commitment. + * @param event The event to apply. + * @returns a new commitment with the next state. + * @throws CommitmentStateError for invalid state transitions. + * @throws StaleSubmissionError when a response carries a stale submissionId. + */ +export function transition(commitment: Commitment, event: CommitmentEvent): Commitment { + const now = new Date(); + + // Terminal states reject all events. + if (isTerminalState(commitment.state)) { + throw new CommitmentStateError( + `Cannot apply event "${event.type}" to terminal state "${commitment.state}"`, + ); + } + + // High-level transition validity. + if (!canTransition(commitment.state, event)) { + throw new CommitmentStateError( + Cannot apply event "${event.type}" in state "${commitment.state}"`, + ); + } + + switch (event.type) { + case 'submit': { + const attempt = commitment.state === 'pending' ? 1 : commitment.attempt + 1; + return { + ...commitment, + state: 'submitting', + attempt, + currentSubmissionId: event.submissionId, + lastSubmissionId: undefined, + lastError: undefined, + updatedAt: now, + }; + } + + case 'success': + case 'reject': + case 'error': { + // Must be exactly in 'submitting' with the matching submission id. + if (commitment.state !== 'submitting') { + throw new CommitmentStateError( + Cannot apply event "${event.type}" unless state is "submitting" (current: "${commitment.state}")`, + ); + } + if (commitment.currentSubmissionId !== event.submissionId) { + throw new StaleSubmissionError( + Submission id "${event.submissionId}" does not match current submission `" + + "${commitment.currentSubmissionId}"`, + ); + } + + const base = { + ...commitment, + currentSubmissionId: null, + lastSubmissionId: event.submissionId, + updatedAt: now, + }; + + if (event.type === 'success') { + return { + ...base, + state: 'confirmed', + transactionHash: event.transactionHash, + lastError: undefined, + }; + } + if (event.type === 'reject') { + return { + ...base, + state: 'rejected', + lastError: event.reason ?? 'Rejected by user', + }; + } + // error + return { + ...base, + state: 'failed', + lastError: event.error, + }; + } + + case 'cancel': { + return { + ...commitment, + state: 'cancelled', + currentSubmissionId: null, + lastError: event.reason, + updatedAt: now, + }; + } + + case 'expire': { + if (commitment.expiresAt && commitment.expiresAt > now) { + // Invariant: expiration event only valid after expiry timestamp. + throw new CommitmentStateError( + 'Cannot expire before the configured expiration time', + ); + } + return { + ...commitment, + state: 'expired', + currentSubmissionId: null, + updatedAt: now, + }; + } + + default: + throw new Error(`Unsupported event type: ${(event as CommitmentEvent).type}`); + } +} + +/** + * Verifies that an incoming create request is idempotent with the stored commitment. + * + * If a commitment with the same idempotency key exists: + * - If it has the same id, it's a retry; the stored commitment should be returned. + * - If it has a different id, the request is invalid (duplicate idempotency key). + */ +export function assertIdempotencyMatch(existing: Commitment | null, input: { id: string; idempotencyKey: string }): void { + if (!existing) return; + if (existing.idempotencyKey === input.idempotencyKey && existing.id !== input.id) { + throw new CommitmentStateError( + Idempotency key "${input.idempotencyKey}" is already used by commitment "${existing.id}"`, + ); + } +} diff --git a/src/lib/commitments/validation.ts b/src/lib/commitments/validation.ts new file mode 100644 index 00000000..a28ba57b --- /dev/null +++ b/src/lib/commitments/validation.ts @@ -0,0 +1,201 @@ +import type { CommitmentType } from "../../types/commitment"; + +export class ApiValidationError extends Error { + public readonly status = 400; + public readonly details: unknown; + + constructor(message: string, details?: unknown) { + super(message); + this.name = "ApiValidationError"; + this.details = details; + } +} + +const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; +const KEY_PATTERN = /^[A-Za-z0-9_-]{1,255}$/; +const TYPES: readonly CommitmentType[] = ["Safe", "Balanced", "Aggressive"]; +const STATES = ["draft", "submitted", "resolved", "cancelled"] as const; +export type CommitmentState = (typeof STATES)[number]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function requireString(value: unknown, name: string, maxLength: number, pattern?: RegExp): string { + if (typeof value !== "string") { + throw new ApiValidationError(`${name} must be a string`, { field: name }); + } + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new ApiValidationError(`${name} must not be empty`, { field: name }); + } + if (trimmed.length > maxLength) { + throw new ApiValidationError(`${name} must be at most ${maxLength} characters`, { field: name }); + } + if (pattern && !pattern.test(trimmed)) { + throw new ApiValidationError(`${name} contains invalid characters`, { field: name }); + } + return trimmed; +} + +function optionalString(value: unknown, name: string, maxLength: number, pattern?: RegExp): string | undefined { + if (value === undefined || value === null || value === "") return undefined; + if (typeof value !== "string") { + throw new ApiValidationError(`${name} must be a string`, { field: name }); + } + const trimmed = value.trim(); + if (trimmed.length === 0) return undefined; + if (trimmed.length > maxLength) { + throw new ApiValidationError(`${name} must be at most ${maxLength} characters`, { field: name }); + } + if (pattern && !pattern.test(trimmed)) { + throw new ApiValidationError(`${name} contains invalid characters`, { field: name }); + } + return trimmed; +} + +function parseOptionalDate(value: unknown, name: string): Date | undefined { + if (value === undefined || value === null || value === "") return undefined; + if (typeof value !== "string") { + throw new ApiValidationError(`${name} must be an ISO-8601 date string`, { field: name }); + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new ApiValidationError(`${name} must be a valid ISO-8601 date string`, { field: name }); + } + return date; +} + +function assertEnum(value: unknown, name: string, allowed: readonly T[]): T { + if (typeof value !== "string" || !(allowed as readonly string[]).includes(value)) { + throw new ApiValidationError(`${name} must be one of: ${allowed.join(", ")}`, { field: name }); + } + return value as T; +} + +function optionalState(value: unknown, name: string): CommitmentState | undefined { + if (value === undefined || value === null || value === "") return undefined; + if (typeof value !== "string" || !(STATES as readonly string[]).includes(value)) { + throw new ApiValidationError(`${name} must be one of: ${STATES.join(", ")}`, { field: name }); + } + return value as CommitmentState; +} + +export interface CreateCommitmentInput { + id: string; + idempotencyKey: string; + expiresAt?: Date; + type?: CommitmentType; + asset?: string; + amount?: string; +} + +export interface SubmitCommitmentInput { + action: "submit"; + id: string; + submissionId: string; + idempotencyKey: string; + expectedState: CommitmentState; +} + +export interface ResolveCommitmentInput { + action: "resolve"; + id: string; + submissionId: string; + idempotencyKey: string; + expectedState: CommitmentState; + outcome: + | { type: "success"; txHash: string } + | { type: "reject"; reason?: string } + | { type: "error"; error: string }; +} + +export interface CancelCommitmentInput { + action: "cancel"; + id: string; + idempotencyKey: string; + expectedState: CommitmentState; + reason?: string; +} + +export type CommitmentActionInput = + | (CreateCommitmentInput & { action: "create" }) + | SubmitCommitmentInput + | ResolveCommitmentInput + | CancelCommitmentInput; + +export function validateCreateCommitmentInput(input: unknown): CreateCommitmentInput { + if (!isRecord(input)) { + throw new ApiValidationError("Request body must be an object"); + } + const id = requireString(input.id, "id", 128, ID_PATTERN); + const idempotencyKey = requireString(input.idempotencyKey, "idempotencyKey", 255, KEY_PATTERN); + const expiresAt = parseOptionalDate(input.expiresAt, "expiresAt"); + const type = input.type === undefined ? undefined : assertEnum(input.type, "type", TYPES); + const asset = optionalString(input.asset, "asset", 64); + const amount = optionalString(input.amount, "amount", 64); + + return { id, idempotencyKey, expiresAt, type, asset, amount }; +} + +export function validateCommitmentAction(input: unknown): CommitmentActionInput { + if (!isRecord(input)) { + throw new ApiValidationError("Request body must be an object"); + } + const action = requireString(input.action, "action", 20); + if (action === "create") { + return { action: "create", ...validateCreateCommitmentInput(input) }; + } + if (action === "submit") { + const id = requireString(input.id, "id", 128, ID_PATTERN); + const submissionId = requireString(input.submissionId, "submissionId", 255, KEY_PATTERN); + const idempotencyKey = requireString(input.idempotencyKey, "idempotencyKey", 255, KEY_PATTERN); + const expectedState = assertEnum(input.expectedState, "expectedState", ["draft", "submitted"] as const); + return { action, id, submissionId, idempotencyKey, expectedState }; + } + if (action === "resolve") { + const id = requireString(input.id, "id", 128, ID_PATTERN); + const submissionId = requireString(input.submissionId, "submissionId", 255, KEY_PATTERN); + const idempotencyKey = requireString(input.idempotencyKey, "idempotencyKey", 255, KEY_PATTERN); + const expectedState = assertEnum(input.expectedState, "expectedState", ["submitted", "resolved"] as const); + if (!isRecord(input.outcome)) { + throw new ApiValidationError("outcome must be an object", { field: "outcome" }); + } + const type = requireString(input.outcome.type, "outcome.type", 20); + if (type === "success") { + const txHash = requireString(input.outcome.txHash, "outcome.txHash", 128); + return { action, id, submissionId, idempotencyKey, expectedState, outcome: { type: "success", txHash } }; + } + if (type === "reject") { + const reason = optionalString(input.outcome.reason, "outcome.reason", 500); + return { action, id, submissionId, idempotencyKey, expectedState, outcome: { type: "reject", reason } }; + } + if (type === "error") { + const error = requireString(input.outcome.error, "outcome.error", 500); + return { action, id, submissionId, idempotencyKey, expectedState, outcome: { type: "error", error } }; + } + throw new ApiValidationError("outcome.type must be success, reject, or error", { field: "outcome.type" }); + } + if (action === "cancel") { + const id = requireString(input.id, "id", 128, ID_PATTERN); + const idempotencyKey = requireString(input.idempotencyKey, "idempotencyKey", 255, KEY_PATTERN); + const expectedState = assertEnum(input.expectedState, "expectedState", ["draft", "submitted", "cancelled"] as const); + const reason = optionalString(input.reason, "reason", 500); + return { action, id, idempotencyKey, expectedState, reason }; + } + throw new ApiValidationError("action must be one of: create, submit, resolve, cancel", { field: "action" }); +} + +export interface SearchCommitmentsInput { + query: string; + state?: CommitmentState; +} + +export function validateSearchParams(params: unknown): SearchCommitmentsInput { + if (!isRecord(params)) { + throw new ApiValidationError("Search parameters must be an object"); + } + const query = optionalString(params.q ?? params.query, "q", 100) ?? ""; + const state = optionalState(params.state, "state"); + return { query, state }; +} diff --git a/src/types/commitment.ts b/src/types/commitment.ts index cb949baa..1e5b56f4 100644 --- a/src/types/commitment.ts +++ b/src/types/commitment.ts @@ -6,12 +6,29 @@ * stable, UI-oriented interface that isn't tied to the backend internals. */ -/** On-chain lifecycle states. */ export type CommitmentStatus = 'Active' | 'Settled' | 'Violated' | 'Early Exit'; -/** Risk profile. */ export type CommitmentType = 'Safe' | 'Balanced' | 'Aggressive'; +export type IdempotencyKey = string; + +export type CommitmentOperationStatus = 'idle' | 'pending' | 'succeeded' | 'failed' | 'cancelled'; + +export type CommitmentStatusTransitions = { + readonly [S in CommitmentStatus]?: readonly CommitmentStatus[]; +}; + +export const COMMIDMENT_STATUS_TRANSITIONS: CommitmentStatusTransitions = { + Active: ['Settled', 'Violated', 'Early Exit'], + Settled: [], + Violated: [], + 'Early Exit': [], +} as const; + +export function canTransitionCommitmentStatus(from: CommitmentStatus, to: CommitmentStatus): boolean { + return COMMIDMENT_STATUS_TRANSITIONS[from]?.includes(to) ?? false; +} + /** UI-facing commitment shape used by MyCommitmentsGrid and related views. */ export interface Commitment { id: string; @@ -27,6 +44,10 @@ export interface Commitment { complianceScore?: number; maxLoss?: string; currentDrawdown?: string; + idempotencyKey?: IdempotencyKey; + operationStatus?: CommitmentOperationStatus; + lastOperationAt?: string; + version?: number; /** ISO-8601 date string (legacy field). */ createdDate?: string; /** ISO-8601 date string (legacy field). */ @@ -36,3 +57,45 @@ export interface Commitment { /** ISO-8601 date string. */ expiresAt?: string; } + +export type CommitmentOperationStatusTransitions = { + readonly [S in CommitmentOperationStatus]?: readonly CommitmentOperationStatus[]; +}; + +export const COMMITMENT_OPERATION_STATUS_TRANSITIONS: CommitmentOperationStatusTransitions = { + idle: ['pending'], + pending: ['succeeded', 'failed', 'cancelled'], + failed: ['pending', 'cancelled'], + succeeded: [], + cancelled: [], +} as const; + +export function canTransitionCommitmentOperationStatus( + from: CommitmentOperationStatus, + to: CommitmentOperationStatus, +): boolean { + return COMMITMENT_OPERATION_STATUS_TRANSITIONS[from]?.includes(to) ?? false; +} + +export function isDuplicatePendingSubmission( + existing: Pick, + incomingKey: IdempotencyKey, +): boolean { + return existing.idempotencyKey === incomingKey && existing.operationStatus === 'pending'; +} + +export function isRetryableFailure( + operationStatus: CommitmentOperationStatus, + attempts: number, + maxAttempts: number, +): boolean { + return operationStatus === 'failed' && attempts >= 0 && attempts < maxAttempts; +} + +export function isStaleVersion(expectedVersion: number | undefined, actualVersion: number | undefined): boolean { + return expectedVersion !== undefined && expectedVersion !== actualVersion; +} + +export function nextVersion(version: number | undefined): number { + return (version ?? 0) + 1; +} \ No newline at end of file diff --git a/tests/api/commitments-route.test.ts b/tests/api/commitments-route.test.ts index 48a9a3b5..9f203b09 100644 --- a/tests/api/commitments-route.test.ts +++ b/tests/api/commitments-route.test.ts @@ -117,6 +117,7 @@ describe('GET /api/commitments', () => { vi.clearAllMocks(); mockedRequireAuth.mockImplementation((req) => req as any); mockedCheckRateLimit.mockResolvedValue(true); + mockedValidateStellarAddress.mockReturnValue(true); mockedGetUserCommitmentsFromChain.mockResolvedValue(ALL_COMMITMENTS); }); @@ -336,8 +337,8 @@ describe('POST /api/commitments', () => { vi.clearAllMocks(); mockedAssertMutationCsrf.mockReturnValue(undefined); mockedCheckRateLimit.mockResolvedValue(true); - mockedValidateSupportedAsset.mockReturnValue(undefined); - mockedValidateStellarAddress.mockReturnValue(undefined); + mockedValidateSupportedAsset.mockReturnValue(true); + mockedValidateStellarAddress.mockReturnValue(true); mockedCreateCommitmentOnChain.mockResolvedValue(mockCreateResult); }); @@ -420,7 +421,7 @@ describe('POST /api/commitments', () => { expect(result.status).toBe(400); expect(result.data.success).toBe(false); - expect(result.data.error.code).toBe('BAD_REQUEST'); + expect(result.data.error.code).toBe('VALIDATION_ERROR'); expect(result.data.error.message).toContain('ownerAddress'); }); @@ -434,7 +435,7 @@ describe('POST /api/commitments', () => { const result = await parseResponse(response); expect(result.status).toBe(400); - expect(result.data.error.code).toBe('BAD_REQUEST'); + expect(result.data.error.code).toBe('VALIDATION_ERROR'); }); it('returns 400 for missing asset', async () => { @@ -448,7 +449,7 @@ describe('POST /api/commitments', () => { expect(result.status).toBe(400); expect(result.data.success).toBe(false); - expect(result.data.error.code).toBe('BAD_REQUEST'); + expect(result.data.error.code).toBe('VALIDATION_ERROR'); expect(result.data.error.message).toContain('asset'); }); @@ -486,7 +487,7 @@ describe('POST /api/commitments', () => { expect(result.status).toBe(400); expect(result.data.success).toBe(false); - expect(result.data.error.code).toBe('BAD_REQUEST'); + expect(result.data.error.code).toBe('VALIDATION_ERROR'); expect(result.data.error.message).toContain('Stellar address'); }); @@ -500,7 +501,7 @@ describe('POST /api/commitments', () => { const result = await parseResponse(response); expect(result.status).toBe(400); - expect(result.data.error.code).toBe('BAD_REQUEST'); + expect(result.data.error.code).toBe('VALIDATION_ERROR'); expect(result.data.error.message).toContain('amount'); }); @@ -514,7 +515,7 @@ describe('POST /api/commitments', () => { const result = await parseResponse(response); expect(result.status).toBe(400); - expect(result.data.error.code).toBe('BAD_REQUEST'); + expect(result.data.error.code).toBe('VALIDATION_ERROR'); }); it('returns 400 for invalid durationDays', async () => { @@ -527,7 +528,7 @@ describe('POST /api/commitments', () => { const result = await parseResponse(response); expect(result.status).toBe(400); - expect(result.data.error.code).toBe('BAD_REQUEST'); + expect(result.data.error.code).toBe('VALIDATION_ERROR'); expect(result.data.error.message).toContain('durationDays'); }); @@ -541,7 +542,7 @@ describe('POST /api/commitments', () => { const result = await parseResponse(response); expect(result.status).toBe(400); - expect(result.data.error.code).toBe('BAD_REQUEST'); + expect(result.data.error.code).toBe('VALIDATION_ERROR'); expect(result.data.error.message).toContain('maxLossBps'); }); @@ -555,7 +556,7 @@ describe('POST /api/commitments', () => { const result = await parseResponse(response); expect(result.status).toBe(400); - expect(result.data.error.code).toBe('BAD_REQUEST'); + expect(result.data.error.code).toBe('VALIDATION_ERROR'); }); }); @@ -633,7 +634,7 @@ describe('POST /api/commitments', () => { const result = await parseResponse(response); expect(result.status).toBe(400); - expect(result.data.error.code).toBe('BAD_REQUEST'); + expect(result.data.error.code).toBe('VALIDATION_ERROR'); }); it('includes x-correlation-id in response headers', async () => {