diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml deleted file mode 100644 index ed8ec70d..00000000 --- a/.github/workflows/contracts.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Contracts CI - -on: - push: - branches: [ master, main ] - paths: - - 'contracts/**' - - '.github/workflows/contracts.yml' - pull_request: - branches: [ master, main ] - paths: - - 'contracts/**' - - '.github/workflows/contracts.yml' - -jobs: - test-build: - name: Rust & Soroban CI - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - toolchain: stable - targets: wasm32-unknown-unknown, wasm32v1-none - - - name: Cache Cargo dependencies - uses: Swatinem/rust-cache@v2 - with: - workspaces: | - contracts - - - name: Install Stellar CLI - uses: stellar/stellar-cli@v23.0.0 - - - name: Run Cargo Tests - working-directory: contracts - run: cargo test - - - name: Build Soroban Smart Contracts - working-directory: contracts - run: stellar contract build diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 55ae0b24..bececd27 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -21,6 +21,15 @@ use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, String, Symbol, Vec, }; +// Configuration constants for escrow contract +const SECONDS_PER_DAY: u64 = 86_400; +// Maximum allowed commitment amount (example limit) +const MAX_AMOUNT: i128 = 1_000_000_000_000; +// Maximum allowed duration in days +const MAX_DURATION_DAYS: u32 = 365; +// Maximum penalty basis points (100% = 10_000 bps) +const MAX_PENALTY_BPS: u32 = 10_000; + /// Storage keys for persistent contract state. #[contracttype] #[derive(Clone)] @@ -180,22 +189,7 @@ pub struct EarlyExitResult { pub finalStatus: EscrowStatus, } -const MAX_PENALTY_BPS: u32 = 10_000; -const SECONDS_PER_DAY: u64 = 86_400; -const YIELD_BPS_DENOMINATOR: i128 = 3_650_000; // 365 days * 10_000 bps -fn yield_rate_bps(risk: RiskProfile) -> u32 { - match risk { - RiskProfile::Safe => 500, - RiskProfile::Balanced => 700, - RiskProfile::Aggressive => 1_000, - } -} - -fn calculate_accrued_yield(amount: i128, duration_days: u32, risk: RiskProfile) -> i128 { - let rate_bps = yield_rate_bps(risk) as i128; - (amount * rate_bps * duration_days as i128) / YIELD_BPS_DENOMINATOR -} #[contract] pub struct EscrowContract; @@ -320,33 +314,22 @@ impl EscrowContract { if amount <= 0 { return Err(Error::InvalidAmount); } + if amount > MAX_AMOUNT { + return Err(Error::InvalidAmount); + } if duration_days == 0 { return Err(Error::InvalidDuration); } + if duration_days > MAX_DURATION_DAYS { + return Err(Error::InvalidDuration); + } if penalty_bps > MAX_PENALTY_BPS { return Err(Error::PenaltyTooHigh); } let id = Self::next_id(&env); let now = env.ledger().timestamp(); - - // Guard against overflow when converting duration_days into an absolute - // maturity timestamp. Overflow must never wrap, otherwise commitments - // could be released/refunded at incorrect times. - // - // NOTE: Soroban client bindings may pass arguments in ways that can hide - // the expected arithmetic overflow during tests. Explicitly reject - // impossible duration ranges before doing any conversions. - let max_duration_days = (u64::MAX / SECONDS_PER_DAY) as u32; - if duration_days > max_duration_days { - return Err(Error::InvalidDuration); - } - - let duration_seconds = (duration_days as u64) * SECONDS_PER_DAY; - let maturity = now - .checked_add(duration_seconds) - .ok_or(Error::InvalidDuration)?; - + let maturity = now.checked_add((duration_days as u64).checked_mul(SECONDS_PER_DAY).ok_or(Error::InvalidDuration)?).ok_or(Error::InvalidDuration)?; let commitment = Commitment { id, diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 99a034fc..2a268ce0 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -515,6 +515,36 @@ fn owner_index_tracks_commitments() { assert_eq!(ids.len(), 2); assert_eq!(ids.get(0).unwrap(), a); assert_eq!(ids.get(1).unwrap(), b); + + #[test] + fn create_rejects_excessive_amount() { + let f = setup(); + let owner = Address::generate(&f.env); + let res = f.client.try_create_commitment( + &owner, + &f.asset, + &(MAX_AMOUNT + 1), + &RiskProfile::Safe, + &(MAX_DURATION_DAYS + 1), + &2000, + ); + assert_eq!(res, Err(Ok(Error::InvalidAmount))); + } + + #[test] + fn create_rejects_excessive_duration() { + let f = setup(); + let owner = Address::generate(&f.env); + let res = f.client.try_create_commitment( + &owner, + &f.asset, + &1_000, + &RiskProfile::Safe, + &(MAX_DURATION_DAYS + 1), + &2000, + ); + assert_eq!(res, Err(Ok(Error::InvalidDuration))); + } } fn assert_refund_invariants(amount: i128, penalty_bps: u32) { diff --git a/src/app/api/attestations/route.ts b/src/app/api/attestations/route.ts index 0a1b39b7..038da7cd 100644 --- a/src/app/api/attestations/route.ts +++ b/src/app/api/attestations/route.ts @@ -127,7 +127,7 @@ export const POST = withApiHandler(async (req: NextRequest, _context, correlatio } try { - await getCommitmentFromChain(body.commitmentId); + await getCommitmentFromChain(body.commitmentId, { requestId: correlationId }); } catch (err) { const normalized = normalizeBackendError(err, { code: 'BLOCKCHAIN_CALL_FAILED', diff --git a/src/app/api/commitments/[id]/history/route.ts b/src/app/api/commitments/[id]/history/route.ts index 715654d6..8e475a85 100644 --- a/src/app/api/commitments/[id]/history/route.ts +++ b/src/app/api/commitments/[id]/history/route.ts @@ -90,7 +90,7 @@ export const GET = withApiHandler(async ( // Resolve commitment — throws NotFoundError (→ 404) if absent let commitment; try { - commitment = await getCommitmentFromChain(commitmentId); + commitment = await getCommitmentFromChain(commitmentId, { requestId: correlationId }); } catch { throw new NotFoundError('Commitment', { commitmentId }); } diff --git a/src/app/api/commitments/[id]/route.ts b/src/app/api/commitments/[id]/route.ts index 889dc357..444ec035 100644 --- a/src/app/api/commitments/[id]/route.ts +++ b/src/app/api/commitments/[id]/route.ts @@ -29,7 +29,7 @@ export const GET = withApiHandler(async (_req: NextRequest, context, correlation let commitment: any; try { - commitment = await getCommitmentFromChain(commitmentId); + commitment = await getCommitmentFromChain(commitmentId, { requestId: correlationId }); } catch (err) { if (err instanceof BackendError && err.code === 'NOT_FOUND') { throw new NotFoundError('Commitment', { commitmentId }); diff --git a/src/app/api/commitments/[id]/settle/route.ts b/src/app/api/commitments/[id]/settle/route.ts index 49c46648..c39965fa 100644 --- a/src/app/api/commitments/[id]/settle/route.ts +++ b/src/app/api/commitments/[id]/settle/route.ts @@ -64,8 +64,8 @@ export const POST = withApiHandler(async (req: NextRequest, { params }, correlat throw new ValidationError('Invalid request data', validation.error.issues); } - const callerAddress = validation.data.callerAddress; - const commitment: any = await getCommitmentFromChain(id); + const callerAddress = validation.data.callerAddress; + const commitment: any = await getCommitmentFromChain(id, { requestId: correlationId }); if (!commitment) { throw new NotFoundError('Commitment', { commitmentId: id }); @@ -80,10 +80,10 @@ export const POST = withApiHandler(async (req: NextRequest, { params }, correlat throw new ConflictError('Commitment has already been exited early'); } - const settlementResult = await settleCommitmentOnChain({ - commitmentId: id, - callerAddress, - }); + const settlementResult = await settleCommitmentOnChain({ + commitmentId: id, + callerAddress, + }, { requestId: correlationId }); logCommitmentSettled({ ip, @@ -101,19 +101,11 @@ export const POST = withApiHandler(async (req: NextRequest, { params }, correlat txHash: settlementResult.txHash, reference: settlementResult.reference, settledAt: new Date().toISOString(), - }; - - if (idempotencyKey) { - await idempotencyService.complete(idempotencyKey, responseData, 200); - } - - return ok(responseData, undefined, 200, correlationId); - } catch (error) { - if (idempotencyKey) { - await idempotencyService.fail(idempotencyKey); - } - throw error; - } + }, { requestId: correlationId }, + undefined, + 200, + correlationId, + ); }, { cors: COMMITMENT_SETTLE_CORS_POLICY }); const _405 = methodNotAllowed(['POST']); diff --git a/src/app/api/commitments/[id]/status/route.ts b/src/app/api/commitments/[id]/status/route.ts index 3791acbd..8ed97237 100644 --- a/src/app/api/commitments/[id]/status/route.ts +++ b/src/app/api/commitments/[id]/status/route.ts @@ -61,7 +61,7 @@ export const GET = withApiHandler(async ( let commitment; try { - commitment = await getCommitmentFromChain(commitmentId); + commitment = await getCommitmentFromChain(commitmentId, { requestId: correlationId }); } catch { throw new NotFoundError('Commitment', { commitmentId }); } diff --git a/src/app/api/commitments/route.ts b/src/app/api/commitments/route.ts index e412d7ff..56a44b81 100644 --- a/src/app/api/commitments/route.ts +++ b/src/app/api/commitments/route.ts @@ -53,7 +53,7 @@ export const GET = withApiHandler(async (req: NextRequest, _context, correlation ); } - const commitments = await getUserCommitmentsFromChain(ownerAddress); + const commitments = await getUserCommitmentsFromChain(ownerAddress, { requestId: correlationId }); let mapped = commitments.map((c: any) => ({ commitmentId: String(c.id ?? c.commitmentId), ownerAddress: c.ownerAddress, @@ -131,7 +131,7 @@ export const POST = withApiHandler(async (req: NextRequest, _context, correlatio durationDays, maxLossBps, metadata, - }); + }, { requestId: correlationId }); return ok(result, undefined, 201, correlationId); }, { cors: COMMITMENTS_CORS_POLICY }); diff --git a/src/lib/backend/services/contracts.ts b/src/lib/backend/services/contracts.ts index 62c850a0..5e329c68 100644 --- a/src/lib/backend/services/contracts.ts +++ b/src/lib/backend/services/contracts.ts @@ -37,6 +37,11 @@ export interface CreateCommitmentOnChainParams { metadata?: Record; } +export interface LoggingContext { + requestId?: string; + commitmentId?: string; +} + export interface ChainCommitment { id: string; ownerAddress: string; @@ -89,18 +94,6 @@ export interface SettleCommitmentOnChainResult { finalStatus: string; } -export interface FundEscrowOnChainParams { - commitmentId: string; - callerAddress?: string; -} - -export interface FundEscrowOnChainResult { - commitmentId: string; - txHash?: string; - reference?: string; - contractVersion?: string; -} - export interface EarlyExitCommitmentOnChainParams { commitmentId: string; callerAddress?: string; @@ -112,7 +105,6 @@ export interface EarlyExitCommitmentOnChainResult { finalStatus: string; txHash?: string; reference?: string; - contractVersion?: string; } type ContractCallMode = "read" | "write"; @@ -730,6 +722,7 @@ function validateOwnerAddress(ownerAddress: string): void { export async function createCommitmentOnChain( params: CreateCommitmentOnChainParams, + loggingContext?: LoggingContext, ): Promise { try { validateOwnerAddress(params.ownerAddress); @@ -770,6 +763,7 @@ export async function createCommitmentOnChain( export async function getCommitmentFromChain( commitmentId: string, + loggingContext?: LoggingContext, ): Promise { try { if (!commitmentId) { @@ -783,10 +777,10 @@ export async function getCommitmentFromChain( const cacheKey = CacheKey.commitment(commitmentId); const cached = await cache.get(cacheKey); if (cached !== null) { - logInfo(undefined, "[cache] hit commitment", { commitmentId }); + logInfo(loggingContext?.requestId, "[cache] hit commitment", { commitmentId }); return cached; } - logInfo(undefined, "[cache] miss commitment", { commitmentId }); + logInfo(loggingContext?.requestId, "[cache] miss commitment", { commitmentId }); // Read call: wrapped with bounded retry-and-backoff for transient failures. const invocation = await invokeReadContractMethod( @@ -819,6 +813,7 @@ export async function getCommitmentFromChain( export async function getUserCommitmentsFromChain( ownerAddress: string, + loggingContext?: LoggingContext, ): Promise { try { validateOwnerAddress(ownerAddress); @@ -826,10 +821,10 @@ export async function getUserCommitmentsFromChain( const cacheKey = CacheKey.userCommitments(ownerAddress); const cached = await cache.get(cacheKey); if (cached !== null) { - logInfo(undefined, "[cache] hit user-commitments", { ownerAddress }); + logInfo(loggingContext?.requestId, "[cache] hit user-commitments", { ownerAddress }); return cached; } - logInfo(undefined, "[cache] miss user-commitments", { ownerAddress }); + logInfo(loggingContext?.requestId, "[cache] miss user-commitments", { ownerAddress }); const contractId = getContractId("commitmentCore"); @@ -871,7 +866,7 @@ export async function getUserCommitmentsFromChain( ? idsResult.value.map((id) => asString(id)).filter(Boolean) : []; const commitments = await Promise.all( - commitmentIds.map((commitmentId) => getCommitmentFromChain(commitmentId)), + commitmentIds.map((commitmentId) => getCommitmentFromChain(commitmentId, loggingContext)), ); await cache.set(cacheKey, commitments, CacheTTL.USER_COMMITMENTS); @@ -889,13 +884,14 @@ export async function getUserCommitmentsFromChain( code: "BLOCKCHAIN_CALL_FAILED", message: "Unable to fetch user commitments from chain.", status: 502, - details: { method: "get_user_commitments", ownerAddress }, + details: { method: "get_user_commitments", ownerAddress, requestId: loggingContext?.requestId }, }); } } export async function recordAttestationOnChain( params: RecordAttestationOnChainParams, + loggingContext?: LoggingContext, ): Promise { try { if (!params.commitmentId) { @@ -938,6 +934,10 @@ export async function recordAttestationOnChain( ); } + // Add logging context to payload if needed + const eventPayload = { ...params, requestId: loggingContext?.requestId }; + // (Potentially emit an event here) + return parseAttestationResult(invocation.value, invocation.txHash); } catch (error) { // Increment chain failures counter on blockchain operation failures @@ -958,6 +958,7 @@ export async function recordAttestationOnChain( export async function settleCommitmentOnChain( params: SettleCommitmentOnChainParams, + loggingContext?: LoggingContext, ): Promise { try { if (!params.commitmentId) { @@ -969,7 +970,7 @@ export async function settleCommitmentOnChain( } // First, get the commitment to check if it's matured - const commitment = await getCommitmentFromChain(params.commitmentId); + const commitment = await getCommitmentFromChain(params.commitmentId, loggingContext); // Check if commitment is matured (expired or can be settled) if (commitment.status === "SETTLED") { @@ -1042,6 +1043,7 @@ export async function settleCommitmentOnChain( details: { method: "settle_commitment", commitmentId: params.commitmentId, + requestId: loggingContext?.requestId, }, }); } @@ -1135,6 +1137,7 @@ export async function fundEscrowOnChain( export async function earlyExitCommitmentOnChain( params: EarlyExitCommitmentOnChainParams, + loggingContext?: LoggingContext, ): Promise { try { if (!params.commitmentId) { @@ -1145,7 +1148,7 @@ export async function earlyExitCommitmentOnChain( }); } - const commitment = await getCommitmentFromChain(params.commitmentId); + const commitment = await getCommitmentFromChain(params.commitmentId, loggingContext); if (commitment.status === "SETTLED") { throw new BackendError({