diff --git a/src/app/api/commitments/[id]/early-exit/route.ts b/src/app/api/commitments/[id]/early-exit/route.ts index 8822084b..3a931255 100644 --- a/src/app/api/commitments/[id]/early-exit/route.ts +++ b/src/app/api/commitments/[id]/early-exit/route.ts @@ -52,13 +52,12 @@ import { requireAuth } from '@/lib/backend/requireAuth'; import { EarlyExitRequestBodySchema } from '@/lib/schemas/apiContracts'; import { earlyExitCommitmentOnChain, getCommitmentFromChain } from '@/lib/backend/services/contracts'; import { - earlyExitCommitmentOnChain, - getCommitmentFromChain, -} from '@/lib/backend/services/contracts'; -import type { TransactionMetadata, TransactionType } from '@/lib/transaction/transactionTypes'; -import { TRANSACTION_BOUNDS, createTransactionError } from '@/lib/transaction/transactionTypes'; -import { TransactionStateMachine } from '@/lib/transaction/transactionStateMachine'; -import { validateTransactionMetadata } from '@/lib/transaction/transactionStateMachine'; + verifyOwnership, + verifySessionConsistency, + verifyCanEarlyExit, + validateTransactionResponse, +} from '@/lib/backend/transactionValidation'; +import { randomUUID } from 'crypto'; const COMMITMENT_EARLY_EXIT_CORS_POLICY = { POST: { access: 'first-party' }, diff --git a/src/app/api/commitments/[id]/fund/route.test.ts b/src/app/api/commitments/[id]/fund/route.test.ts index e8f31353..5c344deb 100644 --- a/src/app/api/commitments/[id]/fund/route.test.ts +++ b/src/app/api/commitments/[id]/fund/route.test.ts @@ -1,7 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { NextRequest } from 'next/server'; -import { POST, OPTIONS, GET, PUT, PATCH, DELETE } from './route'; -import { CsrfValidationError, BackendError } from '@/lib/backend/errors'; import { POST } from './route'; import { diagnosticsService } from '@/lib/backend/diagnostics'; import { randomUUID } from 'crypto'; @@ -100,59 +98,6 @@ const MOCK_COMMITMENT_CREATED = { expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), }; -const MOCK_FUND_RESULT = { - commitmentId: 'cmt-123', - txHash: '0xdeadbeef', - contractVersion: '1.0.0', - reference: undefined, -}; - -function makeRequest( - id: string, - body?: Record, - method = 'POST', - headers?: Record, -): [NextRequest, { params: { id: string } }] { - const reqHeaders: Record = { - ...(body !== undefined ? { 'content-type': 'application/json' } : {}), - ...headers, - }; - const req = new NextRequest(`http://localhost/api/commitments/${id}/fund`, { - method, - headers: reqHeaders, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); - return [req, { params: { id } }]; -} - -async function expectError( - req: NextRequest, - ctx: { params: { id: string } }, - status: number, - code?: string, -): Promise { - const res = await POST(req, ctx); - const body = await res.json(); - expect(res.status).toBe(status); - expect(body.success).toBe(false); - expect(body.error).toBeDefined(); - if (code) expect(body.error.code).toBe(code); -} - -// ─── Helper to build a completed idempotency record ────────────────────────── - -function completedRecord(response: Record, statusCode = 200) { - return { - key: 'idem-test', - status: 'COMPLETED' as const, - response, - statusCode, - createdAt: Date.now(), - expiresAt: Date.now() + 86400000, - }; -} - -describe('POST /api/commitments/[id]/fund', () => { // ── Tests ────────────────────────────────────────────────────────────────────── describe('POST /api/commitments/[id]/fund - Idempotency & Concurrent Request Bounds', () => { @@ -171,22 +116,6 @@ describe('POST /api/commitments/[id]/fund - Idempotency & Concurrent Request Bou mockIdempotency.fail.mockResolvedValue(undefined); }); - // ─── 200 Success ───────────────────────────────────────────────────────── - - describe('200 - success', () => { - it('funds a commitment escrow', async () => { - const [req, ctx] = makeRequest('cmt-123', {}); - const res = await POST(req, ctx); - const body = await res.json(); - - expect(res.status).toBe(200); - expect(body.success).toBe(true); - expect(body.data.commitmentId).toBe('cmt-123'); - expect(body.data.txHash).toBe('0xdeadbeef'); - expect(body.data.reference).toBeUndefined(); - expect(body.data.fundedAt).toBeDefined(); - expect(body.meta).toBeDefined(); - }); afterEach(() => { vi.clearAllMocks(); diagnosticsService.clear(); @@ -251,112 +180,6 @@ describe('POST /api/commitments/[id]/fund - Idempotency & Concurrent Request Bou idempotencyKey, }); - it('response shape: required fields commitmentId, txHash, fundedAt are present', async () => { - const [req, ctx] = makeRequest('cmt-123', {}); - const res = await POST(req, ctx); - const body = await res.json(); - - // These three fields are always present in a successful response - expect(body.data.commitmentId).toBeDefined(); - expect(body.data.txHash).toBeDefined(); - expect(body.data.fundedAt).toBeDefined(); - // reference is present only when txHash is absent (undefined is stripped by JSON) - // No extraneous fields beyond the documented contract - const allowedKeys = new Set(['commitmentId', 'txHash', 'reference', 'fundedAt']); - const extraKeys = Object.keys(body.data).filter((k) => !allowedKeys.has(k)); - expect(extraKeys).toHaveLength(0); - }); - - it('fundedAt is a valid ISO-8601 timestamp', async () => { - const [req, ctx] = makeRequest('cmt-123', {}); - const before = Date.now(); - const res = await POST(req, ctx); - const after = Date.now(); - const body = await res.json(); - - const fundedAtMs = new Date(body.data.fundedAt).getTime(); - expect(Number.isNaN(fundedAtMs)).toBe(false); - expect(fundedAtMs).toBeGreaterThanOrEqual(before); - expect(fundedAtMs).toBeLessThanOrEqual(after); - }); - - it('includes x-correlation-id header on success', async () => { - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { - 'x-correlation-id': 'test-corr-001', - }); - const res = await POST(req, ctx); - - expect(res.headers.get('x-correlation-id')).toBe('test-corr-001'); - }); - - it('callerAddress absent: does not perform ownership check, calls fundEscrowOnChain', async () => { - // When callerAddress is omitted the route skips the ownership guard — - // authorization is delegated to fundEscrowOnChain / the chain itself. - const [req, ctx] = makeRequest('cmt-123', {}); - const res = await POST(req, ctx); - - expect(res.status).toBe(200); - expect(mockFundEscrowOnChain).toHaveBeenCalledWith({ - commitmentId: 'cmt-123', - callerAddress: undefined, - }); - }); - - it('reference is undefined when txHash is present', async () => { - mockFundEscrowOnChain.mockResolvedValue({ - ...MOCK_FUND_RESULT, - txHash: '0xabc123', - reference: undefined, - }); - const [req, ctx] = makeRequest('cmt-123', {}); - const res = await POST(req, ctx); - const body = await res.json(); - - expect(body.data.txHash).toBe('0xabc123'); - expect(body.data.reference).toBeUndefined(); - }); - - it('reference is present when txHash is absent (fallback reference)', async () => { - mockFundEscrowOnChain.mockResolvedValue({ - ...MOCK_FUND_RESULT, - txHash: undefined, - reference: 'TODO_CHAIN_CALL_FUND_ESCROW', - }); - const [req, ctx] = makeRequest('cmt-123', {}); - const res = await POST(req, ctx); - const body = await res.json(); - - expect(body.data.txHash).toBeUndefined(); - expect(body.data.reference).toBe('TODO_CHAIN_CALL_FUND_ESCROW'); - }); - - it('does not track idempotency when header is absent', async () => { - // No idempotency-key header → none of the idempotency methods should be called - const [req, ctx] = makeRequest('cmt-123', {}); - await POST(req, ctx); - - expect(mockIdempotencyGetRecord).not.toHaveBeenCalled(); - expect(mockIdempotencyStart).not.toHaveBeenCalled(); - expect(mockIdempotencyComplete).not.toHaveBeenCalled(); - expect(mockIdempotencyFail).not.toHaveBeenCalled(); - }); - }); - - // ─── 200 Success with idempotency ──────────────────────────────────────── - - describe('200 - success with idempotency', () => { - it('returns cached response when idempotency key is COMPLETED', async () => { - const cachedResponse = { commitmentId: 'cmt-123', txHash: '0xold' }; - mockIdempotencyGetRecord.mockResolvedValue(completedRecord(cachedResponse)); - - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { 'idempotency-key': 'idem-001' }); - const res = await POST(req, ctx); - const body = await res.json(); - - expect(res.status).toBe(200); - expect(body.data).toEqual(cachedResponse); - expect(mockFundEscrowOnChain).not.toHaveBeenCalled(); - }); const context = { params: { id: COMMITMENT_ID } }; const response = await POST(req, context, 'correlation-123'); @@ -386,82 +209,6 @@ describe('POST /api/commitments/[id]/fund - Idempotency & Concurrent Request Bou const context = { params: { id: COMMITMENT_ID } }; const response = await POST(req, context, 'correlation-123'); - expect(mockIdempotencyComplete).toHaveBeenCalledWith( - 'idem-003', - expect.objectContaining({ commitmentId: 'cmt-123' }), - 200, - ); - }); - - it('idempotency replay returns the exact same fundedAt as the original request', async () => { - const frozenFundedAt = '2026-08-01T12:00:00.000Z'; - const cachedPayload = { - commitmentId: 'cmt-123', - txHash: '0xdeadbeef', - reference: undefined, - fundedAt: frozenFundedAt, - }; - mockIdempotencyGetRecord.mockResolvedValue(completedRecord(cachedPayload)); - - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { 'idempotency-key': 'idem-replay' }); - const res = await POST(req, ctx); - const body = await res.json(); - - // The replayed response must include the original, stable fundedAt — - // not a freshly generated timestamp. - expect(body.data.fundedAt).toBe(frozenFundedAt); - expect(mockFundEscrowOnChain).not.toHaveBeenCalled(); - }); - - it('idempotency complete call stores the same fundedAt that is returned in the response', async () => { - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { 'idempotency-key': 'idem-ts' }); - const res = await POST(req, ctx); - const body = await res.json(); - - // Verify the value stored in the idempotency cache equals the response body - const storedPayload = mockIdempotencyComplete.mock.calls[0][1] as Record; - expect(storedPayload.fundedAt).toBe(body.data.fundedAt); - }); - - it('allows retry after FAILED idempotency: fail() deletes key so retry proceeds', async () => { - // First call: STARTED → normal flow fails → fail() is called → key deleted - // Second call: getRecord returns null because key was deleted → new start - // This test simulates the second (retry) call: - mockIdempotencyGetRecord.mockResolvedValue(null); // key was deleted by fail() - - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { 'idempotency-key': 'idem-retry' }); - const res = await POST(req, ctx); - const body = await res.json(); - - expect(res.status).toBe(200); - expect(body.success).toBe(true); - expect(mockIdempotencyStart).toHaveBeenCalledWith('idem-retry'); - expect(mockFundEscrowOnChain).toHaveBeenCalled(); - }); - - it('idempotency key header value is propagated correctly to all service calls', async () => { - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { - 'idempotency-key': 'exact-key-value', - }); - await POST(req, ctx); - - expect(mockIdempotencyGetRecord).toHaveBeenCalledWith('exact-key-value'); - expect(mockIdempotencyStart).toHaveBeenCalledWith('exact-key-value'); - expect(mockIdempotencyComplete).toHaveBeenCalledWith( - 'exact-key-value', - expect.any(Object), - 200, - ); - }); - }); - - // ─── 400 Validation ────────────────────────────────────────────────────── - - describe('400 - validation errors', () => { - it('rejects empty commitment id', async () => { - const [req, ctx] = makeRequest('', {}); - await expectError(req, ctx, 400, 'VALIDATION_ERROR'); - }); const result = await parseResponse(response); expect(result.status).toBe(409); expect(result.data.error.code).toBe('CONFLICT_ERROR'); @@ -491,12 +238,6 @@ describe('POST /api/commitments/[id]/fund - Idempotency & Concurrent Request Bou expect(mockIdempotency.fail).toHaveBeenCalledWith(idempotencyKey); }); - // ─── 403 Forbidden ─────────────────────────────────────────────────────── - - describe('403 - forbidden', () => { - it('rejects callerAddress that does not match owner', async () => { - const [req, ctx] = makeRequest('cmt-123', { callerAddress: 'GWRONGADDRESS' }); - await expectError(req, ctx, 403, 'FORBIDDEN'); // ── State Invariant Tests ────────────────────────────────────────────────── it('rejects funding of non-CREATED commitments (precondition invariant)', async () => { @@ -518,13 +259,6 @@ describe('POST /api/commitments/[id]/fund - Idempotency & Concurrent Request Bou expect(result.data.error.message).toContain('Only CREATED commitments can be funded'); }); - // ─── 404 Not Found ─────────────────────────────────────────────────────── - - describe('404 - not found', () => { - it('returns 404 when commitment does not exist', async () => { - mockGetCommitmentFromChain.mockResolvedValue(null); - const [req, ctx] = makeRequest('nonexistent', {}); - await expectError(req, ctx, 404, 'NOT_FOUND'); it('rejects funding by non-owner (ownership invariant)', async () => { const differentAddress = `GBAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB`; @@ -541,38 +275,6 @@ describe('POST /api/commitments/[id]/fund - Idempotency & Concurrent Request Bou expect(result.data.error.message).toContain('Only the commitment owner may fund'); }); - // ─── 409 Conflict ──────────────────────────────────────────────────────── - - describe('409 - conflict: non-CREATED commitment statuses', () => { - const nonCreatedStatuses = [ - 'ACTIVE', - 'SETTLED', - 'VIOLATED', - 'EARLY_EXIT', - 'DISPUTED', - 'UNKNOWN', - ] as const; - - for (const status of nonCreatedStatuses) { - it(`rejects funding a commitment with status ${status}`, async () => { - mockGetCommitmentFromChain.mockResolvedValue({ - ...MOCK_COMMITMENT, - status, - } as typeof MOCK_COMMITMENT); - const [req, ctx] = makeRequest('cmt-123', {}); - await expectError(req, ctx, 409, 'CONFLICT'); - }); - } - - it('rejects duplicate idempotency key that is still processing (STARTED)', async () => { - mockIdempotencyGetRecord.mockResolvedValue({ - key: 'idem-004', - status: 'STARTED', - createdAt: Date.now(), - expiresAt: Date.now() + 86400000, - }); - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { 'idempotency-key': 'idem-004' }); - await expectError(req, ctx, 409, 'CONFLICT'); it('rejects funding of non-existent commitment', async () => { mockGetCommitment.mockResolvedValue(null); @@ -588,72 +290,6 @@ describe('POST /api/commitments/[id]/fund - Idempotency & Concurrent Request Bou expect(result.data.error.code).toBe('NOT_FOUND_ERROR'); }); - // ─── 429 Rate Limited ──────────────────────────────────────────────────── - - describe('429 - rate limited', () => { - it('returns 429 when rate limit exceeded', async () => { - mockCheckRateLimit.mockResolvedValue(false); - const [req, ctx] = makeRequest('cmt-123', {}); - await expectError(req, ctx, 429, 'TOO_MANY_REQUESTS'); - }); - - it('includes Retry-After header on 429', async () => { - mockCheckRateLimit.mockResolvedValue(false); - mockGetRateLimitWindowSeconds.mockReturnValue(60); - const [req, ctx] = makeRequest('cmt-123', {}); - const res = await POST(req, ctx); - - expect(res.status).toBe(429); - expect(res.headers.get('Retry-After')).toBe('60'); - }); - }); - - // ─── 502 Blockchain error ───────────────────────────────────────────────── - - describe('502 - blockchain error', () => { - it('returns 502 when fundEscrowOnChain throws a BLOCKCHAIN_CALL_FAILED BackendError', async () => { - mockFundEscrowOnChain.mockRejectedValue( - new BackendError({ - code: 'BLOCKCHAIN_CALL_FAILED', - message: 'Unable to fund escrow on chain.', - status: 502, - details: { method: 'fund_escrow', commitmentId: 'cmt-123' }, - }), - ); - const [req, ctx] = makeRequest('cmt-123', {}); - const res = await POST(req, ctx); - const body = await res.json(); - - expect(res.status).toBe(502); - // BackendError uses the toBackendErrorResponse shape: { error: { code, message, details } } - expect(body.error).toBeDefined(); - expect(body.error.code).toBe('BLOCKCHAIN_CALL_FAILED'); - }); - - it('marks idempotency key as failed when blockchain call fails', async () => { - mockFundEscrowOnChain.mockRejectedValue( - new BackendError({ - code: 'BLOCKCHAIN_CALL_FAILED', - message: 'RPC timeout', - status: 502, - }), - ); - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { 'idempotency-key': 'idem-502' }); - await POST(req, ctx); - - expect(mockIdempotencyFail).toHaveBeenCalledWith('idem-502'); - }); - }); - - // ─── 405 Method Not Allowed ────────────────────────────────────────────── - - describe('405 - method not allowed', () => { - it('rejects GET requests', async () => { - const [req, ctx] = makeRequest('cmt-123', undefined, 'GET'); - const res = await GET(req, ctx); - const body = await res.json(); - expect(res.status).toBe(405); - expect(body.error.code).toBe('METHOD_NOT_ALLOWED'); // ── Boundary & Validation Tests ──────────────────────────────────────────── it('rejects commitment ID with empty/whitespace string', async () => { @@ -723,16 +359,6 @@ describe('POST /api/commitments/[id]/fund - Idempotency & Concurrent Request Bou // In practice, you'd mock the time or use a smaller threshold for testing. }); - // ─── OPTIONS preflight ─────────────────────────────────────────────────── - - describe('OPTIONS', () => { - it('returns 204 for OPTIONS preflight', async () => { - const req = new NextRequest('http://localhost/api/commitments/cmt-123/fund', { - method: 'OPTIONS', - headers: { 'access-control-request-method': 'POST' }, - }); - const res = await OPTIONS(req); - expect(res.status).toBe(204); // ── Rate Limit Tests ────────────────────────────────────────────────────── it('respects rate limit for IP', async () => { @@ -750,13 +376,6 @@ describe('POST /api/commitments/[id]/fund - Idempotency & Concurrent Request Bou expect(result.data.error.code).toBe('TOO_MANY_REQUESTS_ERROR'); }); - // ─── Error handling and idempotency failure path ────────────────────────── - - describe('error handling', () => { - it('fails idempotency key when getCommitmentFromChain throws', async () => { - mockGetCommitmentFromChain.mockRejectedValue(new Error('RPC failure')); - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { 'idempotency-key': 'idem-005' }); - await POST(req, ctx); // ── CSRF Protection Tests ────────────────────────────────────────────────── it('asserts CSRF token on POST request', async () => { @@ -764,27 +383,6 @@ describe('POST /api/commitments/[id]/fund - Idempotency & Concurrent Request Bou body: { callerAddress: VALID_ADDRESS }, }); - it('fails idempotency key when fundEscrowOnChain throws', async () => { - mockFundEscrowOnChain.mockRejectedValue(new Error('Chain timeout')); - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { 'idempotency-key': 'idem-006' }); - await POST(req, ctx); - - expect(mockIdempotencyFail).toHaveBeenCalledWith('idem-006'); - }); - - it('does not call idempotencyFail when no idempotency key is present', async () => { - mockGetCommitmentFromChain.mockRejectedValue(new Error('RPC failure')); - const [req, ctx] = makeRequest('cmt-123', {}); - await POST(req, ctx); - - expect(mockIdempotencyFail).not.toHaveBeenCalled(); - }); - - it('returns 500 for unexpected errors', async () => { - mockGetCommitmentFromChain.mockRejectedValue(new Error('Unexpected DB error')); - const [req, ctx] = makeRequest('cmt-123', {}); - const res = await POST(req, ctx); - const body = await res.json(); const context = { params: { id: COMMITMENT_ID } }; await POST(req, context, 'correlation-123'); @@ -796,89 +394,6 @@ describe('POST /api/commitments/[id]/fund - Idempotency & Concurrent Request Bou throw new Error('CSRF token invalid'); }); - it('returns 500 with x-correlation-id header on unhandled error', async () => { - mockGetCommitmentFromChain.mockRejectedValue(new Error('boom')); - const [req, ctx] = makeRequest('cmt-123', {}, 'POST', { - 'x-correlation-id': 'err-corr-001', - }); - const res = await POST(req, ctx); - - expect(res.status).toBe(500); - expect(res.headers.get('x-correlation-id')).toBe('err-corr-001'); - }); - }); - - // ─── Boundary / edge cases ──────────────────────────────────────────────── - - describe('boundary and edge cases', () => { - it('accepts a commitment id with special characters (URL-encoded)', async () => { - const [req, ctx] = makeRequest('cmt-abc_123-XYZ', {}); - const res = await POST(req, ctx); - - expect(mockGetCommitmentFromChain).toHaveBeenCalledWith('cmt-abc_123-XYZ'); - expect(res.status).toBe(200); - }); - - it('does not call fundEscrowOnChain when CSRF check throws', async () => { - mockAssertMutationCsrf.mockImplementation(() => { - throw new CsrfValidationError('Missing CSRF token.'); - }); - const [req, ctx] = makeRequest('cmt-123', {}); - await POST(req, ctx); - - expect(mockFundEscrowOnChain).not.toHaveBeenCalled(); - }); - - it('does not call fundEscrowOnChain when rate limit is exceeded', async () => { - mockCheckRateLimit.mockResolvedValue(false); - const [req, ctx] = makeRequest('cmt-123', {}); - await POST(req, ctx); - - expect(mockFundEscrowOnChain).not.toHaveBeenCalled(); - }); - - it('does not call fundEscrowOnChain when commitment is not found', async () => { - mockGetCommitmentFromChain.mockResolvedValue(null); - const [req, ctx] = makeRequest('cmt-123', {}); - await POST(req, ctx); - - expect(mockFundEscrowOnChain).not.toHaveBeenCalled(); - }); - - it('does not call fundEscrowOnChain when status is not CREATED', async () => { - mockGetCommitmentFromChain.mockResolvedValue({ - ...MOCK_COMMITMENT, - status: 'SETTLED', - } as typeof MOCK_COMMITMENT); - const [req, ctx] = makeRequest('cmt-123', {}); - await POST(req, ctx); - - expect(mockFundEscrowOnChain).not.toHaveBeenCalled(); - }); - - it('does not call fundEscrowOnChain when caller address is forbidden', async () => { - const [req, ctx] = makeRequest('cmt-123', { callerAddress: 'GEVIL999' }); - await POST(req, ctx); - - expect(mockFundEscrowOnChain).not.toHaveBeenCalled(); - }); - - it('success response body has success: true at top level', async () => { - const [req, ctx] = makeRequest('cmt-123', {}); - const res = await POST(req, ctx); - const body = await res.json(); - - expect(body.success).toBe(true); - }); - - it('error response body has success: false at top level', async () => { - mockGetCommitmentFromChain.mockResolvedValue(null); - const [req, ctx] = makeRequest('nonexistent', {}); - const res = await POST(req, ctx); - const body = await res.json(); - - expect(body.success).toBe(false); - }); const req = createMockRequest(`http://localhost/api/commitments/${COMMITMENT_ID}/fund`, { body: { callerAddress: VALID_ADDRESS }, }); diff --git a/src/app/api/commitments/[id]/fund/route.ts b/src/app/api/commitments/[id]/fund/route.ts index cc29f05a..5c5a6dc9 100644 --- a/src/app/api/commitments/[id]/fund/route.ts +++ b/src/app/api/commitments/[id]/fund/route.ts @@ -1,4 +1,3 @@ -import { NextRequest, NextResponse } from 'next/server'; /** * POST /api/commitments/[id]/fund * @@ -211,11 +210,6 @@ export const POST = withApiHandler( 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. - const fundedAt = new Date().toISOString(); - // ─── Success Response & Idempotency Caching ─────────────────────────────── const responseData = { commitmentId: id, @@ -254,13 +248,6 @@ export const POST = withApiHandler( 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 = diff --git a/src/app/api/commitments/[id]/settle/route.ts b/src/app/api/commitments/[id]/settle/route.ts index 034d595e..d2dc3c97 100644 --- a/src/app/api/commitments/[id]/settle/route.ts +++ b/src/app/api/commitments/[id]/settle/route.ts @@ -44,14 +44,18 @@ import { logCommitmentSettled } from '@/lib/backend/logger'; import { idempotencyService } from '@/lib/backend/idempotency'; import { checkRateLimit, getRateLimitWindowSeconds } from '@/lib/backend/rateLimit'; import { withApiHandler } from '@/lib/backend/withApiHandler'; -import type { TransactionMetadata, TransactionType } from '@/lib/transaction/transactionTypes'; -import { TRANSACTION_BOUNDS, createTransactionError } from '@/lib/transaction/transactionTypes'; -import { TransactionStateMachine } from '@/lib/transaction/transactionStateMachine'; -import { validateTransactionMetadata } from '@/lib/transaction/transactionStateMachine'; +import { idempotencyService } from '@/lib/backend/idempotency'; +import { diagnosticsService } from '@/lib/backend/diagnostics'; +import { + verifyOwnership, + verifyCanSettle, + validateTransactionResponse, + validateAddressBounds, +} from '@/lib/backend/transactionValidation'; +import { randomUUID } from 'crypto'; const SettleRequestSchema = z.object({ - callerAddress: z.string().optional(), - transactionId: z.string().optional(), + callerAddress: z.string(), }); const COMMITMENT_SETTLE_CORS_POLICY = { @@ -60,15 +64,16 @@ const COMMITMENT_SETTLE_CORS_POLICY = { export const OPTIONS = createCorsOptionsHandler(COMMITMENT_SETTLE_CORS_POLICY); -/** - * Generate a unique transaction ID - */ -function generateTransactionId(commitmentId: string): string { - return `settle_${commitmentId}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; -} - -export const POST = withApiHandler( - async (req: NextRequest, { params }, correlationId) => { +export const POST = withApiHandler(async (req: NextRequest, { params }, correlationId) => { + // Generate unique operation ID for diagnostics + const operationId = randomUUID(); + const telemetry = diagnosticsService.startOperation( + operationId, + 'settle_commitment', + 100, // max concurrent + ); + try { + // ─── CSRF Protection ────────────────────────────────────────────────────── assertMutationCsrf(req); // ─── Rate Limiting ──────────────────────────────────────────────────────── @@ -87,6 +92,35 @@ export const POST = withApiHandler( throw new ValidationError('Commitment ID is required'); } + // ─── Idempotency Check & Protection ────────────────────────────────────── + const idempotencyKey = req.headers.get('idempotency-key'); + if (idempotencyKey) { + const record = await idempotencyService.getRecord(idempotencyKey); + if (record) { + if (record.status === 'COMPLETED') { + diagnosticsService.completeOperation(operationId, 'success', undefined, { + cacheHit: true, + idempotent: true, + }); + const response = ok(record.response, undefined, record.statusCode, correlationId); + response.headers.set('X-Idempotent-Replay', 'true'); + return response; + } else if (record.status === 'STARTED') { + throw new ConflictError( + 'A request with this Idempotency-Key is currently processing. Please retry after a brief delay.', + ); + } + } + await idempotencyService.start(idempotencyKey); + } + // ─── Request Body Validation ────────────────────────────────────────────── + let body: unknown; + try { + body = await req.json(); + } catch { + throw new ValidationError('Invalid JSON in request body'); + } + // ─── Idempotency Check & Protection ────────────────────────────────────── const idempotencyKey = req.headers.get('idempotency-key'); if (idempotencyKey) { @@ -118,21 +152,10 @@ export const POST = withApiHandler( throw new ValidationError('Invalid JSON in request body'); } - const validation = SettleRequestSchema.safeParse(body); - if (!validation.success) { - throw new ValidationError('Invalid request data', validation.error.issues); - } + // ─── Address Bounds Validation ──────────────────────────────────────────── + const callerAddress = validateAddressBounds(validation.data.callerAddress, 'callerAddress'); - const callerAddress = validation.data.callerAddress; - const clientTransactionId = validation.data.transactionId; - - // Generate or use client-provided transaction ID - const transactionId = clientTransactionId || generateTransactionId(id); - - // Initialize state machine for this transaction - const stateMachine = new TransactionStateMachine('pending'); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // ─── Commitment State Check (Precondition Invariant) ─────────────────────── const commitment: any = await getCommitmentFromChain(id, { requestId: correlationId }); if (!commitment) { @@ -144,32 +167,29 @@ export const POST = withApiHandler( stateMachine.transition('failed'); throw new NotFoundError('Commitment', { commitmentId: id }); } - if (commitment.status === 'SETTLED') { - const error = createTransactionError( - 'VALIDATION_ERROR' as any, - 'Commitment has already been settled', - transactionId, - ); - stateMachine.transition('rejected'); - throw new ConflictError('Commitment has already been settled'); - } - if (commitment.status === 'VIOLATED') { - const error = createTransactionError( - 'VALIDATION_ERROR' as any, - 'Commitment has been violated and cannot be settled', - transactionId, - ); - stateMachine.transition('rejected'); - throw new ConflictError('Commitment has been violated and cannot be settled'); + + // Verify commitment can be settled + try { + verifyCanSettle(commitment.status); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Cannot settle commitment'; + throw new ConflictError(errorMsg, { commitmentId: id, status: commitment.status }); } - if (commitment.status === 'EARLY_EXIT') { - const error = createTransactionError( - 'VALIDATION_ERROR' as any, - 'Commitment has already been exited early', - transactionId, + + // ─── Ownership Verification (Authorization Boundary) ────────────────────── + try { + verifyOwnership(callerAddress, commitment.ownerAddress); + } catch (error) { + diagnosticsService.completeOperation( + operationId, + 'failure', + 'Authorization failed: ownership verification', + { commitmentId: id, reason: 'ownership_mismatch' }, ); - stateMachine.transition('rejected'); - throw new ConflictError('Commitment has already been exited early'); + if (error instanceof ForbiddenError) { + throw error; + } + throw new ForbiddenError('Ownership verification failed', { commitmentId: id }); } if ( callerAddress && @@ -185,74 +205,65 @@ export const POST = withApiHandler( throw new ForbiddenError('You do not own this commitment'); } - // Transition to confirming state before blockchain call - const transitionError = stateMachine.transition('confirming'); - if (transitionError) { - throw new ConflictError(transitionError.message); - } + // ─── Execute Settlement on Chain ────────────────────────────────────────── + const settlementResult = await settleCommitmentOnChain( + { + commitmentId: id, + callerAddress, + }, + { requestId: correlationId }, + ); - try { - const settlementResult = await settleCommitmentOnChain( - { - commitmentId: id, - callerAddress, - }, - { requestId: correlationId }, - ); + // ─── Validate Transaction Response (Malformed Response Detection) ────────── + validateTransactionResponse(settlementResult, 'settlement'); - // Transition to confirmed state on success - stateMachine.transition('confirmed'); + logCommitmentSettled({ + ip, + commitmentId: id, + callerAddress, + settlementAmount: settlementResult.settlementAmount, + finalStatus: settlementResult.finalStatus, + txHash: settlementResult.txHash, + }); - logCommitmentSettled({ - ip, - commitmentId: id, - callerAddress, - settlementAmount: settlementResult.settlementAmount, - finalStatus: settlementResult.finalStatus, - txHash: settlementResult.txHash, - }); + const responseData = { + commitmentId: id, + settlementAmount: settlementResult.settlementAmount, + finalStatus: settlementResult.finalStatus, + txHash: settlementResult.txHash, + reference: settlementResult.reference, + settledAt: new Date().toISOString(), + }; - const responseData = { - commitmentId: id, - settlementAmount: settlementResult.settlementAmount, - finalStatus: settlementResult.finalStatus, - txHash: settlementResult.txHash, - reference: settlementResult.reference, - settledAt: new Date().toISOString(), - transactionId, - transactionState: stateMachine.getState(), - }; - - return ok(responseData, undefined, 200, correlationId); - } catch (error) { - // Transition to failed state on error - stateMachine.transition('failed'); - - // Create transaction metadata for error tracking - const additionalFields: Partial = { - callerAddress, - error: error instanceof Error ? error.message : String(error), - }; - - const transactionMetadata: TransactionMetadata = stateMachine.toMetadata( - transactionId, - 'settlement' as TransactionType, - id, - additionalFields, - ); - - // Validate metadata invariants - const validationError = validateTransactionMetadata(transactionMetadata); - if (validationError) { - // Log validation error but don't fail the request - console.error('[Transaction] Metadata validation failed:', validationError); - } - - throw error; + if (idempotencyKey) { + await idempotencyService.complete(idempotencyKey, responseData, 200); } - }, - { cors: COMMITMENT_SETTLE_CORS_POLICY }, -); + + diagnosticsService.completeOperation( + operationId, + 'success', + undefined, + { commitmentId: id, txHash: settlementResult.txHash }, + ); + + return ok(responseData, undefined, 200, correlationId); + } catch (error) { + // Clean up idempotency record on failure to allow retry + const idempotencyKey = req.headers.get('idempotency-key'); + if (idempotencyKey) { + await idempotencyService.fail(idempotencyKey); + } + + // Record failure in diagnostics + const errorMessage = + error instanceof Error ? error.message : 'Unknown error during settlement'; + diagnosticsService.completeOperation(operationId, 'failure', errorMessage, { + errorType: error instanceof Error ? error.constructor.name : typeof error, + }); + + throw error; + } +}, { cors: COMMITMENT_SETTLE_CORS_POLICY }); const _405 = methodNotAllowed(['POST']); export { _405 as GET, _405 as PUT, _405 as PATCH, _405 as DELETE };