diff --git a/src/lib/backend/errorCodes.ts b/src/lib/backend/errorCodes.ts index 275cfd8..b604767 100644 --- a/src/lib/backend/errorCodes.ts +++ b/src/lib/backend/errorCodes.ts @@ -43,6 +43,17 @@ export const ERROR_CODE_REGISTRY: Record = { "Triggered when the HTTP request is malformed, has invalid headers, or violates the API protocol.", }, + NOT_MATURED: { + code: "NOT_MATURED", + statusCode: 400, + meaning: "Commitment has not matured yet and cannot be settled.", + clientHandling: + "Check the maturity date of the commitment before attempting to settle. Do not retry until maturity.", + retriable: false, + description: + "Triggered when a user or caller attempts to settle a commitment that has not reached its expiration time, or lacks expiry information.", + }, + // ─── 400 Validation Error ───────────────────────────────────────────────── VALIDATION_ERROR: { code: "VALIDATION_ERROR", diff --git a/src/lib/backend/errors.ts b/src/lib/backend/errors.ts index c9fb060..1d0b604 100644 --- a/src/lib/backend/errors.ts +++ b/src/lib/backend/errors.ts @@ -157,6 +157,7 @@ export const HTTP_ERROR_CODES: Record = { export type BackendErrorCode = | "BAD_REQUEST" + | "NOT_MATURED" | "VALIDATION_ERROR" | "UNAUTHORIZED" | "FORBIDDEN" diff --git a/src/lib/backend/services/contracts.ts b/src/lib/backend/services/contracts.ts index ffa78a0..3171bb3 100644 --- a/src/lib/backend/services/contracts.ts +++ b/src/lib/backend/services/contracts.ts @@ -126,7 +126,6 @@ export interface ResolveDisputeOnChainResult { resolvedAt: string; } -type ContractCallMode = 'read' | 'write'; export interface EarlyExitCommitmentOnChainParams { commitmentId: string; callerAddress?: string; @@ -1016,20 +1015,27 @@ export async function settleCommitmentOnChain( } if (commitment.status === "ACTIVE") { - // Check if commitment has expired (if expiresAt is available) + // Invariant: An active commitment can only be settled if it has matured. + // If expiresAt is present, we check if the current time is past the expiry time. + // If expiresAt is missing, we err on the side of safety and block settlement + // to prevent un-matured commitments from being settled prematurely. if (commitment.expiresAt) { const expiryTime = new Date(commitment.expiresAt).getTime(); const now = new Date().getTime(); if (now < expiryTime) { throw new BackendError({ - code: "BAD_REQUEST", + code: "NOT_MATURED", message: "Commitment has not matured yet and cannot be settled.", status: 400, }); } + } else { + throw new BackendError({ + code: "NOT_MATURED", + message: "Commitment maturity information is missing. Cannot settle.", + status: 400, + }); } - // TODO: Add additional maturity checks if needed - // For now, we'll allow settling active commitments } // Call the settlement function on the contract @@ -1169,9 +1175,6 @@ export async function fundEscrowOnChain( } } -export async function openDisputeOnChain( - params: DisputeOnChainParams, -): Promise { export async function earlyExitCommitmentOnChain( params: EarlyExitCommitmentOnChainParams, loggingContext?: LoggingContext, @@ -1180,18 +1183,11 @@ export async function earlyExitCommitmentOnChain( if (!params.commitmentId) { throw new BackendError({ code: "BAD_REQUEST", - message: "Missing commitment id for dispute.", message: "Missing commitment id for early exit.", status: 400, }); } - const commitment = await getCommitmentFromChain(params.commitmentId); - - if (commitment.status === "SETTLED" || commitment.status === "EARLY_EXIT") { - throw new BackendError({ - code: "CONFLICT", - message: "Cannot dispute a commitment that is already settled or exited.", const commitment = await getCommitmentFromChain(params.commitmentId, loggingContext); if (commitment.status === "SETTLED") { @@ -1203,10 +1199,6 @@ export async function earlyExitCommitmentOnChain( }); } - if (commitment.status === "DISPUTED") { - throw new BackendError({ - code: "CONFLICT", - message: "Commitment is already in dispute.", if (commitment.status === "EARLY_EXIT") { throw new BackendError({ code: "CONFLICT", @@ -1215,6 +1207,77 @@ export async function earlyExitCommitmentOnChain( }); } + 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, + contractVersion: invocation.version, + reference: invocation.txHash ? undefined : `TODO_CHAIN_CALL_EARLY_EXIT`, + }; + } catch (error) { + throw normalizeContractError(error, { + code: "BLOCKCHAIN_CALL_FAILED", + message: "Unable to exit commitment early on chain.", + status: 502, + details: { + method: "early_exit_commitment", + commitmentId: params.commitmentId, + }, + }); + } +} + +export async function openDisputeOnChain( + params: DisputeOnChainParams, +): Promise { + try { + if (!params.commitmentId) { + throw new BackendError({ + code: "BAD_REQUEST", + message: "Missing commitment id for dispute.", + status: 400, + }); + } + + const commitment = await getCommitmentFromChain(params.commitmentId); + + if (commitment.status === "SETTLED" || commitment.status === "EARLY_EXIT") { + throw new BackendError({ + code: "CONFLICT", + message: "Cannot dispute a commitment that is already settled or exited.", + status: 409, + }); + } + + if (commitment.status === "DISPUTED") { + throw new BackendError({ + code: "CONFLICT", + message: "Commitment is already in dispute.", + status: 409, + }); + } + const invocation = await invokeContractMethod( getContractId("commitmentCore"), "dispute", @@ -1273,10 +1336,6 @@ export async function resolveDisputeOnChain( throw new BackendError({ code: "CONFLICT", message: "Can only resolve a commitment that is currently in dispute.", - if (commitment.status === "VIOLATED") { - throw new BackendError({ - code: "CONFLICT", - message: "Commitment has been violated and cannot be exited early.", status: 409, }); } @@ -1285,8 +1344,6 @@ export async function resolveDisputeOnChain( getContractId("commitmentCore"), "resolve_dispute", [params.commitmentId, params.resolution, params.notes ?? ""], - "early_exit_commitment", - [params.commitmentId, params.callerAddress ?? commitment.ownerAddress], "write", ); @@ -1310,17 +1367,6 @@ export async function resolveDisputeOnChain( finalStatus, txHash: invocation.txHash, resolvedAt: new Date().toISOString(), - 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, - contractVersion: invocation.version, - reference: invocation.txHash ? undefined : `TODO_CHAIN_CALL_EARLY_EXIT`, }; } catch (error) { throw normalizeContractError(error, { @@ -1329,10 +1375,6 @@ export async function resolveDisputeOnChain( status: 502, details: { method: "resolve_dispute", - message: "Unable to exit commitment early on chain.", - status: 502, - details: { - method: "early_exit_commitment", commitmentId: params.commitmentId, }, }); diff --git a/tests/api/contracts.test.ts b/tests/api/contracts.test.ts index 53df636..eefc0ac 100644 --- a/tests/api/contracts.test.ts +++ b/tests/api/contracts.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { ErrorBodySchema, OkBodySchema, @@ -14,6 +14,37 @@ import { } from '@/lib/schemas/apiContracts'; import { z } from 'zod'; +vi.mock("ioredis", () => ({ default: class {} })); +vi.mock("@/lib/backend/cache/factory", () => ({ + cache: { + get: vi.fn(async () => null), + set: vi.fn(async () => {}), + delete: vi.fn(async () => {}), + }, +})); +vi.mock("@/lib/backend/counters/provider", () => ({ + getCountersAdapter: () => ({ + incrementSuccessfulActions: vi.fn(), + incrementChainFailures: vi.fn(), + }), +})); +vi.mock("@/lib/backend/config", () => ({ + getBackendConfig: () => ({ + sorobanRpcUrl: "https://example.invalid", + networkPassphrase: "TEST", + contractAddresses: { commitmentCore: "CORE", attestationEngine: "ENGINE" }, + }), +})); +vi.mock("@/lib/backend/logger", () => ({ + logInfo: vi.fn(), + logWarn: vi.fn(), + logError: vi.fn(), +})); + +import { cache } from '@/lib/backend/cache/factory'; +import { settleCommitmentOnChain } from '@/lib/backend/services/contracts'; +import { BackendError } from '@/lib/backend/errors'; + // ─── Helpers ────────────────────────────────────────────────────────────────── function expectValid(schema: T, value: unknown) { @@ -662,3 +693,53 @@ describe('Compliance Score Scaling Round-Trip', () => { }); }); }); + +describe('settleCommitmentOnChain maturity gate', () => { + it('throws NOT_MATURED error when active commitment has missing expiresAt', async () => { + const mockedCache = vi.mocked(cache); + mockedCache.get.mockResolvedValueOnce({ + id: 'cm_123', + ownerAddress: 'GABC', + asset: 'USDC', + amount: '1000', + status: 'ACTIVE', + complianceScore: 100, + currentValue: '1000', + feeEarned: '0', + violationCount: 0, + expiresAt: undefined, // missing expiresAt + } as any); + + await expect( + settleCommitmentOnChain({ commitmentId: 'cm_123' }) + ).rejects.toMatchObject({ + code: 'NOT_MATURED', + message: 'Commitment maturity information is missing. Cannot settle.', + status: 400, + }); + }); + + it('throws NOT_MATURED error when active commitment has expiresAt in the future', async () => { + const mockedCache = vi.mocked(cache); + mockedCache.get.mockResolvedValueOnce({ + id: 'cm_123', + ownerAddress: 'GABC', + asset: 'USDC', + amount: '1000', + status: 'ACTIVE', + complianceScore: 100, + currentValue: '1000', + feeEarned: '0', + violationCount: 0, + expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(), // 1 hour in future + } as any); + + await expect( + settleCommitmentOnChain({ commitmentId: 'cm_123' }) + ).rejects.toMatchObject({ + code: 'NOT_MATURED', + message: 'Commitment has not matured yet and cannot be settled.', + status: 400, + }); + }); +});