Skip to content

[Cross-Layer] Escrow Release/Refund Atomicity and Fund-Safety Invariant #236

Description

@david87131

Task Classification: Hardening / invariant enforcement (audit-funded tier)
Affected Layers: contract, backend, frontend
Affected Paths: contract/contracts/escrow/, contract/contracts/payment/, contract/contracts/dispute_resolution/, backend/src/modules/transactions/, backend/src/modules/queues/, backend/src/modules/stellar/, backend/src/modules/payments/, backend/src/modules/disputes/, backend/src/migrations/, frontend/app/payments/, frontend/lib/stellar.ts
Severity: Critical (fund-safety; irreversible on-chain custody movement)
Estimated Window: 72-96 hours

Technical Context & Monorepo Integration Failure

The escrow crate custodies tenant deposits and rent in USDC and moves them to landlord (release) or tenant (refund) on condition. Fund safety depends on one invariant that no single layer currently owns end to end:

  • Accounting: the sum of all active escrowed balances tracked in escrow persistent storage equals the token balance the escrow contract holds on-chain.
  • Terminality: each escrow reaches exactly one terminal outcome. Release and refund are mutually exclusive and non-repeatable.
  • Authorization: every state transition carries require_auth from the party entitled to trigger it under the current escrow phase.
  • Compensation: a release or refund that depends on a downstream leg (a dispute_resolution status check, a payment crate transfer) either commits atomically or leaves custody unchanged.

The gap spans layers because the three tiers hold independent, unreconciled notions of "settled." A single-layer fix is insufficient: contract-only guards stop double-spend on-chain but do not stop the backend from re-submitting a second transition it believes still pending; backend-only idempotency does not stop a contract that lacks a terminal-state guard from executing a stale envelope; frontend-only state labels can display "refunded" while custody still sits in escrow. Fund safety requires the contract to be the terminal authority, the backend to record intent before submission and reconcile against on-chain truth, and the frontend to render only backend-confirmed state.

Drift walkthrough (missing control: terminal-state guard plus intent ledger reconciliation):

  1. Tenant requests refund. backend/src/modules/payments builds a refund invocation and enqueues a stellar-submit job on the Bull transactions queue.
  2. The worker submits the transaction. Horizon accepts it; the escrow ledger entry moves to refunded on-chain, but the Horizon response times out before the worker records success.
  3. Bull retry policy re-runs the job. A second refund envelope is submitted. Without a terminal-state guard in the escrow crate, the second invocation is evaluated against storage the contract believes still active.
  4. Concurrently a landlord release request for the same escrow id is enqueued from a different module path, because no backend row marks the escrow as intent-locked.
  5. Result absent the invariant: duplicate refund or a release-plus-refund pair, breaking terminality and driving tracked balances out of agreement with on-chain custody. The reconciliation job has no authoritative intent record to detect or compensate the divergence.

The consequence is silent custody loss or double payout. The fix installs the missing controls: an on-chain terminal state machine, a backend intent ledger with idempotency keyed to escrow id and transition, and a reconciler that treats on-chain state as source of truth.

Core Component Invariants & Code Paths

Smart Contract Infrastructure

  • escrow crate: add an EscrowStatus enum (Funded, Released, Refunded, Disputed) in persistent storage per escrow id, with extend_ttl on every access. release and refund must load status and return a typed EscrowError (AlreadyReleased, AlreadyRefunded, NotFunded, Unauthorized, CustodyMismatch) if the current status is not Funded.
  • Post-change invariants: release and refund are guarded by require_auth for the entitled party; each transitions Funded to a terminal status in a single atomic invocation and emits escrow_released or escrow_refunded via env.events().publish. A downstream dispute_resolution::is_open or payment::transfer call that panics or returns error aborts the whole invocation, leaving status and custody unchanged (Soroban rolls back on host error). Add an assert that post-transition tracked total equals on-chain token balance before commit.
  • payment and dispute_resolution crates: expose read functions the escrow invocation can call in the same transaction so the downstream leg is atomic, not a separate submission.

Backend/API Layer

  • transactions module: new TypeORM entity escrow_intent (columns escrow_id, transition enum, idempotency_key unique, status pending/submitted/settled/failed/compensated, tx_hash, expected_status, observed_status). Insert the intent row before enqueueing submission; the unique constraint on (escrow_id, transition) plus a partial index rejecting a second non-failed transition per escrow enforces terminality at the database boundary.
  • queues module: the stellar-submit Bull job is keyed by idempotency_key; retries reuse the same key and never build a second envelope. Add an escrow-reconcile repeatable job that reads on-chain EscrowStatus through the stellar module and closes, fails, or compensates each intent against observed state.
  • stellar module: getEscrowStatus(escrowId) reads contract storage as authoritative truth. payments and disputes modules call the intent ledger; neither submits an escrow transition directly.
  • Post-change invariants: no escrow transition is submitted without a prior escrow_intent row; the reconciler flips status to settled only after the on-chain terminal status matches expected_status; a divergence raises a monitoring alert and marks the intent compensated. New migration under backend/src/migrations/ creates escrow_intent and its indexes.

Frontend Client

  • frontend/app/payments/: escrow display state derives only from the backend escrow_intent row via TanStack Query. Submission progress reads from escrow_intent.status (pending, submitted, settled, failed, compensated); terminal custody outcome reads from escrow_intent.observed_status (Funded, Released, Refunded, Disputed). No render derives from an optimistic client mutation.
  • frontend/lib/stellar.ts: remove any client-side inference of settlement from a submitted tx hash; treat a submitted transaction as pending until the backend reconciler confirms terminal status. Disable release and refund controls when the escrow_intent for the escrow id is not in a terminal or failed state.

Root Configuration & Orchestration

  • CI (.github/workflows/contract-ci-cd.yml, backend-ci-cd.yml, frontend-ci-cd.yml): add the new escrow invariant tests as required gates. contract/check-all.sh must run the terminal-state and custody-equality tests under cargo test.
  • Shared types: regenerate the EscrowStatus and transition enum bindings consumed by backend and frontend so the three layers agree on identical status strings.

Verification & Acceptance Criteria

  • contract/check-all.sh passes: build, clippy, and cargo test green.
  • backend compiles; pnpm --filter backend build and migration:run apply the escrow_intent migration and migration:revert rolls it back.
  • frontend typechecks and builds against regenerated enum bindings.
  • Contract unit tests: double release, double refund, and release then refund on one escrow id each return the correct EscrowError; a forced downstream error leaves status and custody unchanged.
  • Contract property test: sum of tracked escrowed balances equals contract token balance after any sequence of fund/release/refund operations.
  • Backend integration tests: duplicate enqueue with the same idempotency_key submits one envelope; the reconciler settles only on matching on-chain terminal status; an injected Horizon timeout does not produce a second submission.
  • End-to-end test: fund, request refund, force a retry, assert single terminal outcome and that the frontend renders observed_status Refunded only after reconciliation.
  • PR attachments: pre/post state dumps of escrow storage and contract token balance; escrow_intent table dump across the e2e run; reconciler log showing observed-vs-expected convergence.

Suggested Execution Path

  • Phase 0 - Invariant spec and test harness (6-8h). Deliverable: written invariant, shared enum definitions, failing tests across all three layers. Exit check: red tests exist for double-release, double-refund, and custody-equality.
  • Phase 1 - Contract state machine and atomic transitions (22-28h). Deliverable: EscrowStatus, guarded release/refund, in-transaction downstream calls, custody-equality assert, events, EscrowError variants. Exit check: contract/check-all.sh green including new tests.
  • Phase 2 - Backend intent ledger and reconciliation (22-30h). Deliverable: escrow_intent entity plus migration, idempotent stellar-submit, escrow-reconcile job, getEscrowStatus. Exit check: integration tests pass; duplicate submission and timeout scenarios produce one terminal outcome.
  • Phase 3 - Frontend authoritative states (10-14h). Deliverable: TanStack Query state derived from the backend escrow_intent row, disabled controls on non-terminal intents, removal of client-side settlement inference. Exit check: frontend build and component tests pass.
  • Phase 4 - Root, CI, integration, and e2e (12-16h). Deliverable: CI gates wired, cross-layer enum bindings regenerated, end-to-end retry scenario, state dumps. Exit check: full e2e green, reconciler convergence log and state dumps attached to PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignbugSomething isn't workingcriticalHigh-impact, hard issue (security, correctness or architecture) - prioritizehelp wantedExtra attention is needed

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions