Payments 5/7: On-Chain Escrow Rail — Soroban Integration & Chain-State Reconciliation - #1581
Merged
yusuftomilola merged 2 commits intoAug 22, 2026
Conversation
Implements the on-chain escrow rail as a PaymentRailAdapter (issue DistinctCodes#1574), building on DistinctCodes#1570-DistinctCodes#1573: - SorobanRailAdapter: initiate() derives the escrow_id deterministically from Payment#id (sha256 of the UUID — always recomputable, no separate mapping table needed) and enqueues submission off the request thread, returning immediately with an AWAITING_CONFIRMATION Payment. - EscrowSubmissionProcessor runs the actual build -> simulate -> sign -> submit -> bounded-poll pipeline via Bull, signing the payer's leg through WalletsService.signPayload (DistinctCodes#1573's KeyCustodyService) and the treasury's leg with a plain operational key. All three job kinds (create/release/refund) share one job name and one concurrency:1 handler, so every submission is strictly serialized regardless of signing account — stronger than the issue's "per signing account" requirement, trading throughput for correctness against sequence-number races. - Hard rule: a Payment is only ever marked CONFIRMED after a fresh contract-state read, never off a submission's SUCCESS response. An indeterminate submission error (RPC timeout, connection down) is never treated as a failure — it's left AWAITING_CONFIRMATION for reconciliation to resolve by asking the chain directly. - soroban-error-mapping.ts: Soroban-specific failure taxonomy (SIMULATION_FAILED, INSUFFICIENT_FEE, SEQUENCE_CONFLICT, TRANSACTION_EXPIRED, CONTRACT_REVERTED) feeding DistinctCodes#1572's pattern, plus the "already released/refunded" contract-guard-as-success case for a retried release. - PaymentRailRegistry: PaymentsService/RefundsService/ PaymentConfirmationService/ReconciliationService now dispatch by Payment#rail instead of hard-depending on the concrete SandboxRailAdapter (DistinctCodes#1570 only ever needed one adapter). FIAT always resolves to sandbox; the Stellar rails resolve to Soroban only when actually configured. SOROBAN_ENABLED defaults to false and every Soroban provider resolves to null when disabled — existing deployments are unaffected until an operator deploys a contract and opts in. escrow-contract.client.ts is a hand-written stand-in for `stellar contract bindings typescript`-generated bindings (documented in its header and the module README) since there's no live contract to generate against yet; no live testnet demo is included for the same reason — both are called out explicitly rather than silently skipped.
|
@Leothosine is attempting to deploy a commit to the naijabuz's projects Team on Vercel. A member of the Team first needs to authorize it. |
The stellar-sdk Contract constructor validates its id as a real StrKey-encoded contract address — the placeholder 'C123' string failed that validation. Use StrKey.encodeContract() to generate a well-formed one instead of a string that merely looks like a contract id.
yusuftomilola
approved these changes
Aug 22, 2026
yusuftomilola
left a comment
Collaborator
There was a problem hiding this comment.
Reviewed the on-chain escrow rail PR (closes #1574, building on #1570-#1573). This is the most architecturally significant PR in the payment track so far, and it holds together well.
- Deriving
escrow_iddeterministically fromPayment#id(sha256 of the UUID) rather than maintaining a separate mapping table means there's no secondary source of truth that can drift out of sync with the payment row — it's always recomputable, never stored-and-trusted. - The hard rule that
CONFIRMEDis only ever set after a freshgetEscrowStatusread, never off a submission'sSUCCESSresponse, is exactly right and consistent with the confirmation discipline established back in #1571/#1578 — a successful submit only proves the call didn't revert, not that it's actually settled. Extending "indeterminate submission error staysAWAITING_CONFIRMATION, let reconciliation ask the chain directly" to the Soroban rail rather than guessing keeps the same correctness guarantee that made the earlier reconciliation engine solid. - Serializing all three job kinds (create/release/refund) through one
concurrency: 1Bull handler is a deliberately conservative choice — trading throughput for correctness against sequence-number races — and it's the right tradeoff for a payment rail where a sequence conflict means real submission failures, not just a slow queue. - Splitting signing correctly by actor: payer's leg through
WalletsService.signPayload(routing through #1573'sKeyCustodyService, so no new code path touches a decrypted key), treasury's leg with a plain operational key since release/refund are platform actions. This mirrors the same signer-responsibility split already validated in the FrieghtFlow escrow bridge review. PaymentRailRegistrydispatching byPayment#railinstead of hard-depending onSandboxRailAdapter, with a clear error when a rail is unconfigured rather than a payment silently going nowhere, is exactly the right generalization now that a second rail actually exists.- The
SOROBAN_ENABLED=false-by-default gating, with every Soroban provider resolving tonullwhen disabled, means this PR is byte-for-byte inert for existing deployments until an operator deploys a contract and opts in — that's what makes it safe to merge despite no live testnet deployment existing yet. - The scope notes are honest rather than glossed over: no live testnet deployment (correctly identified as an operational action outside what an automated PR should do), and
escrow-contract.client.tsbeing hand-written against a documented reference ABI rather than generated bindings, with exactly one file identified as needing replacement once a real contract exists. That's a well-contained seam. - Test coverage is comprehensive for everything unit-testable ahead of a live contract: idempotent
initiate, indeterminate-vs-definite error classification, the fresh-read confirmation rule, RPC endpoint failover, the already-done contract-guard-as-success case for retried release, and full rail-registry dispatch.
CI is green across Backend, Frontend, and Frontend E2E. Vercel's FAILURE is the usual unauthorized deployment integration link, unrelated to the code.
Approving — well-architected, honestly-scoped, and safely inert until deliberately activated.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the Soroban on-chain escrow rail as a
PaymentRailAdapter(issue #1574), building on #1570 (rail abstraction), #1571 (confirmation pipeline), #1572 (retry/backoff + failure taxonomy + reconciliation pattern), and #1573 (wallet signing).SorobanRailAdapter:initiate()derives theescrow_iddeterministically fromPayment#id(sha256 of the UUID — always recomputable, no separate mapping table to drift) and enqueues submission off the request thread, returning immediately with anAWAITING_CONFIRMATIONPayment.EscrowSubmissionProcessorruns the real build → simulate → sign → submit → bounded-poll pipeline via Bull (@nestjs/bull, already an installed-but-unused dependency), signing the payer's leg throughWalletsService.signPayload(Payments 4/7: Custodial Wallet Onboarding & Non-Custodial Upgrade Path #1573'sKeyCustodyService— no other module ever touches a decrypted key) and the treasury's leg with a plain operational key (release/refund are platform actions, not per-user custodial ones). All three job kinds (create/release/refund) share one job name and oneconcurrency: 1handler, so every submission is strictly serialized regardless of signing account — stronger than "per signing account," trading throughput for correctness against sequence-number races.CONFIRMEDafter a fresh contract-state read (EscrowContractClient.getEscrowStatus), never off a submission'sSUCCESSresponse, which only proves the call didn't revert. An indeterminate submission error (RPC timeout, connection down) is never treated as a failure — it's leftAWAITING_CONFIRMATIONand resolved later by reconciliation asking the chain directly, independent of this attempt.soroban-error-mapping.ts: Soroban-specific failure taxonomy (SIMULATION_FAILED,INSUFFICIENT_FEE,SEQUENCE_CONFLICT,TRANSACTION_EXPIRED,CONTRACT_REVERTED) feeding Payments 3/7: Failure Taxonomy, Retries, Partial Outcomes & Payment Reconciliation Engine #1572's pattern, plus the "already released/refunded" contract-guard-as-success case for a retried release.PaymentRailRegistry:PaymentsService/RefundsService/PaymentConfirmationService/ReconciliationServicenow dispatch byPayment#railinstead of hard-depending on the concreteSandboxRailAdapter(Payments 1/7: Payment Domain Model, Initiation Flow & Idempotent Transaction Lifecycle #1570 only ever needed one adapter).FIATalways resolves to sandbox; the Stellar rails resolve to Soroban only when actually configured — otherwise a clear error instead of a payment silently going nowhere.PaymentWebhookControlleris deliberately left alone: Soroban has no webhook channel at all.SorobanRpcClienttries multiple configured endpoints (STELLAR_RPC_URLS) in order with retry/backoff (reusing Payments 3/7: Failure Taxonomy, Retries, Partial Outcomes & Payment Reconciliation Engine #1572'sretryWithBackoff) before failing over.SOROBAN_ENABLEDdefaults tofalse; every Soroban provider resolves tonullwhen disabled (seePaymentsModule), so existing deployments are byte-for-byte unaffected until an operator deploys a contract and opts in. When enabled, missingSTELLAR_*config throws naming exactly what's missing (matching the promise already documented in.env.examplebefore this PR).See
backend/src/payments/soroban/README.mdfor the full design writeup.Honest scope notes (called out explicitly, not silently skipped)
SOROBAN_ENABLED=falseby default means nothing here activates until an operator does that and configures the contract ID.escrow-contract.client.tsis hand-written, not generated.stellar contract bindings typescriptneeds a deployed contract to generate against. This file targets the reference ABI documented in its header (create/release/refund/get_status) and is the one file to swap for real generated bindings once a contract exists — nothing else in the module depends on the XDR encoding, only on these method signatures.Test plan
escrow-id.spec.ts— deterministic, 32-byte escrow id derivation.soroban-config.spec.ts— enabled/disabled gating, missing-var error naming, RPC URL parsing/fallback.soroban-rpc-client.spec.ts— endpoint failover,TRY_AGAIN_LATERretry-within-endpoint, all-endpoints-fail propagation.escrow-contract.client.spec.ts— submission error mapping, bounded poll (immediate/looping/timeout), simulation-error →NOT_FOUND.soroban-error-mapping.spec.ts— every failure reason, the already-done guard, and indeterminate-vs-definite classification.soroban-rail.adapter.spec.ts—initiatenever touches the chain and is idempotent under a duplicate call,verifyByReference's full status mapping,refund/releasejob enqueuing, webhook methods are inert.escrow-submission.processor.spec.ts— replay-safe no-ops, the RPC-timeout-but-possibly-landed scenario (no failure, no duplicate submission), sequence-conflict → definite failure + reason persisted, already-succeeded guard resolves via fresh read, confirmation always requires the fresh read, release/refund never touch Payment state.payment-rail-registry.spec.ts— FIAT/Stellar dispatch, clear error when unconfigured.payments.service.spec.ts,refunds.service.spec.ts,payment-confirmation.service.spec.ts,reconciliation.service.spec.ts,wallets.service.spec.tsfor the registry-based constructor change and the newWalletsService.signPayload.Closes #1574