Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app/api/commitments/[id]/early-exit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
21 changes: 19 additions & 2 deletions src/app/api/commitments/[id]/settle/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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();
Expand All @@ -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({
Expand Down
67 changes: 67 additions & 0 deletions src/lib/backend/services/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -794,3 +794,70 @@ export async function settleCommitmentOnChain(
});
}
}

export async function earlyExitCommitmentOnChain(
params: EarlyExitCommitmentOnChainParams
): Promise<EarlyExitCommitmentOnChainResult> {
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 }
});
}
}
173 changes: 173 additions & 0 deletions tests/api/early-exit.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading