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):
- Tenant requests refund.
backend/src/modules/payments builds a refund invocation and enqueues a stellar-submit job on the Bull transactions queue.
- 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.
- 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.
- 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.
- 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
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.
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.tsSeverity: Critical (fund-safety; irreversible on-chain custody movement)
Estimated Window: 72-96 hours
Technical Context & Monorepo Integration Failure
The
escrowcrate 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:escrowpersistent storage equals the token balance theescrowcontract holds on-chain.require_authfrom the party entitled to trigger it under the current escrow phase.dispute_resolutionstatus check, apaymentcrate 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):
backend/src/modules/paymentsbuilds arefundinvocation and enqueues astellar-submitjob on the Bulltransactionsqueue.escrowledger entry moves to refunded on-chain, but the Horizon response times out before the worker records success.refundenvelope is submitted. Without a terminal-state guard in theescrowcrate, the second invocation is evaluated against storage the contract believes still active.releaserequest for the same escrow id is enqueued from a different module path, because no backend row marks the escrow as intent-locked.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
escrowcrate: add anEscrowStatusenum (Funded,Released,Refunded,Disputed) in persistent storage per escrow id, withextend_ttlon every access.releaseandrefundmust load status and return a typedEscrowError(AlreadyReleased,AlreadyRefunded,NotFunded,Unauthorized,CustodyMismatch) if the current status is notFunded.releaseandrefundare guarded byrequire_authfor the entitled party; each transitionsFundedto a terminal status in a single atomic invocation and emitsescrow_releasedorescrow_refundedviaenv.events().publish. A downstreamdispute_resolution::is_openorpayment::transfercall that panics or returns error aborts the whole invocation, leaving status and custody unchanged (Soroban rolls back on host error). Add anassertthat post-transition tracked total equals on-chain token balance before commit.paymentanddispute_resolutioncrates: expose read functions theescrowinvocation can call in the same transaction so the downstream leg is atomic, not a separate submission.Backend/API Layer
transactionsmodule: new TypeORM entityescrow_intent(columnsescrow_id,transitionenum,idempotency_keyunique,statuspending/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.queuesmodule: thestellar-submitBull job is keyed byidempotency_key; retries reuse the same key and never build a second envelope. Add anescrow-reconcilerepeatable job that reads on-chainEscrowStatusthrough thestellarmodule and closes, fails, or compensates each intent against observed state.stellarmodule:getEscrowStatus(escrowId)reads contract storage as authoritative truth.paymentsanddisputesmodules call the intent ledger; neither submits an escrow transition directly.escrow_intentrow; the reconciler flipsstatustosettledonly after the on-chain terminal status matchesexpected_status; a divergence raises amonitoringalert and marks the intentcompensated. New migration underbackend/src/migrations/createsescrow_intentand its indexes.Frontend Client
frontend/app/payments/: escrow display state derives only from the backendescrow_intentrow via TanStack Query. Submission progress reads fromescrow_intent.status(pending,submitted,settled,failed,compensated); terminal custody outcome reads fromescrow_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 aspendinguntil the backend reconciler confirms terminal status. Disable release and refund controls when theescrow_intentfor the escrow id is not in a terminal or failed state.Root Configuration & Orchestration
.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.shmust run the terminal-state and custody-equality tests undercargo test.EscrowStatusandtransitionenum bindings consumed by backend and frontend so the three layers agree on identical status strings.Verification & Acceptance Criteria
contract/check-all.shpasses: build, clippy, andcargo testgreen.backendcompiles;pnpm --filter backend buildandmigration:runapply theescrow_intentmigration andmigration:revertrolls it back.frontendtypechecks and builds against regenerated enum bindings.release, doublerefund, andreleasethenrefundon one escrow id each return the correctEscrowError; a forced downstream error leaves status and custody unchanged.idempotency_keysubmits one envelope; the reconciler settles only on matching on-chain terminal status; an injected Horizon timeout does not produce a second submission.observed_statusRefundedonly after reconciliation.escrowstorage and contract token balance;escrow_intenttable dump across the e2e run; reconciler log showing observed-vs-expected convergence.Suggested Execution Path
EscrowStatus, guardedrelease/refund, in-transaction downstream calls, custody-equality assert, events,EscrowErrorvariants. Exit check:contract/check-all.shgreen including new tests.escrow_intententity plus migration, idempotentstellar-submit,escrow-reconcilejob,getEscrowStatus. Exit check: integration tests pass; duplicate submission and timeout scenarios produce one terminal outcome.escrow_intentrow, disabled controls on non-terminal intents, removal of client-side settlement inference. Exit check: frontend build and component tests pass.