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
8 changes: 8 additions & 0 deletions contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ create_commitment ──► fund_escrow ──► release (matured: p
| `unpause()` | Admin-only resume for paused contract writes. |
| `is_paused()` | Read the current paused state. |
| `get_commitment(commitment_id)` | Read a single commitment record. |
| `get_user_commitments(owner)` | Read up to `MAX_USER_COMMITMENTS_READ` full `Commitment` records for `owner`. This is the primary backend read path and is intentionally bounded to keep Soroban read responses within practical limits. |
| `get_user_commitment_ids(owner)` | Read all commitment ids for `owner`. The backend uses this as its fallback path when it needs to hydrate records one by one. |
| `get_owner_commitments(owner)` | List commitment ids owned by an address. |
| `get_attestations(commitment_id)` | Retrieve the timeline of `AttestationRecord`s for a commitment. |
| `refund_partial(commitment_id, amount)` | Partial early-exit: withdraw `amount` from the principal, apply the proportional penalty to that portion, keep the remainder escrowed. |
Expand All @@ -102,6 +104,12 @@ create_commitment ──► fund_escrow ──► release (matured: p

Compliance scores recorded via `record_attestation` are appended to an on-chain historical log. This allows clients to query the timeline of scores for a given commitment rather than just reading the latest value. Use `get_attestations` to retrieve a list of `AttestationRecord` structures, each containing the attestor address, the compliance score, and the timestamp.

### User commitment readers

The backend first tries `get_user_commitments(owner)` so it can read a user's commitments in one typed call. That reader now returns full `Commitment` records directly from the owner index and intentionally caps the response size with `MAX_USER_COMMITMENTS_READ` to avoid oversized Soroban read payloads.

For compatibility and fallback hydration, the contract also keeps an id-only reader at `get_user_commitment_ids(owner)`. The older `get_owner_commitments(owner)` name remains available as a legacy alias for the same owner index.

### `early_exit_commitment` entrypoint details

#### ABI Signature
Expand Down
52 changes: 47 additions & 5 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
//! `fund_escrow`, `release`, `refund`, and `dispute`.

use soroban_sdk::{
contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, String, Symbol, Vec,
contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, Map, String,
Symbol, Vec,
};

// Configuration constants for escrow contract
Expand All @@ -37,6 +38,10 @@ const MAX_DURATION_DAYS: u32 = 365;
/// Upper bound for penalty basis points (10_000 = 100%).
const MAX_PENALTY_BPS: u32 = 10_000;

/// Bound full-record owner reads so a single query does not exceed Soroban
/// simulation/result size limits.
const MAX_USER_COMMITMENTS_READ: u32 = 100;

/// Storage keys for persistent contract state.
#[contracttype]
#[derive(Clone)]
Expand Down Expand Up @@ -962,6 +967,8 @@ impl EscrowContract {
);

Ok(())
}

/// Return the list of attestation history for a commitment id.
pub fn get_attestations(env: Env, commitment_id: u64) -> Vec<AttestationRecord> {
env.storage()
Expand All @@ -970,12 +977,40 @@ impl EscrowContract {
.unwrap_or_else(|| Vec::new(&env))
}

/// Return full commitment records for a user.
///
/// This is the backend's primary read path. The result is intentionally
/// bounded so a single read stays within Soroban RPC payload limits.
pub fn get_user_commitments(env: Env, owner: Address) -> Vec<Commitment> {
let ids = Self::owner_commitment_ids(&env, owner);
let mut commitments = Vec::new(&env);
let limit = ids.len().min(MAX_USER_COMMITMENTS_READ);
let mut index = 0;

while index < limit {
let commitment_id = ids.get(index).unwrap();
if let Some(commitment) = env
.storage()
.persistent()
.get(&DataKey::Commitment(commitment_id))
{
commitments.push_back(commitment);
}
index += 1;
}

commitments
}

/// Return the list of commitment ids owned by an address using the backend's
/// fallback reader name.
pub fn get_user_commitment_ids(env: Env, owner: Address) -> Vec<u64> {
Self::owner_commitment_ids(&env, owner)
}

/// Return the list of commitment ids owned by an address.
pub fn get_owner_commitments(env: Env, owner: Address) -> Vec<u64> {
env.storage()
.persistent()
.get(&DataKey::OwnerIndex(owner))
.unwrap_or_else(|| Vec::new(&env))
Self::owner_commitment_ids(&env, owner)
}

/// Retrieve the dispute record for a commitment. Returns `None` if no
Expand Down Expand Up @@ -1154,6 +1189,13 @@ impl EscrowContract {
.set(&DataKey::OwnerIndex(owner.clone()), &ids);
}

fn owner_commitment_ids(env: &Env, owner: Address) -> Vec<u64> {
env.storage()
.persistent()
.get(&DataKey::OwnerIndex(owner))
.unwrap_or_else(|| Vec::new(env))
}

/// Remove `id` from `owner`'s OwnerIndex list.
fn deindex_owner(env: &Env, owner: &Address, id: u64) {
let mut ids: Vec<u64> = env
Expand Down
73 changes: 73 additions & 0 deletions contracts/escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,79 @@ fn owner_index_tracks_commitments() {
assert_eq!(ids.get(1).unwrap(), b);
}

#[test]
fn get_user_commitments_returns_full_records() {
let f = setup();
let owner = Address::generate(&f.env);
let first_id = f.client.create_commitment(
&owner,
&f.asset,
&100,
&RiskProfile::Safe,
&30,
&200,
&Map::new(&f.env),
);
let second_id = f.client.create_commitment(
&owner,
&f.asset,
&250,
&RiskProfile::Balanced,
&45,
&300,
&Map::new(&f.env),
);

let commitments = f.client.get_user_commitments(&owner);

assert_eq!(commitments.len(), 2);

let first = commitments.get(0).unwrap();
assert_eq!(first.id, first_id);
assert_eq!(first.owner, owner);
assert_eq!(first.amount, 100);
assert_eq!(first.status, EscrowStatus::Created);

let second = commitments.get(1).unwrap();
assert_eq!(second.id, second_id);
assert_eq!(second.owner, owner);
assert_eq!(second.amount, 250);
assert_eq!(second.status, EscrowStatus::Created);
}

#[test]
fn get_user_commitments_is_bounded() {
let f = setup();
let owner = Address::generate(&f.env);

for index in 0..(MAX_USER_COMMITMENTS_READ + 5) {
let amount = 100 + index as i128;
f.client.create_commitment(
&owner,
&f.asset,
&amount,
&RiskProfile::Safe,
&30,
&200,
&Map::new(&f.env),
);
}

let commitments = f.client.get_user_commitments(&owner);
let ids = f.client.get_user_commitment_ids(&owner);

assert_eq!(commitments.len(), MAX_USER_COMMITMENTS_READ);
assert_eq!(ids.len(), MAX_USER_COMMITMENTS_READ + 5);
assert_eq!(commitments.get(0).unwrap().id, ids.get(0).unwrap());
assert_eq!(
commitments
.get(MAX_USER_COMMITMENTS_READ - 1)
.unwrap()
.id,
ids.get(MAX_USER_COMMITMENTS_READ - 1).unwrap()
);
}

#[test]
fn create_rejects_excessive_amount() {
let f = setup();
Expand Down
2 changes: 0 additions & 2 deletions src/app/api/commitments/[id]/events/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,7 @@ export const GET = withApiHandler(async (
req: NextRequest,
context: { params: Record<string, string> },
) => {
// 1. Authenticate Request
requireAuth(req);

const commitmentId = context.params.id;
if (!commitmentId) {
throw new NotFoundError('Commitment');
Expand Down
16 changes: 11 additions & 5 deletions src/app/api/commitments/[id]/history/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const DEFAULT_HISTORY_PAGE_SIZE = 20;
export const GET = withApiHandler(async (
req: NextRequest,
context: { params: Record<string, string> },
correlationId: string,
) => {
const commitmentId = context.params.id;

Expand Down Expand Up @@ -101,9 +102,14 @@ export const GET = withApiHandler(async (
// Paginate
const page = paginateArray(events, pagination);

return ok({
commitmentId,
events: page.data,
meta: page.meta,
});
return ok(
{
commitmentId,
events: page.data,
meta: page.meta,
},
undefined,
200,
correlationId,
);
});
98 changes: 47 additions & 51 deletions src/app/api/commitments/[id]/settle/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@ import { z } from 'zod';
import { ok, methodNotAllowed } from '@/lib/backend/apiResponse';
import { assertMutationCsrf } from '@/lib/backend/csrf';
import { createCorsOptionsHandler, type CorsRoutePolicy } from '@/lib/backend/cors';
import { ConflictError, NotFoundError, TooManyRequestsError, ValidationError } from '@/lib/backend/errors';
import { ConflictError, ForbiddenError, NotFoundError, TooManyRequestsError, ValidationError } from '@/lib/backend/errors';
import { getClientIp } from '@/lib/backend/getClientIp';
import { getCommitmentFromChain, settleCommitmentOnChain } from '@/lib/backend/services/contracts';
import { logCommitmentSettled } from '@/lib/backend/logger';
import { checkRateLimit, getRateLimitWindowSeconds } from '@/lib/backend/rateLimit';
import { withApiHandler } from '@/lib/backend/withApiHandler';
import { idempotencyService } from '@/lib/backend/idempotency';

const SettleRequestSchema = z.object({
callerAddress: z.string().optional(),
Expand Down Expand Up @@ -38,75 +37,72 @@ export const POST = withApiHandler(async (req: NextRequest, { params }, correlat
throw new ValidationError('Commitment ID is required');
}

const idempotencyKey = req.headers.get('idempotency-key');
if (idempotencyKey) {
const record = await idempotencyService.getRecord(idempotencyKey);
if (record) {
if (record.status === 'COMPLETED') {
return ok(record.response, undefined, record.statusCode, correlationId);
} else if (record.status === 'STARTED') {
throw new ConflictError('A request with this Idempotency-Key is currently processing');
}
}
await idempotencyService.start(idempotencyKey);
}

let body: unknown;
try {
let body: unknown;
try {
body = await req.json();
} catch {
throw new ValidationError('Invalid JSON in request body');
}
body = await req.json();
} 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.issues);
}
const validation = SettleRequestSchema.safeParse(body);
if (!validation.success) {
throw new ValidationError('Invalid request data', validation.error.issues);
}

const callerAddress = validation.data.callerAddress;
const commitment: any = await getCommitmentFromChain(id, { requestId: correlationId });

if (!commitment) {
throw new NotFoundError('Commitment', { commitmentId: id });
}
if (commitment.status === 'SETTLED') {
throw new ConflictError('Commitment has already been settled');
}
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');
}

const settlementResult = await settleCommitmentOnChain({
commitmentId: id,
callerAddress,
}, { requestId: correlationId });
if (!commitment) {
throw new NotFoundError('Commitment', { commitmentId: id });
}
if (commitment.status === 'SETTLED') {
throw new ConflictError('Commitment has already been settled');
}
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 (
callerAddress &&
commitment.ownerAddress &&
callerAddress.toLowerCase() !== commitment.ownerAddress.toLowerCase()
) {
throw new ForbiddenError('You do not own this commitment');
}

logCommitmentSettled({
ip,
const settlementResult = await settleCommitmentOnChain(
{
commitmentId: id,
callerAddress,
settlementAmount: settlementResult.settlementAmount,
finalStatus: settlementResult.finalStatus,
txHash: settlementResult.txHash,
});
},
{ requestId: correlationId },
);

logCommitmentSettled({
ip,
commitmentId: id,
callerAddress,
settlementAmount: settlementResult.settlementAmount,
finalStatus: settlementResult.finalStatus,
txHash: settlementResult.txHash,
});

const responseData = {
return ok(
{
commitmentId: id,
settlementAmount: settlementResult.settlementAmount,
finalStatus: settlementResult.finalStatus,
txHash: settlementResult.txHash,
reference: settlementResult.reference,
settledAt: new Date().toISOString(),
}, { requestId: correlationId },
},
undefined,
200,
correlationId,
);
}, { cors: COMMITMENT_SETTLE_CORS_POLICY });

const _405 = methodNotAllowed(['POST']);
export { _405 as GET, _405 as PUT, _405 as PATCH, _405 as DELETE };
export { _405 as GET, _405 as PUT, _405 as PATCH, _405 as DELETE };
5 changes: 3 additions & 2 deletions src/app/api/commitments/[id]/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function getDaysRemaining(expiresAt?: string): number {
export const GET = withApiHandler(async (
req: NextRequest,
context: { params: Record<string, string> },
correlationId: string,
) => {
const ip = req.ip ?? req.headers.get('x-forwarded-for') ?? 'anonymous';
const isAllowed = await checkRateLimit(ip, 'api/commitments/status');
Expand Down Expand Up @@ -80,5 +81,5 @@ export const GET = withApiHandler(async (
expiresAt: commitment.expiresAt ?? null,
};

return ok(response);
});
return ok(response, undefined, 200, correlationId);
});
Loading
Loading