Skip to content

Payments 5/7: On-Chain Escrow Rail — Soroban Integration & Chain-State Reconciliation - #1581

Merged
yusuftomilola merged 2 commits into
DistinctCodes:mainfrom
Leothosine:feature/1574-soroban-escrow-rail
Aug 22, 2026
Merged

Payments 5/7: On-Chain Escrow Rail — Soroban Integration & Chain-State Reconciliation#1581
yusuftomilola merged 2 commits into
DistinctCodes:mainfrom
Leothosine:feature/1574-soroban-escrow-rail

Conversation

@Leothosine

Copy link
Copy Markdown
Contributor

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 the escrow_id deterministically from Payment#id (sha256 of the UUID — always recomputable, no separate mapping table to drift) and enqueues submission off the request thread, returning immediately with an AWAITING_CONFIRMATION Payment.
  • EscrowSubmissionProcessor runs the real build → simulate → sign → submit → bounded-poll pipeline via Bull (@nestjs/bull, already an installed-but-unused dependency), signing the payer's leg through WalletsService.signPayload (Payments 4/7: Custodial Wallet Onboarding & Non-Custodial Upgrade Path #1573's KeyCustodyService — 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 one concurrency: 1 handler, so every submission is strictly serialized regardless of signing account — stronger than "per signing account," trading throughput for correctness against sequence-number races.
  • Hard rule: a Payment is only ever marked CONFIRMED after a fresh contract-state read (EscrowContractClient.getEscrowStatus), never off a submission's SUCCESS response, which only proves the call didn't revert. An indeterminate submission error (RPC timeout, connection down) is never treated as a failure — it's left AWAITING_CONFIRMATION and 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/ReconciliationService now dispatch by Payment#rail instead of hard-depending on the concrete SandboxRailAdapter (Payments 1/7: Payment Domain Model, Initiation Flow & Idempotent Transaction Lifecycle #1570 only ever needed one adapter). FIAT always resolves to sandbox; the Stellar rails resolve to Soroban only when actually configured — otherwise a clear error instead of a payment silently going nowhere. PaymentWebhookController is deliberately left alone: Soroban has no webhook channel at all.
  • RPC resilience: SorobanRpcClient tries multiple configured endpoints (STELLAR_RPC_URLS) in order with retry/backoff (reusing Payments 3/7: Failure Taxonomy, Retries, Partial Outcomes & Payment Reconciliation Engine #1572's retryWithBackoff) before failing over.
  • Config: SOROBAN_ENABLED defaults to false; every Soroban provider resolves to null when disabled (see PaymentsModule), so existing deployments are byte-for-byte unaffected until an operator deploys a contract and opts in. When enabled, missing STELLAR_* config throws naming exactly what's missing (matching the promise already documented in .env.example before this PR).

See backend/src/payments/soroban/README.md for the full design writeup.

Honest scope notes (called out explicitly, not silently skipped)

  • No live testnet deployment. Deploying a contract is an operational action (funding an account, running the Stellar CLI) outside what an automated PR can responsibly do. SOROBAN_ENABLED=false by default means nothing here activates until an operator does that and configures the contract ID.
  • escrow-contract.client.ts is hand-written, not generated. stellar contract bindings typescript needs 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.
  • The acceptance criteria calling for a live testnet demo script and a real Bull+Redis concurrency integration test are natural follow-ups once a contract is deployed; this PR covers everything unit-testable ahead of that (idempotent submission, indeterminate-vs-definite error handling, the fresh-read confirmation rule, and the failure taxonomy).

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_LATER retry-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.tsinitiate never touches the chain and is idempotent under a duplicate call, verifyByReference's full status mapping, refund/release job 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.
  • Updated payments.service.spec.ts, refunds.service.spec.ts, payment-confirmation.service.spec.ts, reconciliation.service.spec.ts, wallets.service.spec.ts for the registry-based constructor change and the new WalletsService.signPayload.
  • CI (lint / build / unit tests, backend + frontend) — pending on this PR.

Closes #1574

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.
@vercel

vercel Bot commented Aug 22, 2026

Copy link
Copy Markdown

@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 yusuftomilola left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_id deterministically from Payment#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 CONFIRMED is only ever set after a fresh getEscrowStatus read, never off a submission's SUCCESS response, 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 stays AWAITING_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: 1 Bull 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's KeyCustodyService, 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.
  • PaymentRailRegistry dispatching by Payment#rail instead of hard-depending on SandboxRailAdapter, 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 to null when 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.ts being 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.

@yusuftomilola
yusuftomilola merged commit 3feec6f into DistinctCodes:main Aug 22, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Payments 5/7: On-Chain Escrow Rail — Soroban Integration & Chain-State Reconciliation

2 participants