diff --git a/src/app/api/commitments/[id]/early-exit/route.ts b/src/app/api/commitments/[id]/early-exit/route.ts index 348f6a47..7f972542 100644 --- a/src/app/api/commitments/[id]/early-exit/route.ts +++ b/src/app/api/commitments/[id]/early-exit/route.ts @@ -11,6 +11,7 @@ export const POST = withApiHandler(async (req: NextRequest, context: { params: R 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'; diff --git a/src/app/api/commitments/[id]/settle/route.ts b/src/app/api/commitments/[id]/settle/route.ts index d9f5a997..c7149fa0 100644 --- a/src/app/api/commitments/[id]/settle/route.ts +++ b/src/app/api/commitments/[id]/settle/route.ts @@ -8,6 +8,7 @@ import { TooManyRequestsError, ValidationError, NotFoundError, ConflictError } f import { getClientIp } from '@/lib/backend/getClientIp'; import { settleCommitmentOnChain } from '@/lib/backend/services/contracts'; import { logCommitmentSettled } from '@/lib/backend/logger'; +import { getCommitmentFromChain } from '@/lib/backend/services/contracts'; const SettleRequestSchema = z.object({ callerAddress: z.string().optional(), @@ -34,7 +35,6 @@ export const POST = withApiHandler( throw new ValidationError("Commitment ID is required"); } - // Parse and validate request body let body; try { body = await req.json(); @@ -50,7 +50,24 @@ export const POST = withApiHandler( ); } - 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 { const settlementResult = await settleCommitmentOnChain({ diff --git a/src/lib/backend/services/contracts.ts b/src/lib/backend/services/contracts.ts index 5f433a43..e85d6af1 100644 --- a/src/lib/backend/services/contracts.ts +++ b/src/lib/backend/services/contracts.ts @@ -794,3 +794,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 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