Skip to content
Open
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
11 changes: 11 additions & 0 deletions src/lib/backend/errorCodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ export const ERROR_CODE_REGISTRY: Record<string, ErrorCodeDefinition> = {
"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",
Expand Down
1 change: 1 addition & 0 deletions src/lib/backend/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export const HTTP_ERROR_CODES: Record<number, string> = {

export type BackendErrorCode =
| "BAD_REQUEST"
| "NOT_MATURED"
| "VALIDATION_ERROR"
| "UNAUTHORIZED"
| "FORBIDDEN"
Expand Down
122 changes: 82 additions & 40 deletions src/lib/backend/services/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@ export interface ResolveDisputeOnChainResult {
resolvedAt: string;
}

type ContractCallMode = 'read' | 'write';
export interface EarlyExitCommitmentOnChainParams {
commitmentId: string;
callerAddress?: string;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1169,9 +1175,6 @@ export async function fundEscrowOnChain(
}
}

export async function openDisputeOnChain(
params: DisputeOnChainParams,
): Promise<DisputeOnChainResult> {
export async function earlyExitCommitmentOnChain(
params: EarlyExitCommitmentOnChainParams,
loggingContext?: LoggingContext,
Expand All @@ -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") {
Expand All @@ -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",
Expand All @@ -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<DisputeOnChainResult> {
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",
Expand Down Expand Up @@ -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,
});
}
Expand All @@ -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",
);

Expand All @@ -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, {
Expand All @@ -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,
},
});
Expand Down
83 changes: 82 additions & 1 deletion tests/api/contracts.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import {
ErrorBodySchema,
OkBodySchema,
Expand All @@ -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<T extends z.ZodTypeAny>(schema: T, value: unknown) {
Expand Down Expand Up @@ -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,
});
});
});