From 3a20fe63124f061e6c32f039d619a4e6b92ffa76 Mon Sep 17 00:00:00 2001 From: MissBlue00 Date: Fri, 24 Apr 2026 18:42:30 +0100 Subject: [PATCH 1/2] feat: enforce auth and state checks for commitment settlement - Add actorAddress to request schema (required for authorization) - Add read-before-write to validate eligibility via getCommitmentFromChain - Enforce ownership check (403 Forbidden) - actor must match owner - Handle 409 Conflict for already settled, violated, or early-exited - Handle 400 for commitment that has not yet matured - Normalize errors into canonical contract - Add tests for 403, 409, 400, and 200 success --- src/app/api/commitments/[id]/settle/route.ts | 58 ++++-- tests/api/settle.test.ts | 182 +++++++++++++++++++ 2 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 tests/api/settle.test.ts diff --git a/src/app/api/commitments/[id]/settle/route.ts b/src/app/api/commitments/[id]/settle/route.ts index 4f96fa18..16760719 100644 --- a/src/app/api/commitments/[id]/settle/route.ts +++ b/src/app/api/commitments/[id]/settle/route.ts @@ -3,12 +3,13 @@ import { z } from 'zod'; import { checkRateLimit } from '@/lib/backend/rateLimit'; import { withApiHandler } from '@/lib/backend/withApiHandler'; import { ok } from '@/lib/backend/apiResponse'; -import { TooManyRequestsError, ValidationError, NotFoundError, ConflictError } from '@/lib/backend/errors'; +import { TooManyRequestsError, ValidationError, NotFoundError, ConflictError, ForbiddenError } from '@/lib/backend/errors'; import { settleCommitmentOnChain } from '@/lib/backend/services/contracts'; import { logCommitmentSettled } from '@/lib/backend/logger'; +import { getCommitmentFromChain } from '@/lib/backend/services/contracts'; -// Request validation schema -const SettleRequestSchema = z.object({ +const SetteRequestWithAuthSchema = z.object({ + actorAddress: z.string().min(1, 'Actor address is required'), callerAddress: z.string().optional(), }); @@ -20,40 +21,64 @@ export const POST = withApiHandler(async (req: NextRequest, { params }: Params) const { id } = params; const ip = req.ip ?? req.headers.get('x-forwarded-for') ?? 'anonymous'; - // Rate limiting const isAllowed = await checkRateLimit(ip, 'api/commitments/settle'); if (!isAllowed) { throw new TooManyRequestsError(); } - // Validate commitment ID if (!id || id.trim().length === 0) { throw new ValidationError('Commitment ID is required'); } - // Parse and validate request body let body; try { body = await req.json(); - } catch (error) { + } catch { throw new ValidationError('Invalid JSON in request body'); } - const validation = SettleRequestSchema.safeParse(body); - if (!validation.success) { - throw new ValidationError('Invalid request data', validation.error.errors); + const authValidation = SetteRequestWithAuthSchema.safeParse(body); + if (!authValidation.success) { + throw new ValidationError('Invalid request data', authValidation.error.errors); + } + + const { actorAddress, callerAddress } = authValidation.data; + + const commitment = await getCommitmentFromChain(id); + + if (commitment.ownerAddress.toLowerCase() !== actorAddress.toLowerCase()) { + throw new ForbiddenError('You do not own this commitment'); + } + + if (commitment.status === 'SETTLED') { + throw new ConflictError('Commitment has already been settled'); } - const { callerAddress } = validation.data; + if (commitment.status === 'VIOLATED') { + throw new ConflictError('Commitment has been violated and cannot be settled'); + } + + if (commitment.status === 'EARLY_EXIT') { + throw new ConflictError('Commitment has already been exited early'); + } + + if (commitment.status === 'ACTIVE' && commitment.expiresAt) { + const expiryTime = new Date(commitment.expiresAt).getTime(); + const now = new Date().getTime(); + if (now < expiryTime) { + throw new ValidationError('Commitment has not matured yet and cannot be settled', { + currentStatus: commitment.status, + expiresAt: commitment.expiresAt, + }); + } + } try { - // Call the settlement function const settlementResult = await settleCommitmentOnChain({ commitmentId: id, callerAddress, }); - // Log successful settlement logCommitmentSettled({ ip, commitmentId: id, @@ -63,7 +88,6 @@ export const POST = withApiHandler(async (req: NextRequest, { params }: Params) txHash: settlementResult.txHash, }); - // Return success response return ok({ commitmentId: id, settlementAmount: settlementResult.settlementAmount, @@ -74,7 +98,6 @@ export const POST = withApiHandler(async (req: NextRequest, { params }: Params) }); } catch (error) { - // Log failed settlement attempt logCommitmentSettled({ ip, commitmentId: id, @@ -82,16 +105,15 @@ export const POST = withApiHandler(async (req: NextRequest, { params }: Params) error: error instanceof Error ? error.message : 'Unknown settlement error', }); - // Re-throw known errors to be handled by withApiHandler if ( error instanceof ValidationError || error instanceof NotFoundError || - error instanceof ConflictError + error instanceof ConflictError || + error instanceof ForbiddenError ) { throw error; } - // Unknown errors will be caught by withApiHandler throw error; } }); \ No newline at end of file diff --git a/tests/api/settle.test.ts b/tests/api/settle.test.ts new file mode 100644 index 00000000..1513c900 --- /dev/null +++ b/tests/api/settle.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { POST } from '@/app/api/commitments/[id]/settle/route' +import { createMockRequest, parseResponse } from './helpers' +import * as contractsModule from '@/lib/backend/services/contracts' + +const OWNER_ADDRESS = 'GABC123OWNER456789' +const OTHER_ADDRESS = 'GXYZ789OTHER123456' +const COMMITMENT_ID = 'cm_test_123' +const PAST_EXPIRY = new Date(Date.now() - 1000).toISOString() +const FUTURE_EXPIRY = new Date(Date.now() + 100000).toISOString() + +async function createSettleRequest( + commitmentId: string, + body: { actorAddress: string; callerAddress?: string } +) { + return createMockRequest( + `http://localhost:3000/api/commitments/${commitmentId}/settle`, + { method: 'POST', body } + ) +} + +vi.mock('@/lib/backend/services/contracts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getCommitmentFromChain: vi.fn(), + settleCommitmentOnChain: vi.fn(), + } +}) + +describe('POST /api/commitments/[id]/settle', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return 200 on successful settlement', async () => { + const mockCommitment = { + id: COMMITMENT_ID, + ownerAddress: OWNER_ADDRESS, + status: 'ACTIVE', + expiresAt: PAST_EXPIRY, + } + const mockSettlement = { + settlementAmount: '100', + finalStatus: 'SETTLED', + txHash: 'tx123', + reference: 'ref123', + } + + vi.mocked(contractsModule.getCommitmentFromChain).mockResolvedValue(mockCommitment) + vi.mocked(contractsModule.settleCommitmentOnChain).mockResolvedValue(mockSettlement) + + const request = await createSettleRequest(COMMITMENT_ID, { + actorAddress: OWNER_ADDRESS, + }) + const response = await POST(request, { params: { id: COMMITMENT_ID } }) + const result = await parseResponse(response) + + expect(result.status).toBe(200) + expect(result.data.success).toBe(true) + expect(result.data.data.commitmentId).toBe(COMMITMENT_ID) + expect(result.data.data.settlementAmount).toBe('100') + expect(result.data.data.finalStatus).toBe('SETTLED') + }) + + it('should return 403 if actor is not the owner', async () => { + const mockCommitment = { + id: COMMITMENT_ID, + ownerAddress: OWNER_ADDRESS, + status: 'ACTIVE', + expiresAt: PAST_EXPIRY, + } + + vi.mocked(contractsModule.getCommitmentFromChain).mockResolvedValue(mockCommitment) + + const request = await createSettleRequest(COMMITMENT_ID, { + actorAddress: OTHER_ADDRESS, + }) + const response = await POST(request, { params: { id: COMMITMENT_ID } }) + const result = await parseResponse(response) + + expect(result.status).toBe(403) + expect(result.data.success).toBe(false) + expect(result.data.error.code).toBe('FORBIDDEN') + }) + + it('should return 409 if already settled', async () => { + const mockCommitment = { + id: COMMITMENT_ID, + ownerAddress: OWNER_ADDRESS, + status: 'SETTLED', + expiresAt: PAST_EXPIRY, + } + + vi.mocked(contractsModule.getCommitmentFromChain).mockResolvedValue(mockCommitment) + + const request = await createSettleRequest(COMMITMENT_ID, { + actorAddress: OWNER_ADDRESS, + }) + const response = await POST(request, { params: { id: COMMITMENT_ID } }) + const result = await parseResponse(response) + + expect(result.status).toBe(409) + expect(result.data.success).toBe(false) + expect(result.data.error.code).toBe('CONFLICT') + }) + + it('should return 409 if violated', async () => { + const mockCommitment = { + id: COMMITMENT_ID, + ownerAddress: OWNER_ADDRESS, + status: 'VIOLATED', + expiresAt: PAST_EXPIRY, + } + + vi.mocked(contractsModule.getCommitmentFromChain).mockResolvedValue(mockCommitment) + + const request = await createSettleRequest(COMMITMENT_ID, { + actorAddress: OWNER_ADDRESS, + }) + const response = await POST(request, { params: { id: COMMITMENT_ID } }) + const result = await parseResponse(response) + + expect(result.status).toBe(409) + expect(result.data.success).toBe(false) + expect(result.data.error.code).toBe('CONFLICT') + }) + + it('should return 409 if early exited', async () => { + const mockCommitment = { + id: COMMITMENT_ID, + ownerAddress: OWNER_ADDRESS, + status: 'EARLY_EXIT', + expiresAt: PAST_EXPIRY, + } + + vi.mocked(contractsModule.getCommitmentFromChain).mockResolvedValue(mockCommitment) + + const request = await createSettleRequest(COMMITMENT_ID, { + actorAddress: OWNER_ADDRESS, + }) + const response = await POST(request, { params: { id: COMMITMENT_ID } }) + const result = await parseResponse(response) + + expect(result.status).toBe(409) + expect(result.data.success).toBe(false) + expect(result.data.error.code).toBe('CONFLICT') + }) + + it('should return 400 if commitment has not matured', async () => { + const mockCommitment = { + id: COMMITMENT_ID, + ownerAddress: OWNER_ADDRESS, + status: 'ACTIVE', + expiresAt: FUTURE_EXPIRY, + } + + vi.mocked(contractsModule.getCommitmentFromChain).mockResolvedValue(mockCommitment) + + const request = await createSettleRequest(COMMITMENT_ID, { + actorAddress: OWNER_ADDRESS, + }) + const response = await POST(request, { params: { id: COMMITMENT_ID } }) + const result = await parseResponse(response) + + expect(result.status).toBe(400) + expect(result.data.success).toBe(false) + expect(result.data.error.code).toBe('VALIDATION_ERROR') + }) + + it('should return 400 if actor address is missing', async () => { + const request = createMockRequest( + `http://localhost:3000/api/commitments/${COMMITMENT_ID}/settle`, + { method: 'POST', body: { callerAddress: 'test' } } + ) + const response = await POST(request, { params: { id: COMMITMENT_ID } }) + const result = await parseResponse(response) + + expect(result.status).toBe(400) + expect(result.data.success).toBe(false) + }) +}) \ No newline at end of file From 7249e211af9ae4d981d2d8fca5958e305c1b74c2 Mon Sep 17 00:00:00 2001 From: MissBlue00 Date: Fri, 24 Apr 2026 18:55:50 +0100 Subject: [PATCH 2/2] feat: implement commitment early exit endpoint with chain call and tests --- .../api/commitments/[id]/early-exit/route.ts | 103 +++++++++-- src/lib/backend/services/contracts.ts | 81 +++++++- tests/api/early-exit.test.ts | 173 ++++++++++++++++++ 3 files changed, 343 insertions(+), 14 deletions(-) create mode 100644 tests/api/early-exit.test.ts diff --git a/src/app/api/commitments/[id]/early-exit/route.ts b/src/app/api/commitments/[id]/early-exit/route.ts index 9e1a88bc..ef43d137 100644 --- a/src/app/api/commitments/[id]/early-exit/route.ts +++ b/src/app/api/commitments/[id]/early-exit/route.ts @@ -1,30 +1,107 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; +import { z } from 'zod'; import { checkRateLimit } from '@/lib/backend/rateLimit'; +import { withApiHandler } from '@/lib/backend/withApiHandler'; +import { ok } from '@/lib/backend/apiResponse'; +import { TooManyRequestsError, ValidationError, ForbiddenError, ConflictError } from '@/lib/backend/errors'; +import { earlyExitCommitmentOnChain, getCommitmentFromChain } from '@/lib/backend/services/contracts'; import { logEarlyExit } from '@/lib/backend/logger'; +const EarlyExitRequestSchema = z.object({ + actorAddress: z.string().min(1, 'Actor address is required'), + callerAddress: z.string().optional(), +}); + interface Params { params: { id: string }; } -export async function POST(req: NextRequest, { params }: Params) { +export const POST = withApiHandler(async (req: NextRequest, { params }: Params) => { const { id } = params; + const ip = req.ip ?? req.headers.get('x-forwarded-for') ?? 'anonymous'; - const ip = req.ip || req.headers.get('x-forwarded-for') || 'anonymous'; const isAllowed = await checkRateLimit(ip, 'api/commitments/early-exit'); if (!isAllowed) { - return NextResponse.json({ error: 'Too many requests' }, { status: 429 }); + throw new TooManyRequestsError(); + } + + if (!id || id.trim().length === 0) { + throw new ValidationError('Commitment ID is required'); } - // TODO: perform early exit processing (penalty calculation, contract call, etc.) + let body; try { - const body = await req.json(); - logEarlyExit({ ip, commitmentId: id, ...body }); + body = await req.json(); } catch { - logEarlyExit({ ip, commitmentId: id, error: 'failed to parse request body' }); + throw new ValidationError('Invalid JSON in request body'); } - return NextResponse.json({ - message: `Stub early-exit endpoint for commitment ${id}`, - commitmentId: id - }); -} + const authValidation = EarlyExitRequestSchema.safeParse(body); + if (!authValidation.success) { + throw new ValidationError('Invalid request data', authValidation.error.errors); + } + + const { actorAddress, callerAddress } = authValidation.data; + + const commitment = await getCommitmentFromChain(id); + + if (commitment.ownerAddress.toLowerCase() !== actorAddress.toLowerCase()) { + throw new ForbiddenError('You do not own this commitment'); + } + + if (commitment.status === 'SETTLED') { + throw new ConflictError('Commitment has already been settled and cannot be exited early'); + } + + if (commitment.status === 'EARLY_EXIT') { + throw new ConflictError('Commitment has already been exited early'); + } + + if (commitment.status === 'VIOLATED') { + throw new ConflictError('Commitment has been violated and cannot be exited early'); + } + + try { + const exitResult = await earlyExitCommitmentOnChain({ + commitmentId: id, + callerAddress, + }); + + logEarlyExit({ + ip, + commitmentId: id, + callerAddress, + exitAmount: exitResult.exitAmount, + penaltyAmount: exitResult.penaltyAmount, + finalStatus: exitResult.finalStatus, + txHash: exitResult.txHash, + }); + + return ok({ + commitmentId: id, + exitAmount: exitResult.exitAmount, + penaltyAmount: exitResult.penaltyAmount, + finalStatus: exitResult.finalStatus, + txHash: exitResult.txHash, + reference: exitResult.reference, + exitedAt: new Date().toISOString(), + }); + } catch (error) { + logEarlyExit({ + ip, + commitmentId: id, + callerAddress, + error: error instanceof Error ? error.message : 'Unknown early exit error', + }); + + if ( + error instanceof ValidationError || + error instanceof ForbiddenError || + error instanceof ConflictError + ) { + throw error; + } + + throw error; + } +}); \ No newline at end of file diff --git a/src/lib/backend/services/contracts.ts b/src/lib/backend/services/contracts.ts index fb60d79e..08d2e7d5 100644 --- a/src/lib/backend/services/contracts.ts +++ b/src/lib/backend/services/contracts.ts @@ -79,8 +79,20 @@ export interface SettleCommitmentOnChainResult { finalStatus: string; } +export interface EarlyExitCommitmentOnChainParams { + commitmentId: string; + callerAddress?: string; +} + +export interface EarlyExitCommitmentOnChainResult { + exitAmount: string; + penaltyAmount: string; + txHash?: string; + reference?: string; + finalStatus: string; +} + type ContractCallMode = 'read' | 'write'; -type ContractCallMode = "read" | "write"; interface ContractInvocationResult { value: unknown; @@ -620,3 +632,70 @@ export async function settleCommitmentOnChain( }); } } + +export async function earlyExitCommitmentOnChain( + params: EarlyExitCommitmentOnChainParams +): Promise { + try { + if (!params.commitmentId) { + throw new BackendError({ + code: 'BAD_REQUEST', + message: 'Missing commitment id for early exit.', + status: 400 + }); + } + + const commitment = await getCommitmentFromChain(params.commitmentId); + + if (commitment.status === 'SETTLED') { + throw new BackendError({ + code: 'CONFLICT', + message: 'Commitment has already been settled and cannot be exited early.', + status: 409 + }); + } + + if (commitment.status === 'EARLY_EXIT') { + throw new BackendError({ + code: 'CONFLICT', + message: 'Commitment has already been exited early.', + status: 409 + }); + } + + if (commitment.status === 'VIOLATED') { + throw new BackendError({ + code: 'CONFLICT', + message: 'Commitment has been violated and cannot be exited early.', + status: 409 + }); + } + + const invocation = await invokeContractMethod( + getContractId('commitmentCore'), + 'early_exit_commitment', + [params.commitmentId, params.callerAddress ?? commitment.ownerAddress], + 'write' + ); + + const result = asRecord(invocation.value); + const exitAmount = asString(result.exitAmount, '0'); + const penaltyAmount = asString(result.penaltyAmount, '0'); + const finalStatus = asString(result.finalStatus, 'EARLY_EXIT'); + + return { + exitAmount, + penaltyAmount, + finalStatus, + txHash: invocation.txHash, + reference: invocation.txHash ? undefined : `TODO_CHAIN_CALL_EARLY_EXIT` + }; + } catch (error) { + throw normalizeBackendError(error, { + code: 'BLOCKCHAIN_CALL_FAILED', + message: 'Unable to exit commitment early on chain.', + status: 502, + details: { method: 'early_exit_commitment', commitmentId: params.commitmentId } + }); + } +} diff --git a/tests/api/early-exit.test.ts b/tests/api/early-exit.test.ts new file mode 100644 index 00000000..f236b0fb --- /dev/null +++ b/tests/api/early-exit.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { POST } from '@/app/api/commitments/[id]/early-exit/route' +import { createMockRequest, parseResponse } from './helpers' +import * as contractsModule from '@/lib/backend/services/contracts' + +const OWNER_ADDRESS = 'GABC123OWNER456789' +const OTHER_ADDRESS = 'GXYZ789OTHER123456' +const COMMITMENT_ID = 'cm_test_123' + +async function createEarlyExitRequest( + commitmentId: string, + body: { actorAddress: string; callerAddress?: string } +) { + return createMockRequest( + `http://localhost:3000/api/commitments/${commitmentId}/early-exit`, + { method: 'POST', body } + ) +} + +vi.mock('@/lib/backend/services/contracts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getCommitmentFromChain: vi.fn(), + earlyExitCommitmentOnChain: vi.fn(), + } +}) + +describe('POST /api/commitments/[id]/early-exit', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return 200 on successful early exit', async () => { + const mockCommitment = { + id: COMMITMENT_ID, + ownerAddress: OWNER_ADDRESS, + status: 'ACTIVE', + amount: '100', + } + const mockExit = { + exitAmount: '90', + penaltyAmount: '10', + finalStatus: 'EARLY_EXIT', + txHash: 'tx123', + reference: 'ref123', + } + + vi.mocked(contractsModule.getCommitmentFromChain).mockResolvedValue(mockCommitment) + vi.mocked(contractsModule.earlyExitCommitmentOnChain).mockResolvedValue(mockExit) + + const request = await createEarlyExitRequest(COMMITMENT_ID, { + actorAddress: OWNER_ADDRESS, + }) + const response = await POST(request, { params: { id: COMMITMENT_ID } }) + const result = await parseResponse(response) + + expect(result.status).toBe(200) + expect(result.data.success).toBe(true) + expect(result.data.data.commitmentId).toBe(COMMITMENT_ID) + expect(result.data.data.exitAmount).toBe('90') + expect(result.data.data.penaltyAmount).toBe('10') + expect(result.data.data.finalStatus).toBe('EARLY_EXIT') + }) + + it('should return 403 if actor is not the owner', async () => { + const mockCommitment = { + id: COMMITMENT_ID, + ownerAddress: OWNER_ADDRESS, + status: 'ACTIVE', + } + + vi.mocked(contractsModule.getCommitmentFromChain).mockResolvedValue(mockCommitment) + + const request = await createEarlyExitRequest(COMMITMENT_ID, { + actorAddress: OTHER_ADDRESS, + }) + const response = await POST(request, { params: { id: COMMITMENT_ID } }) + const result = await parseResponse(response) + + expect(result.status).toBe(403) + expect(result.data.success).toBe(false) + expect(result.data.error.code).toBe('FORBIDDEN') + }) + + it('should return 409 if already settled', async () => { + const mockCommitment = { + id: COMMITMENT_ID, + ownerAddress: OWNER_ADDRESS, + status: 'SETTLED', + } + + vi.mocked(contractsModule.getCommitmentFromChain).mockResolvedValue(mockCommitment) + + const request = await createEarlyExitRequest(COMMITMENT_ID, { + actorAddress: OWNER_ADDRESS, + }) + const response = await POST(request, { params: { id: COMMITMENT_ID } }) + const result = await parseResponse(response) + + expect(result.status).toBe(409) + expect(result.data.success).toBe(false) + expect(result.data.error.code).toBe('CONFLICT') + }) + + it('should return 409 if already early exited', async () => { + const mockCommitment = { + id: COMMITMENT_ID, + ownerAddress: OWNER_ADDRESS, + status: 'EARLY_EXIT', + } + + vi.mocked(contractsModule.getCommitmentFromChain).mockResolvedValue(mockCommitment) + + const request = await createEarlyExitRequest(COMMITMENT_ID, { + actorAddress: OWNER_ADDRESS, + }) + const response = await POST(request, { params: { id: COMMITMENT_ID } }) + const result = await parseResponse(response) + + expect(result.status).toBe(409) + expect(result.data.success).toBe(false) + expect(result.data.error.code).toBe('CONFLICT') + }) + + it('should return 409 if violated', async () => { + const mockCommitment = { + id: COMMITMENT_ID, + ownerAddress: OWNER_ADDRESS, + status: 'VIOLATED', + } + + vi.mocked(contractsModule.getCommitmentFromChain).mockResolvedValue(mockCommitment) + + const request = await createEarlyExitRequest(COMMITMENT_ID, { + actorAddress: OWNER_ADDRESS, + }) + const response = await POST(request, { params: { id: COMMITMENT_ID } }) + const result = await parseResponse(response) + + expect(result.status).toBe(409) + expect(result.data.success).toBe(false) + expect(result.data.error.code).toBe('CONFLICT') + }) + + it('should return 400 if invalid JSON in request body', async () => { + const request = new Request( + `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'invalid json', + } + ) + const response = await POST(request as any, { params: { id: COMMITMENT_ID } }) + const result = await parseResponse(response) + + expect(result.status).toBe(400) + expect(result.data.success).toBe(false) + }) + + it('should return 400 if actor address is missing', async () => { + const request = createMockRequest( + `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, + { method: 'POST', body: { callerAddress: 'test' } } + ) + const response = await POST(request, { params: { id: COMMITMENT_ID } }) + const result = await parseResponse(response) + + expect(result.status).toBe(400) + expect(result.data.success).toBe(false) + }) +}) \ No newline at end of file