From 12e264114a9c2ffdd6b19d6b55066c0ad713d61b Mon Sep 17 00:00:00 2001 From: Leothosine Date: Sat, 22 Aug 2026 13:49:24 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(payments):=20Soroban=20escrow=20rail?= =?UTF-8?q?=20=E2=80=94=20create/release/refund/status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the on-chain escrow rail as a PaymentRailAdapter (issue #1574), building on #1570-#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 (#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 #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 (#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. --- backend/.env.example | 9 + backend/src/app.module.ts | 19 ++ .../1787401777618-AddSorobanFailureReasons.ts | 33 ++ .../enums/payment-failure-reason.enum.ts | 13 + .../payment-confirmation.service.spec.ts | 4 +- .../payments/payment-confirmation.service.ts | 8 +- .../payments/payment-rail-registry.spec.ts | 36 +++ backend/src/payments/payment-rail-registry.ts | 39 +++ backend/src/payments/payments.module.ts | 71 ++++- backend/src/payments/payments.service.spec.ts | 4 +- backend/src/payments/payments.service.ts | 6 +- .../payments/reconciliation.service.spec.ts | 4 +- .../src/payments/reconciliation.service.ts | 8 +- backend/src/payments/refunds.service.spec.ts | 6 +- backend/src/payments/refunds.service.ts | 9 +- backend/src/payments/soroban/README.md | 85 ++++++ .../soroban/escrow-contract.client.spec.ts | 141 +++++++++ .../soroban/escrow-contract.client.ts | 180 +++++++++++ .../src/payments/soroban/escrow-id.spec.ts | 31 ++ backend/src/payments/soroban/escrow-id.ts | 19 ++ .../payments/soroban/escrow-status.enum.ts | 11 + .../escrow-submission.processor.spec.ts | 281 ++++++++++++++++++ .../soroban/escrow-submission.processor.ts | 281 ++++++++++++++++++ .../payments/soroban/soroban-config.spec.ts | 66 ++++ .../src/payments/soroban/soroban-config.ts | 63 ++++ .../soroban/soroban-error-mapping.spec.ts | 73 +++++ .../payments/soroban/soroban-error-mapping.ts | 69 +++++ .../soroban/soroban-rail.adapter.spec.ts | 167 +++++++++++ .../payments/soroban/soroban-rail.adapter.ts | 133 +++++++++ .../soroban/soroban-rpc-client.spec.ts | 73 +++++ .../payments/soroban/soroban-rpc-client.ts | 88 ++++++ .../src/payments/soroban/soroban.tokens.ts | 14 + backend/src/wallets/wallets.service.spec.ts | 49 +++ backend/src/wallets/wallets.service.ts | 26 ++ 34 files changed, 2101 insertions(+), 18 deletions(-) create mode 100644 backend/src/database/migrations/1787401777618-AddSorobanFailureReasons.ts create mode 100644 backend/src/payments/payment-rail-registry.spec.ts create mode 100644 backend/src/payments/payment-rail-registry.ts create mode 100644 backend/src/payments/soroban/README.md create mode 100644 backend/src/payments/soroban/escrow-contract.client.spec.ts create mode 100644 backend/src/payments/soroban/escrow-contract.client.ts create mode 100644 backend/src/payments/soroban/escrow-id.spec.ts create mode 100644 backend/src/payments/soroban/escrow-id.ts create mode 100644 backend/src/payments/soroban/escrow-status.enum.ts create mode 100644 backend/src/payments/soroban/escrow-submission.processor.spec.ts create mode 100644 backend/src/payments/soroban/escrow-submission.processor.ts create mode 100644 backend/src/payments/soroban/soroban-config.spec.ts create mode 100644 backend/src/payments/soroban/soroban-config.ts create mode 100644 backend/src/payments/soroban/soroban-error-mapping.spec.ts create mode 100644 backend/src/payments/soroban/soroban-error-mapping.ts create mode 100644 backend/src/payments/soroban/soroban-rail.adapter.spec.ts create mode 100644 backend/src/payments/soroban/soroban-rail.adapter.ts create mode 100644 backend/src/payments/soroban/soroban-rpc-client.spec.ts create mode 100644 backend/src/payments/soroban/soroban-rpc-client.ts create mode 100644 backend/src/payments/soroban/soroban.tokens.ts diff --git a/backend/.env.example b/backend/.env.example index 724093b6..9af97fac 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -79,6 +79,15 @@ STELLAR_NETWORK=your_stellar_network_passphrase STELLAR_BENEFICIARY_ADDRESS=your_stellar_beneficiary_address # Optional — defaults to https://soroban-testnet.stellar.org STELLAR_HORIZON_URL=https://soroban-testnet.stellar.org +# Optional, comma-separated — RPC failover list for the escrow rail +# (issue #1574). Falls back to a single endpoint (STELLAR_HORIZON_URL) +# when unset; for real resilience, configure at least two. +STELLAR_RPC_URLS=https://soroban-testnet.stellar.org,https://rpc-futurenet.stellar.org +# How long the submission pipeline polls a submitted transaction for +# finality before giving up and leaving the Payment AWAITING_CONFIRMATION +# for chain-state reconciliation to pick up later. +SOROBAN_POLL_TIMEOUT_MS=15000 +SOROBAN_POLL_INTERVAL_MS=2000 # Scheduled Jobs # Minutes a PENDING booking may wait for payment before it is auto-cancelled BOOKING_PAYMENT_TTL_MINUTES=120 diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 77e63199..d337aa47 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ScheduleModule } from '@nestjs/schedule'; +import { BullModule } from '@nestjs/bull'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; @@ -26,6 +27,24 @@ import { WalletsModule } from './wallets/wallets.module'; synchronize: false, }), }), + // Backs the Soroban escrow submission queue (issue #1574) — see + // PaymentsModule — on the same Redis instance .env.example already + // documents for Bull-backed background jobs. The queue is registered + // regardless of SOROBAN_ENABLED (ioredis retries quietly in the + // background if Redis isn't reachable rather than blocking app + // startup); no job is ever added to it unless the Soroban rail is + // actually enabled and something calls it. + BullModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + redis: { + host: config.get('REDIS_HOST', 'localhost'), + port: config.get('REDIS_PORT', 6379), + password: config.get('REDIS_PASSWORD') || undefined, + db: config.get('REDIS_DB', 0), + }, + }), + }), AuthModule, PaymentsModule, WalletsModule, diff --git a/backend/src/database/migrations/1787401777618-AddSorobanFailureReasons.ts b/backend/src/database/migrations/1787401777618-AddSorobanFailureReasons.ts new file mode 100644 index 00000000..af8e171d --- /dev/null +++ b/backend/src/database/migrations/1787401777618-AddSorobanFailureReasons.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSorobanFailureReasons1787401777618 + implements MigrationInterface +{ + name = 'AddSorobanFailureReasons1787401777618'; + + public async up(queryRunner: QueryRunner): Promise { + // Soroban-specific failure taxonomy (issue #1574) on top of #1572's enum. + await queryRunner.query(` + ALTER TYPE "payments_failure_reason_enum" ADD VALUE IF NOT EXISTS 'SIMULATION_FAILED' + `); + await queryRunner.query(` + ALTER TYPE "payments_failure_reason_enum" ADD VALUE IF NOT EXISTS 'INSUFFICIENT_FEE' + `); + await queryRunner.query(` + ALTER TYPE "payments_failure_reason_enum" ADD VALUE IF NOT EXISTS 'SEQUENCE_CONFLICT' + `); + await queryRunner.query(` + ALTER TYPE "payments_failure_reason_enum" ADD VALUE IF NOT EXISTS 'TRANSACTION_EXPIRED' + `); + await queryRunner.query(` + ALTER TYPE "payments_failure_reason_enum" ADD VALUE IF NOT EXISTS 'CONTRACT_REVERTED' + `); + } + + public async down(): Promise { + // Postgres has no DROP VALUE for enums — reverting these would require + // recreating payments_failure_reason_enum, which isn't safe to do + // automatically without knowing whether any row already uses them + // (same tradeoff already accepted in AddPaymentReconciliationFields). + } +} diff --git a/backend/src/payments/enums/payment-failure-reason.enum.ts b/backend/src/payments/enums/payment-failure-reason.enum.ts index bf0c4670..f49635eb 100644 --- a/backend/src/payments/enums/payment-failure-reason.enum.ts +++ b/backend/src/payments/enums/payment-failure-reason.enum.ts @@ -13,4 +13,17 @@ export enum PaymentFailureReason { PROVIDER_ERROR = 'PROVIDER_ERROR', /** The payment never progressed past INITIATED before expiring — the user never returned. */ ABANDONED = 'ABANDONED', + + // ── Soroban escrow rail (issue #1574) ───────────────────────────────── + + /** Transaction simulation failed against current contract/ledger state (e.g. a require_auth or balance check would revert). */ + SIMULATION_FAILED = 'SIMULATION_FAILED', + /** The network rejected the transaction for an underpriced fee. */ + INSUFFICIENT_FEE = 'INSUFFICIENT_FEE', + /** Two transactions raced for the same signing account's sequence number. */ + SEQUENCE_CONFLICT = 'SEQUENCE_CONFLICT', + /** The transaction's time-bounds elapsed before it was included in a ledger. */ + TRANSACTION_EXPIRED = 'TRANSACTION_EXPIRED', + /** The contract call itself reverted on-chain (e.g. insufficient custodial balance). */ + CONTRACT_REVERTED = 'CONTRACT_REVERTED', } diff --git a/backend/src/payments/payment-confirmation.service.spec.ts b/backend/src/payments/payment-confirmation.service.spec.ts index 98b18947..588107e9 100644 --- a/backend/src/payments/payment-confirmation.service.spec.ts +++ b/backend/src/payments/payment-confirmation.service.spec.ts @@ -49,6 +49,7 @@ describe('PaymentConfirmationService', () => { let paymentsService: { transitionStatus: jest.Mock }; let gateway: { emitPaymentUpdate: jest.Mock }; let railAdapter: { verifyByReference: jest.Mock }; + let railRegistry: { get: jest.Mock }; let config: { get: jest.Mock }; let service: PaymentConfirmationService; @@ -63,6 +64,7 @@ describe('PaymentConfirmationService', () => { }; gateway = { emitPaymentUpdate: jest.fn() }; railAdapter = { verifyByReference: jest.fn() }; + railRegistry = { get: jest.fn().mockReturnValue(railAdapter) }; config = { get: jest.fn((_key: string, fallback?: unknown) => fallback) }; service = new PaymentConfirmationService( @@ -70,7 +72,7 @@ describe('PaymentConfirmationService', () => { eventRepository as any, paymentsService as any, gateway as any, - railAdapter as any, + railRegistry as any, config as any, ); }); diff --git a/backend/src/payments/payment-confirmation.service.ts b/backend/src/payments/payment-confirmation.service.ts index b504ea32..88b95889 100644 --- a/backend/src/payments/payment-confirmation.service.ts +++ b/backend/src/payments/payment-confirmation.service.ts @@ -10,7 +10,7 @@ import { PaymentStatus } from './enums/payment-status.enum'; import { PaymentVerificationOutcome } from './interfaces/payment-rail-adapter.interface'; import { PaymentsService } from './payments.service'; import { PaymentsGateway } from './payments.gateway'; -import { SandboxRailAdapter } from './adapters/sandbox-rail.adapter'; +import { PaymentRailRegistry } from './payment-rail-registry'; import { withTimeout } from './utils/with-timeout'; const DEFAULT_VERIFY_TIMEOUT_MS = 3000; @@ -43,7 +43,7 @@ export class PaymentConfirmationService { private readonly eventRepository: Repository, private readonly paymentsService: PaymentsService, private readonly gateway: PaymentsGateway, - private readonly railAdapter: SandboxRailAdapter, + private readonly railRegistry: PaymentRailRegistry, private readonly config: ConfigService, ) {} @@ -170,7 +170,9 @@ export class PaymentConfirmationService { let result: { outcome: PaymentVerificationOutcome }; try { result = await withTimeout( - this.railAdapter.verifyByReference(payment.providerReference), + this.railRegistry + .get(payment.rail) + .verifyByReference(payment.providerReference), timeoutMs, ); } catch (error) { diff --git a/backend/src/payments/payment-rail-registry.spec.ts b/backend/src/payments/payment-rail-registry.spec.ts new file mode 100644 index 00000000..e96855fd --- /dev/null +++ b/backend/src/payments/payment-rail-registry.spec.ts @@ -0,0 +1,36 @@ +import { PaymentRailRegistry } from './payment-rail-registry'; +import { PaymentRail } from './enums/payment-rail.enum'; + +describe('PaymentRailRegistry', () => { + it('resolves FIAT to the sandbox adapter', () => { + const sandbox = {} as any; + const registry = new PaymentRailRegistry(sandbox, undefined); + + expect(registry.get(PaymentRail.FIAT)).toBe(sandbox); + }); + + it('resolves STELLAR_CUSTODIAL to the Soroban adapter when configured', () => { + const sandbox = {} as any; + const soroban = {} as any; + const registry = new PaymentRailRegistry(sandbox, soroban); + + expect(registry.get(PaymentRail.STELLAR_CUSTODIAL)).toBe(soroban); + }); + + it('resolves STELLAR_EXTERNAL to the Soroban adapter when configured', () => { + const sandbox = {} as any; + const soroban = {} as any; + const registry = new PaymentRailRegistry(sandbox, soroban); + + expect(registry.get(PaymentRail.STELLAR_EXTERNAL)).toBe(soroban); + }); + + it('throws a clear error for an on-chain rail when Soroban is not configured', () => { + const sandbox = {} as any; + const registry = new PaymentRailRegistry(sandbox, undefined); + + expect(() => registry.get(PaymentRail.STELLAR_CUSTODIAL)).toThrow( + /SOROBAN_ENABLED/, + ); + }); +}); diff --git a/backend/src/payments/payment-rail-registry.ts b/backend/src/payments/payment-rail-registry.ts new file mode 100644 index 00000000..6db7c9d8 --- /dev/null +++ b/backend/src/payments/payment-rail-registry.ts @@ -0,0 +1,39 @@ +import { Inject, Injectable, Optional } from '@nestjs/common'; +import { PaymentRail } from './enums/payment-rail.enum'; +import { PaymentRailAdapter } from './interfaces/payment-rail-adapter.interface'; +import { SandboxRailAdapter } from './adapters/sandbox-rail.adapter'; +import { SOROBAN_RAIL_ADAPTER } from './soroban/soroban.tokens'; + +/** + * Resolves the right PaymentRailAdapter for a Payment#rail (issue #1574 — + * #1570 only ever needed one adapter, so nothing dispatched by rail yet). + * FIAT always resolves to the sandbox adapter; the on-chain rails resolve + * to the Soroban adapter only when it's actually configured + * (SOROBAN_ENABLED=true) — otherwise callers get a clear error instead of + * a payment silently going nowhere. + */ +@Injectable() +export class PaymentRailRegistry { + constructor( + private readonly sandboxRailAdapter: SandboxRailAdapter, + @Optional() + @Inject(SOROBAN_RAIL_ADAPTER) + private readonly sorobanRailAdapter: PaymentRailAdapter | undefined, + ) {} + + get(rail: PaymentRail): PaymentRailAdapter { + switch (rail) { + case PaymentRail.FIAT: + return this.sandboxRailAdapter; + case PaymentRail.STELLAR_CUSTODIAL: + case PaymentRail.STELLAR_EXTERNAL: + if (!this.sorobanRailAdapter) { + throw new Error( + `Payment rail ${rail} requires the Soroban escrow rail, but ` + + 'it is not configured (SOROBAN_ENABLED is not true)', + ); + } + return this.sorobanRailAdapter; + } + } +} diff --git a/backend/src/payments/payments.module.ts b/backend/src/payments/payments.module.ts index e3dd1784..511310a6 100644 --- a/backend/src/payments/payments.module.ts +++ b/backend/src/payments/payments.module.ts @@ -1,5 +1,7 @@ import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BullModule } from '@nestjs/bull'; import { Payment } from './entities/payment.entity'; import { ConfirmationEvent } from './entities/confirmation-event.entity'; import { Refund } from './entities/refund.entity'; @@ -7,14 +9,34 @@ import { PaymentsService } from './payments.service'; import { PaymentConfirmationService } from './payment-confirmation.service'; import { ReconciliationService } from './reconciliation.service'; import { RefundsService } from './refunds.service'; +import { PaymentRailRegistry } from './payment-rail-registry'; import { PaymentsGateway } from './payments.gateway'; import { PaymentsController } from './payments.controller'; import { PaymentWebhookController } from './payment-webhook.controller'; import { PaymentsAdminController } from './payments-admin.controller'; import { SandboxRailAdapter } from './adapters/sandbox-rail.adapter'; +import { WalletsModule } from '../wallets/wallets.module'; +import { loadSorobanConfig } from './soroban/soroban-config'; +import { SorobanRailAdapter } from './soroban/soroban-rail.adapter'; +import { EscrowSubmissionProcessor } from './soroban/escrow-submission.processor'; +import { EscrowContractClient } from './soroban/escrow-contract.client'; +import { + SorobanRpcClient, + createSorobanRpcServer, +} from './soroban/soroban-rpc-client'; +import { + ESCROW_CONTRACT_CLIENT, + SOROBAN_CONFIG, + SOROBAN_ESCROW_QUEUE, + SOROBAN_RAIL_ADAPTER, +} from './soroban/soroban.tokens'; @Module({ - imports: [TypeOrmModule.forFeature([Payment, ConfirmationEvent, Refund])], + imports: [ + TypeOrmModule.forFeature([Payment, ConfirmationEvent, Refund]), + WalletsModule, + BullModule.registerQueue({ name: SOROBAN_ESCROW_QUEUE }), + ], controllers: [ PaymentsController, PaymentWebhookController, @@ -25,8 +47,55 @@ import { SandboxRailAdapter } from './adapters/sandbox-rail.adapter'; PaymentConfirmationService, ReconciliationService, RefundsService, + PaymentRailRegistry, PaymentsGateway, SandboxRailAdapter, + // The Soroban escrow rail (issue #1574): every provider below + // resolves to null unless SOROBAN_ENABLED=true and every required + // STELLAR_* variable is set (see soroban-config.ts) — including + // SOROBAN_RAIL_ADAPTER itself, which is what PaymentRailRegistry + // checks. A disabled rail never makes an RPC call or touches a + // wallet; it's simply unavailable, with a clear error if selected. + { + provide: SOROBAN_CONFIG, + inject: [ConfigService], + useFactory: (config: ConfigService) => loadSorobanConfig(config), + }, + { + provide: SorobanRpcClient, + inject: [SOROBAN_CONFIG], + useFactory: (sorobanConfig: ReturnType) => + sorobanConfig + ? new SorobanRpcClient( + sorobanConfig.rpcUrls.map(createSorobanRpcServer), + ) + : null, + }, + { + provide: ESCROW_CONTRACT_CLIENT, + inject: [SOROBAN_CONFIG, SorobanRpcClient], + useFactory: ( + sorobanConfig: ReturnType, + rpcClient: SorobanRpcClient | null, + ) => + sorobanConfig && rpcClient + ? new EscrowContractClient( + rpcClient, + sorobanConfig.contractId, + sorobanConfig.networkPassphrase, + ) + : null, + }, + { + provide: SOROBAN_RAIL_ADAPTER, + inject: [SOROBAN_CONFIG, SorobanRailAdapter], + useFactory: ( + sorobanConfig: ReturnType, + adapter: SorobanRailAdapter, + ) => (sorobanConfig ? adapter : null), + }, + SorobanRailAdapter, + EscrowSubmissionProcessor, ], exports: [PaymentsService, PaymentConfirmationService, ReconciliationService], }) diff --git a/backend/src/payments/payments.service.spec.ts b/backend/src/payments/payments.service.spec.ts index cd7824a2..9666dc6a 100644 --- a/backend/src/payments/payments.service.spec.ts +++ b/backend/src/payments/payments.service.spec.ts @@ -55,6 +55,7 @@ function uniqueViolation(constraint: string) { describe('PaymentsService', () => { let repository: MockRepository; let railAdapter: { initiate: jest.Mock }; + let railRegistry: { get: jest.Mock }; let config: { get: jest.Mock }; let service: PaymentsService; @@ -63,10 +64,11 @@ describe('PaymentsService', () => { railAdapter = { initiate: jest.fn().mockResolvedValue({ providerReference: 'ref-1' }), }; + railRegistry = { get: jest.fn().mockReturnValue(railAdapter) }; config = { get: jest.fn().mockReturnValue(30) }; service = new PaymentsService( repository as any, - railAdapter as any, + railRegistry as any, config as any, ); }); diff --git a/backend/src/payments/payments.service.ts b/backend/src/payments/payments.service.ts index 6734741b..f83dcb46 100644 --- a/backend/src/payments/payments.service.ts +++ b/backend/src/payments/payments.service.ts @@ -17,7 +17,7 @@ import { PaymentStatus, } from './enums/payment-status.enum'; import { assertValidTransition } from './payment-state-machine'; -import { SandboxRailAdapter } from './adapters/sandbox-rail.adapter'; +import { PaymentRailRegistry } from './payment-rail-registry'; const USER_IDEMPOTENCY_KEY_CONSTRAINT = 'uq_payments_user_id_idempotency_key'; const BOOKING_NON_TERMINAL_CONSTRAINT = 'uq_payments_booking_id_non_terminal'; @@ -33,7 +33,7 @@ export class PaymentsService { constructor( @InjectRepository(Payment) private readonly paymentRepository: Repository, - private readonly railAdapter: SandboxRailAdapter, + private readonly railRegistry: PaymentRailRegistry, private readonly config: ConfigService, ) {} @@ -159,7 +159,7 @@ export class PaymentsService { private async progressToAwaitingConfirmation( payment: Payment, ): Promise { - const result = await this.railAdapter.initiate(payment); + const result = await this.railRegistry.get(payment.rail).initiate(payment); payment.providerReference = result.providerReference; this.transitionStatus(payment, PaymentStatus.AWAITING_CONFIRMATION); return this.paymentRepository.save(payment); diff --git a/backend/src/payments/reconciliation.service.spec.ts b/backend/src/payments/reconciliation.service.spec.ts index a378d06b..63912bbd 100644 --- a/backend/src/payments/reconciliation.service.spec.ts +++ b/backend/src/payments/reconciliation.service.spec.ts @@ -130,11 +130,13 @@ function makeConfigService(overrides: Record = {}) { describe('ReconciliationService', () => { let railAdapter: { verifyByReference: jest.Mock }; + let railRegistry: { get: jest.Mock }; let confirmationService: { apply: jest.Mock }; let gateway: { emitPaymentUpdate: jest.Mock }; beforeEach(() => { railAdapter = { verifyByReference: jest.fn() }; + railRegistry = { get: jest.fn().mockReturnValue(railAdapter) }; confirmationService = { apply: jest.fn() }; gateway = { emitPaymentUpdate: jest.fn() }; }); @@ -148,7 +150,7 @@ describe('ReconciliationService', () => { const service = new ReconciliationService( paymentRepository as any, confirmationService as any, - railAdapter as any, + railRegistry as any, gateway as any, config as any, ); diff --git a/backend/src/payments/reconciliation.service.ts b/backend/src/payments/reconciliation.service.ts index 563de6d8..73b267d5 100644 --- a/backend/src/payments/reconciliation.service.ts +++ b/backend/src/payments/reconciliation.service.ts @@ -16,7 +16,7 @@ import { ConfirmationSource } from './enums/confirmation-source.enum'; import { assertValidTransition } from './payment-state-machine'; import { PaymentConfirmationService } from './payment-confirmation.service'; import { PaymentsGateway } from './payments.gateway'; -import { SandboxRailAdapter } from './adapters/sandbox-rail.adapter'; +import { PaymentRailRegistry } from './payment-rail-registry'; import { withTimeout } from './utils/with-timeout'; export interface ReconciliationSummary { @@ -51,7 +51,7 @@ export class ReconciliationService { @InjectRepository(Payment) private readonly paymentRepository: Repository, private readonly confirmationService: PaymentConfirmationService, - private readonly railAdapter: SandboxRailAdapter, + private readonly railRegistry: PaymentRailRegistry, private readonly gateway: PaymentsGateway, private readonly config: ConfigService, ) {} @@ -242,7 +242,9 @@ export class ReconciliationService { let outcome: 'confirmed' | 'failed' | 'pending'; try { const result = await withTimeout( - this.railAdapter.verifyByReference(payment.providerReference), + this.railRegistry + .get(payment.rail) + .verifyByReference(payment.providerReference), timeoutMs, ); outcome = result.outcome; diff --git a/backend/src/payments/refunds.service.spec.ts b/backend/src/payments/refunds.service.spec.ts index c05497be..085c2681 100644 --- a/backend/src/payments/refunds.service.spec.ts +++ b/backend/src/payments/refunds.service.spec.ts @@ -109,10 +109,12 @@ function makeHarness(initialPayment: Payment) { describe('RefundsService', () => { let railAdapter: { refund: jest.Mock }; + let railRegistry: { get: jest.Mock }; let gateway: { emitPaymentUpdate: jest.Mock }; beforeEach(() => { railAdapter = { refund: jest.fn().mockResolvedValue(undefined) }; + railRegistry = { get: jest.fn().mockReturnValue(railAdapter) }; gateway = { emitPaymentUpdate: jest.fn() }; }); @@ -121,7 +123,7 @@ describe('RefundsService', () => { const service = new RefundsService( harness.paymentRepository as any, harness.refundRepository as any, - railAdapter as any, + railRegistry as any, gateway as any, ); return { service, harness }; @@ -226,7 +228,7 @@ describe('RefundsService', () => { const service = new RefundsService( harness.paymentRepository as any, {} as any, - railAdapter as any, + railRegistry as any, gateway as any, ); diff --git a/backend/src/payments/refunds.service.ts b/backend/src/payments/refunds.service.ts index 29ba885b..f764bfde 100644 --- a/backend/src/payments/refunds.service.ts +++ b/backend/src/payments/refunds.service.ts @@ -12,7 +12,7 @@ import { Payment } from './entities/payment.entity'; import { Refund } from './entities/refund.entity'; import { PaymentStatus } from './enums/payment-status.enum'; import { assertValidTransition } from './payment-state-machine'; -import { SandboxRailAdapter } from './adapters/sandbox-rail.adapter'; +import { PaymentRailRegistry } from './payment-rail-registry'; import { PaymentsGateway } from './payments.gateway'; import { retryWithBackoff } from './utils/retry-with-backoff'; @@ -41,7 +41,7 @@ export class RefundsService { private readonly paymentRepository: Repository, @InjectRepository(Refund) private readonly refundRepository: Repository, - private readonly railAdapter: SandboxRailAdapter, + private readonly railRegistry: PaymentRailRegistry, private readonly gateway: PaymentsGateway, ) {} @@ -81,7 +81,10 @@ export class RefundsService { // need a saga/outbox, tracked separately). try { await retryWithBackoff( - () => this.railAdapter.refund(payment.providerReference ?? '', amount), + () => + this.railRegistry + .get(payment.rail) + .refund(payment.providerReference ?? '', amount), { maxAttempts: 3, baseDelayMs: 50, maxDelayMs: 500 }, ); } catch (error) { diff --git a/backend/src/payments/soroban/README.md b/backend/src/payments/soroban/README.md new file mode 100644 index 00000000..4bcb8466 --- /dev/null +++ b/backend/src/payments/soroban/README.md @@ -0,0 +1,85 @@ +# Soroban escrow rail + +On-chain escrow (create/release/refund/status) as a `PaymentRailAdapter` +(issue #1574), payment track item 5/7. Depends on #1570 (rail +abstraction), #1571 (confirmation pipeline), #1572 (retry/backoff, +failure taxonomy, reconciliation pattern), #1573 (wallet signing). + +## What's here vs. what isn't + +This ships the full submission pipeline, RPC resilience, error taxonomy, +and chain-state reconciliation wiring, all unit-tested. Two things this +PR deliberately does **not** do, both flagged here rather than silently +skipped: + +- **No live testnet deployment.** `SOROBAN_ENABLED` defaults to `false`; + nothing here activates until an operator deploys a contract and sets + `STELLAR_ESCROW_CONTRACT_ID` and friends (see `.env.example`). Doing an + actual testnet deployment is an operational action (funding an account, + running the Stellar CLI) outside what a PR's CI can respons­ibly do. +- **`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. Once a contract exists, regenerate real bindings from it and + swap them in — nothing else in this module depends on how a call is + XDR-encoded, only on the method signatures `EscrowContractClient` + exposes now. + +## Escrow-ID discipline + +`deriveEscrowId(paymentId)` is a pure function (sha256 of the UUID) — the +escrow_id is never a separate stored value, it's always recomputable from +`Payment#id`. That's a stronger link than a mapping table with its own +unique constraint could give: there's nothing to get out of sync. + +## The submission pipeline + +`SorobanRailAdapter.initiate()` never touches the chain on the request +thread — it derives the escrow_id and enqueues a `submit` job (Bull, on +the `soroban-escrow` queue), returning immediately so the Payment reaches +`AWAITING_CONFIRMATION` fast. `EscrowSubmissionProcessor` does the actual +build → simulate → sign → submit → bounded-poll work in the background, +via `KeyCustodyService.sign` (through `WalletsService.signPayload` — see +#1573) for the payer's custodial key, and a plain treasury key +(`STELLAR_SECRET_KEY`) for release/refund, which are platform-operated +actions rather than a per-user custodial one. + +All three job kinds (create/release/refund) route through one job name +and one `@Process({ concurrency: 1 })` handler, so every submission this +rail makes is strictly serialized regardless of which account signs it — +stronger than "per signing account," which is what the issue's +sequence-number-race edge case asks for. The cost is throughput, not +correctness; a real Bull+Redis integration test is the natural follow-up +to verify this at the queue level (this module's tests verify each +handler's own logic, not Bull's scheduling). + +## The hard rule + +A Payment is only ever moved to `CONFIRMED` after a *fresh* contract-state +read (`EscrowContractClient.getEscrowStatus`) — never off a submission's +`SUCCESS` response, which only proves the call didn't revert. See +`EscrowSubmissionProcessor.resolveFromFreshState`, the only path that +calls `PaymentConfirmationService.apply(..., 'confirmed', ...)`. + +The same fresh-read requirement is why an indeterminate submission error +(RPC timeout, connection down) is never treated as a failure: it isn't a +verdict at all. `soroban-error-mapping.ts` separates "definitely rejected +on-chain" (maps to a specific `PaymentFailureReason`) from "we don't know +yet" (`reason: null`, leaves the Payment `AWAITING_CONFIRMATION` for +`ReconciliationService` — now rail-dispatched via `PaymentRailRegistry`, +see below — to resolve independently, by asking the chain again, not by +trusting this attempt's own memory of what it sent). + +## PaymentRailRegistry + +`PaymentsService`, `RefundsService`, `PaymentConfirmationService`, and +`ReconciliationService` used to depend on the concrete `SandboxRailAdapter` +directly — #1570 only ever needed one adapter. `PaymentRailRegistry` +resolves the right `PaymentRailAdapter` by `Payment#rail`: `FIAT` always +goes to the sandbox adapter; the Stellar rails go to the Soroban adapter +only when it's actually configured, otherwise callers get a clear error +naming why instead of a payment silently going nowhere. + +`PaymentWebhookController` is deliberately **not** rail-dispatched — it's +bound to the fiat/sandbox rail's webhook format specifically. Soroban has +no webhook channel at all; see `SorobanRailAdapter`'s class doc. diff --git a/backend/src/payments/soroban/escrow-contract.client.spec.ts b/backend/src/payments/soroban/escrow-contract.client.spec.ts new file mode 100644 index 00000000..92cbaf39 --- /dev/null +++ b/backend/src/payments/soroban/escrow-contract.client.spec.ts @@ -0,0 +1,141 @@ +import { + EscrowContractClient, + EscrowSubmissionError, + attachSignature, +} from './escrow-contract.client'; +import { EscrowStatus } from './escrow-status.enum'; +import { Account, Keypair } from '@stellar/stellar-sdk'; + +/** + * These tests deliberately stay at the boundary that doesn't require + * knowing the exact shape of a *successful* SorobanRpc.Api simulation + * response (that's replicated from live SDK behavior, not something to + * hand-construct reliably in a unit test). The "does this correctly + * encode and submit a real transaction" half is exercised manually + * against Stellar testnet, not CI — see the module README. What's fully + * covered here: bounded polling, submission error mapping, and the + * error/not-found path of a status read, none of which depend on that + * shape. + */ +describe('EscrowContractClient', () => { + function makeRpc(overrides: Record = {}) { + return { + getAccount: jest.fn(), + simulateTransaction: jest.fn(), + sendTransaction: jest.fn(), + getTransaction: jest.fn(), + ...overrides, + }; + } + + describe('submit', () => { + it('returns the hash on a non-error status', async () => { + const rpc = makeRpc({ + sendTransaction: jest + .fn() + .mockResolvedValue({ status: 'PENDING', hash: 'hash-1' }), + }); + const client = new EscrowContractClient(rpc as any, 'C123', 'passphrase'); + + const result = await client.submit({} as any); + + expect(result).toEqual({ hash: 'hash-1' }); + }); + + it('throws EscrowSubmissionError on an ERROR status', async () => { + const rpc = makeRpc({ + sendTransaction: jest.fn().mockResolvedValue({ + status: 'ERROR', + errorResult: { code: 'txBAD_SEQ' }, + }), + }); + const client = new EscrowContractClient(rpc as any, 'C123', 'passphrase'); + + await expect(client.submit({} as any)).rejects.toThrow( + EscrowSubmissionError, + ); + }); + }); + + describe('pollFinality', () => { + it('returns immediately once the status is no longer NOT_FOUND', async () => { + const rpc = makeRpc({ + getTransaction: jest.fn().mockResolvedValue({ status: 'SUCCESS' }), + }); + const client = new EscrowContractClient(rpc as any, 'C123', 'passphrase'); + + const status = await client.pollFinality('hash-1', { + timeoutMs: 1000, + intervalMs: 10, + }); + + expect(status).toBe('SUCCESS'); + expect(rpc.getTransaction).toHaveBeenCalledTimes(1); + }); + + it('polls until success, respecting the interval', async () => { + const rpc = makeRpc({ + getTransaction: jest + .fn() + .mockResolvedValueOnce({ status: 'NOT_FOUND' }) + .mockResolvedValueOnce({ status: 'NOT_FOUND' }) + .mockResolvedValueOnce({ status: 'SUCCESS' }), + }); + const client = new EscrowContractClient(rpc as any, 'C123', 'passphrase'); + + const status = await client.pollFinality('hash-1', { + timeoutMs: 5000, + intervalMs: 5, + }); + + expect(status).toBe('SUCCESS'); + expect(rpc.getTransaction).toHaveBeenCalledTimes(3); + }); + + it('gives up and returns NOT_FOUND once the deadline passes, never throwing', async () => { + const rpc = makeRpc({ + getTransaction: jest.fn().mockResolvedValue({ status: 'NOT_FOUND' }), + }); + const client = new EscrowContractClient(rpc as any, 'C123', 'passphrase'); + + const status = await client.pollFinality('hash-1', { + timeoutMs: 20, + intervalMs: 10, + }); + + expect(status).toBe('NOT_FOUND'); + }); + }); + + describe('getEscrowStatus', () => { + it('maps a simulation error to NOT_FOUND rather than throwing', async () => { + const rpc = makeRpc({ + getAccount: jest + .fn() + .mockResolvedValue(new Account(Keypair.random().publicKey(), '1')), + simulateTransaction: jest + .fn() + .mockResolvedValue({ error: 'Error(Contract, #1)' }), + }); + const client = new EscrowContractClient(rpc as any, 'C123', 'passphrase'); + + const status = await client.getEscrowStatus( + Keypair.random().publicKey(), + Buffer.alloc(32, 1), + ); + + expect(status).toBe(EscrowStatus.NOT_FOUND); + }); + }); + + describe('attachSignature', () => { + it('appends a decorated signature carrying the signer public key hint', () => { + const signer = Keypair.random(); + const tx = { signatures: [] as unknown[] }; + + attachSignature(tx as any, signer.publicKey(), Buffer.alloc(64, 7)); + + expect(tx.signatures).toHaveLength(1); + }); + }); +}); diff --git a/backend/src/payments/soroban/escrow-contract.client.ts b/backend/src/payments/soroban/escrow-contract.client.ts new file mode 100644 index 00000000..27cb304d --- /dev/null +++ b/backend/src/payments/soroban/escrow-contract.client.ts @@ -0,0 +1,180 @@ +import { + Address, + BASE_FEE, + Contract, + Keypair, + SorobanRpc, + TransactionBuilder, + nativeToScVal, + scValToNative, + xdr, +} from '@stellar/stellar-sdk'; +import { SorobanRpcClient } from './soroban-rpc-client'; +import { EscrowStatus } from './escrow-status.enum'; + +/** + * Hand-written stand-in for what `stellar contract bindings typescript` + * would generate against the deployed escrow contract (issue #1574's + * "contract-first" requirement). It targets this reference ABI: + * + * fn create(escrow_id: BytesN<32>, payer: Address, beneficiary: Address, amount: i128) + * fn release(escrow_id: BytesN<32>) + * fn refund(escrow_id: BytesN<32>) + * fn get_status(escrow_id: BytesN<32>) -> u32 // 0=NotFound 1=Locked 2=Released 3=Refunded + * + * Once a contract is deployed and real bindings are generated from its + * actual ABI, this file is the one to replace — nothing else in the + * module depends on how a call is encoded, only on the method signatures + * below. + */ +export class EscrowContractClient { + constructor( + private readonly rpc: SorobanRpcClient, + private readonly contractId: string, + private readonly networkPassphrase: string, + ) {} + + async buildCreateTx( + sourceAccountPublicKey: string, + escrowId: Buffer, + payerAddress: string, + beneficiaryAddress: string, + amount: bigint, + ): Promise { + return this.buildAndAssemble(sourceAccountPublicKey, 'create', [ + nativeToScVal(escrowId, { type: 'bytes' }), + new Address(payerAddress).toScVal(), + new Address(beneficiaryAddress).toScVal(), + nativeToScVal(amount, { type: 'i128' }), + ]); + } + + async buildReleaseTx( + sourceAccountPublicKey: string, + escrowId: Buffer, + ): Promise { + return this.buildAndAssemble(sourceAccountPublicKey, 'release', [ + nativeToScVal(escrowId, { type: 'bytes' }), + ]); + } + + async buildRefundTx( + sourceAccountPublicKey: string, + escrowId: Buffer, + ): Promise { + return this.buildAndAssemble(sourceAccountPublicKey, 'refund', [ + nativeToScVal(escrowId, { type: 'bytes' }), + ]); + } + + async submit(signedTx: any): Promise<{ hash: string }> { + const result = await this.rpc.sendTransaction(signedTx); + if (result.status === 'ERROR') { + throw new EscrowSubmissionError( + result.errorResult ? JSON.stringify(result.errorResult) : 'unknown', + ); + } + return { hash: result.hash }; + } + + /** + * Bounded poll — this does NOT wait indefinitely. A still-pending result + * after the deadline is not a failure: the caller leaves the Payment + * AWAITING_CONFIRMATION and the chain-state reconciliation job asks the + * chain again later (issue #1574's "never assume, always ask" rule). + */ + async pollFinality( + hash: string, + { timeoutMs, intervalMs }: { timeoutMs: number; intervalMs: number }, + ): Promise<'SUCCESS' | 'FAILED' | 'NOT_FOUND'> { + const deadline = Date.now() + timeoutMs; + for (;;) { + const result = await this.rpc.getTransaction(hash); + if (result.status !== 'NOT_FOUND') { + return result.status; + } + if (Date.now() >= deadline) { + return 'NOT_FOUND'; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + } + + /** + * Fresh, direct read of contract state — the only thing allowed to + * justify marking a Payment CONFIRMED (issue #1574's hard rule). + * Read-only, so it's simulated but never submitted/signed. + */ + async getEscrowStatus( + sourceAccountPublicKey: string, + escrowId: Buffer, + ): Promise { + const tx = await this.build(sourceAccountPublicKey, 'get_status', [ + nativeToScVal(escrowId, { type: 'bytes' }), + ]); + const sim = await this.rpc.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(sim)) { + return EscrowStatus.NOT_FOUND; + } + // Different SDK minor versions have shipped this under either + // `result.retval` or `results[0].retval` — accept either shape. + const retval = sim.result?.retval ?? sim.results?.[0]?.retval; + const raw = Number(scValToNative(retval)); + return EscrowContractClient.mapRawStatus(raw); + } + + private static mapRawStatus(raw: number): EscrowStatus { + switch (raw) { + case 1: + return EscrowStatus.LOCKED; + case 2: + return EscrowStatus.RELEASED; + case 3: + return EscrowStatus.REFUNDED; + default: + return EscrowStatus.NOT_FOUND; + } + } + + private async build( + sourceAccountPublicKey: string, + method: string, + args: xdr.ScVal[], + ): Promise { + const account = await this.rpc.getAccount(sourceAccountPublicKey); + const contract = new Contract(this.contractId); + return new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(contract.call(method, ...args)) + .setTimeout(30) + .build(); + } + + private async buildAndAssemble( + sourceAccountPublicKey: string, + method: string, + args: xdr.ScVal[], + ): Promise { + const tx = await this.build(sourceAccountPublicKey, method, args); + const sim = await this.rpc.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(sim)) { + throw new SimulationFailedError(sim.error); + } + return SorobanRpc.assembleTransaction(tx, sim).build(); + } +} + +export class SimulationFailedError extends Error {} +export class EscrowSubmissionError extends Error {} + +/** Attaches a detached (remote-signed) signature without ever holding a secret. */ +export function attachSignature( + tx: any, + signerPublicKey: string, + signature: Buffer, +): void { + const hint = Keypair.fromPublicKey(signerPublicKey).signatureHint(); + tx.signatures.push(new xdr.DecoratedSignature({ hint, signature })); +} diff --git a/backend/src/payments/soroban/escrow-id.spec.ts b/backend/src/payments/soroban/escrow-id.spec.ts new file mode 100644 index 00000000..c6417dfa --- /dev/null +++ b/backend/src/payments/soroban/escrow-id.spec.ts @@ -0,0 +1,31 @@ +import { deriveEscrowId, escrowIdToHex } from './escrow-id'; + +describe('deriveEscrowId', () => { + it('is deterministic for the same payment id', () => { + const a = deriveEscrowId('11111111-1111-1111-1111-111111111111'); + const b = deriveEscrowId('11111111-1111-1111-1111-111111111111'); + + expect(a.equals(b)).toBe(true); + }); + + it('differs for different payment ids', () => { + const a = deriveEscrowId('11111111-1111-1111-1111-111111111111'); + const b = deriveEscrowId('22222222-2222-2222-2222-222222222222'); + + expect(a.equals(b)).toBe(false); + }); + + it("produces exactly 32 bytes, matching the contract's BytesN<32> id", () => { + expect( + deriveEscrowId('11111111-1111-1111-1111-111111111111'), + ).toHaveLength(32); + }); + + it('escrowIdToHex is a deterministic hex encoding of the same bytes', () => { + const paymentId = '11111111-1111-1111-1111-111111111111'; + expect(escrowIdToHex(paymentId)).toBe( + deriveEscrowId(paymentId).toString('hex'), + ); + expect(escrowIdToHex(paymentId)).toHaveLength(64); + }); +}); diff --git a/backend/src/payments/soroban/escrow-id.ts b/backend/src/payments/soroban/escrow-id.ts new file mode 100644 index 00000000..facfbbf8 --- /dev/null +++ b/backend/src/payments/soroban/escrow-id.ts @@ -0,0 +1,19 @@ +import { createHash } from 'crypto'; + +/** + * Derives the on-chain escrow_id deterministically from a Payment's UUID, + * so there is always exactly one queryable link between a Payment row and + * its on-chain record (issue #1574) — no separate mapping table, no extra + * unique constraint needed: Payment#id is already the primary key, and + * this is a pure function of it. + * + * The escrow contract's `create` takes a `BytesN<32>` id — sha256 gives us + * a fixed 32-byte value from an arbitrary-length input (a UUID string). + */ +export function deriveEscrowId(paymentId: string): Buffer { + return createHash('sha256').update(paymentId, 'utf8').digest(); +} + +export function escrowIdToHex(paymentId: string): string { + return deriveEscrowId(paymentId).toString('hex'); +} diff --git a/backend/src/payments/soroban/escrow-status.enum.ts b/backend/src/payments/soroban/escrow-status.enum.ts new file mode 100644 index 00000000..86c5d2d6 --- /dev/null +++ b/backend/src/payments/soroban/escrow-status.enum.ts @@ -0,0 +1,11 @@ +/** + * Mirrors the escrow contract's own on-chain status (see the reference + * ABI documented in escrow-contract.client.ts). This is read fresh from + * the chain, never inferred from a submission response. + */ +export enum EscrowStatus { + NOT_FOUND = 'NOT_FOUND', + LOCKED = 'LOCKED', + RELEASED = 'RELEASED', + REFUNDED = 'REFUNDED', +} diff --git a/backend/src/payments/soroban/escrow-submission.processor.spec.ts b/backend/src/payments/soroban/escrow-submission.processor.spec.ts new file mode 100644 index 00000000..e961ee3c --- /dev/null +++ b/backend/src/payments/soroban/escrow-submission.processor.spec.ts @@ -0,0 +1,281 @@ +import { Keypair } from '@stellar/stellar-sdk'; +import { EscrowSubmissionProcessor } from './escrow-submission.processor'; +import { EscrowStatus } from './escrow-status.enum'; +import { deriveEscrowId } from './escrow-id'; +import { Payment } from '../entities/payment.entity'; +import { PaymentRail } from '../enums/payment-rail.enum'; +import { PaymentStatus } from '../enums/payment-status.enum'; +import { PaymentFailureReason } from '../enums/payment-failure-reason.enum'; + +const TREASURY = Keypair.random(); +const PAYER_WALLET = Keypair.random().publicKey(); + +function makePayment(overrides: Partial = {}): Payment { + return { + id: 'payment-1', + bookingId: 'booking-1', + userId: 'user-1', + amount: 1000, + currency: 'USD', + rail: PaymentRail.STELLAR_CUSTODIAL, + provider: null, + providerReference: deriveEscrowId('payment-1').toString('hex'), + status: PaymentStatus.AWAITING_CONFIRMATION, + idempotencyKey: 'key-1', + metadata: null, + expiresAt: null, + failureReason: null, + reconciliationAttempts: 0, + providerErrorStreak: 0, + lastReconciledAt: null, + manualReviewReason: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as Payment; +} + +function makeTx() { + return { + hash: jest.fn().mockReturnValue(Buffer.from('txhash')), + signatures: [] as unknown[], + sign: jest.fn(), + }; +} + +describe('EscrowSubmissionProcessor', () => { + let paymentRepository: { findOne: jest.Mock; update: jest.Mock }; + let contractClient: { + buildCreateTx: jest.Mock; + buildReleaseTx: jest.Mock; + buildRefundTx: jest.Mock; + submit: jest.Mock; + pollFinality: jest.Mock; + getEscrowStatus: jest.Mock; + }; + let walletsService: { getWalletStatus: jest.Mock; signPayload: jest.Mock }; + let confirmationService: { apply: jest.Mock }; + let sorobanConfig: { + beneficiaryAddress: string; + treasuryPublicKey: string; + treasurySecretKey: string; + }; + let config: { get: jest.Mock }; + let processor: EscrowSubmissionProcessor; + + beforeEach(() => { + paymentRepository = { findOne: jest.fn(), update: jest.fn() }; + contractClient = { + buildCreateTx: jest.fn().mockResolvedValue(makeTx()), + buildReleaseTx: jest.fn().mockResolvedValue(makeTx()), + buildRefundTx: jest.fn().mockResolvedValue(makeTx()), + submit: jest.fn().mockResolvedValue({ hash: 'tx-hash-1' }), + pollFinality: jest.fn().mockResolvedValue('SUCCESS'), + getEscrowStatus: jest.fn().mockResolvedValue(EscrowStatus.LOCKED), + }; + walletsService = { + getWalletStatus: jest + .fn() + .mockResolvedValue({ account: { address: PAYER_WALLET } }), + signPayload: jest.fn().mockResolvedValue(Buffer.alloc(64, 1)), + }; + confirmationService = { + apply: jest.fn().mockResolvedValue({ status: PaymentStatus.CONFIRMED }), + }; + sorobanConfig = { + beneficiaryAddress: Keypair.random().publicKey(), + treasuryPublicKey: TREASURY.publicKey(), + treasurySecretKey: TREASURY.secret(), + }; + config = { get: jest.fn((_key: string, fallback: number) => fallback) }; + + processor = new EscrowSubmissionProcessor( + paymentRepository as any, + contractClient as any, + sorobanConfig as any, + walletsService as any, + confirmationService as any, + config as any, + ); + }); + + function submitJob(data: any) { + return processor.handleSubmit({ data } as any); + } + + describe('create', () => { + it('does nothing for an unknown payment', async () => { + paymentRepository.findOne.mockResolvedValue(null); + + await submitJob({ kind: 'create', paymentId: 'payment-1' }); + + expect(contractClient.buildCreateTx).not.toHaveBeenCalled(); + }); + + it('is a replay-safe no-op once the payment is already resolved', async () => { + paymentRepository.findOne.mockResolvedValue( + makePayment({ status: PaymentStatus.CONFIRMED }), + ); + + await submitJob({ kind: 'create', paymentId: 'payment-1' }); + + expect(contractClient.buildCreateTx).not.toHaveBeenCalled(); + }); + + it('does nothing when the user has no custodial wallet yet', async () => { + paymentRepository.findOne.mockResolvedValue(makePayment()); + walletsService.getWalletStatus.mockResolvedValue({ account: null }); + + await submitJob({ kind: 'create', paymentId: 'payment-1' }); + + expect(contractClient.buildCreateTx).not.toHaveBeenCalled(); + expect(confirmationService.apply).not.toHaveBeenCalled(); + }); + + it('confirms only after a fresh contract-state read shows LOCKED', async () => { + const payment = makePayment(); + paymentRepository.findOne.mockResolvedValue(payment); + + await submitJob({ kind: 'create', paymentId: 'payment-1' }); + + expect(contractClient.getEscrowStatus).toHaveBeenCalledWith( + TREASURY.publicKey(), + deriveEscrowId(payment.id), + ); + expect(confirmationService.apply).toHaveBeenCalledWith( + deriveEscrowId(payment.id).toString('hex'), + 'confirmed', + expect.anything(), + expect.any(String), + ); + }); + + it('never applies "confirmed" straight off a SUCCESS submission without the fresh read', async () => { + contractClient.getEscrowStatus.mockResolvedValue(EscrowStatus.NOT_FOUND); + paymentRepository.findOne.mockResolvedValue(makePayment()); + + await submitJob({ kind: 'create', paymentId: 'payment-1' }); + + expect(confirmationService.apply).not.toHaveBeenCalled(); + }); + + it('leaves the payment AWAITING_CONFIRMATION when the bounded poll times out — never fails, never resubmits', async () => { + contractClient.pollFinality.mockResolvedValue('NOT_FOUND'); + paymentRepository.findOne.mockResolvedValue(makePayment()); + + await submitJob({ kind: 'create', paymentId: 'payment-1' }); + + expect(confirmationService.apply).not.toHaveBeenCalled(); + expect(contractClient.submit).toHaveBeenCalledTimes(1); + }); + + it('an RPC timeout on submit is treated as indeterminate — no failure, no duplicate submission', async () => { + contractClient.submit.mockRejectedValue(new Error('Request timed out')); + paymentRepository.findOne.mockResolvedValue(makePayment()); + + await submitJob({ kind: 'create', paymentId: 'payment-1' }); + + expect(confirmationService.apply).not.toHaveBeenCalled(); + expect(contractClient.submit).toHaveBeenCalledTimes(1); + }); + + it('maps a sequence-number conflict to a definite failure with the right reason', async () => { + contractClient.submit.mockRejectedValue(new Error('txBAD_SEQ')); + confirmationService.apply.mockResolvedValue({ + status: PaymentStatus.FAILED, + }); + const payment = makePayment(); + paymentRepository.findOne.mockResolvedValue(payment); + + await submitJob({ kind: 'create', paymentId: 'payment-1' }); + + expect(confirmationService.apply).toHaveBeenCalledWith( + deriveEscrowId(payment.id).toString('hex'), + 'failed', + expect.anything(), + expect.any(String), + ); + expect(paymentRepository.update).toHaveBeenCalledWith(payment.id, { + failureReason: PaymentFailureReason.SEQUENCE_CONFLICT, + }); + }); + + it("treats a retried create against the contract's already-done guard as success via a fresh read", async () => { + contractClient.submit.mockRejectedValue(new Error('escrow already released')); + const payment = makePayment(); + paymentRepository.findOne.mockResolvedValue(payment); + + await submitJob({ kind: 'create', paymentId: 'payment-1' }); + + expect(contractClient.getEscrowStatus).toHaveBeenCalled(); + expect(confirmationService.apply).toHaveBeenCalledWith( + deriveEscrowId(payment.id).toString('hex'), + 'confirmed', + expect.anything(), + expect.any(String), + ); + }); + + it('signs the transaction hash via the wallet, never touching a raw secret', async () => { + const payment = makePayment(); + paymentRepository.findOne.mockResolvedValue(payment); + + await submitJob({ kind: 'create', paymentId: 'payment-1' }); + + expect(walletsService.signPayload).toHaveBeenCalledWith( + payment.userId, + Buffer.from('txhash'), + 'soroban-escrow-create', + ); + }); + }); + + describe('release', () => { + it('submits the release transaction and never touches Payment/confirmation state', async () => { + await submitJob({ + kind: 'release', + escrowIdHex: 'ab'.repeat(32), + paymentId: 'payment-1', + }); + + expect(contractClient.buildReleaseTx).toHaveBeenCalledWith( + TREASURY.publicKey(), + Buffer.from('ab'.repeat(32), 'hex'), + ); + expect(contractClient.submit).toHaveBeenCalledTimes(1); + expect(confirmationService.apply).not.toHaveBeenCalled(); + expect(paymentRepository.update).not.toHaveBeenCalled(); + }); + + it('treats an already-released guard as success, not an error', async () => { + contractClient.submit.mockRejectedValue(new Error('already released')); + + await expect( + submitJob({ + kind: 'release', + escrowIdHex: 'ab'.repeat(32), + paymentId: 'payment-1', + }), + ).resolves.toBeUndefined(); + }); + }); + + describe('refund', () => { + it('submits the refund transaction against the treasury account', async () => { + await submitJob({ kind: 'refund', escrowIdHex: 'cd'.repeat(32) }); + + expect(contractClient.buildRefundTx).toHaveBeenCalledWith( + TREASURY.publicKey(), + Buffer.from('cd'.repeat(32), 'hex'), + ); + }); + + it('logs rather than throws on a genuine on-chain failure', async () => { + contractClient.submit.mockRejectedValue(new Error('txBAD_SEQ')); + + await expect( + submitJob({ kind: 'refund', escrowIdHex: 'cd'.repeat(32) }), + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/backend/src/payments/soroban/escrow-submission.processor.ts b/backend/src/payments/soroban/escrow-submission.processor.ts new file mode 100644 index 00000000..286b6fb5 --- /dev/null +++ b/backend/src/payments/soroban/escrow-submission.processor.ts @@ -0,0 +1,281 @@ +import { Process, Processor } from '@nestjs/bull'; +import { Inject, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Job } from 'bull'; +import { Keypair } from '@stellar/stellar-sdk'; +import { Payment } from '../entities/payment.entity'; +import { PaymentStatus } from '../enums/payment-status.enum'; +import { PaymentFailureReason } from '../enums/payment-failure-reason.enum'; +import { ConfirmationSource } from '../enums/confirmation-source.enum'; +import { PaymentConfirmationService } from '../payment-confirmation.service'; +import { WalletsService } from '../../wallets/wallets.service'; +import { attachSignature, EscrowContractClient } from './escrow-contract.client'; +import { EscrowStatus } from './escrow-status.enum'; +import { deriveEscrowId } from './escrow-id'; +import { mapSorobanError } from './soroban-error-mapping'; +import { SorobanConfig } from './soroban-config'; +import { + ESCROW_CONTRACT_CLIENT, + SOROBAN_CONFIG, + SOROBAN_ESCROW_QUEUE, +} from './soroban.tokens'; + +type SubmitJobData = + | { kind: 'create'; paymentId: string } + | { kind: 'release'; escrowIdHex: string; paymentId: string } + | { kind: 'refund'; escrowIdHex: string }; + +/** + * Runs the actual on-chain submission pipeline off the request thread + * (issue #1574): build -> simulate -> sign -> submit -> bounded poll. + * + * `concurrency: 1` on the one `@Process()` handler below is deliberate: + * every submission this rail makes — payer-signed creates, treasury-signed + * releases/refunds — is strictly serialized, regardless of which account + * signs it. That's a stronger guarantee than "per signing account" (what + * the issue asks for); the cost is throughput, not correctness, and + * throughput isn't the bottleneck for an escrow rail. A real Bull+Redis + * integration test is the natural follow-up to verify this at the queue + * level — this module's own tests verify the logic each handler runs, not + * Bull's own scheduling. + */ +@Processor(SOROBAN_ESCROW_QUEUE) +export class EscrowSubmissionProcessor { + private readonly logger = new Logger(EscrowSubmissionProcessor.name); + + constructor( + @InjectRepository(Payment) + private readonly paymentRepository: Repository, + @Inject(ESCROW_CONTRACT_CLIENT) + private readonly contractClient: EscrowContractClient, + @Inject(SOROBAN_CONFIG) private readonly sorobanConfig: SorobanConfig, + private readonly walletsService: WalletsService, + private readonly confirmationService: PaymentConfirmationService, + private readonly config: ConfigService, + ) {} + + @Process({ name: 'submit', concurrency: 1 }) + async handleSubmit(job: Job): Promise { + switch (job.data.kind) { + case 'create': + return this.handleCreate(job.data.paymentId); + case 'release': + return this.handleRelease(job.data.escrowIdHex); + case 'refund': + return this.handleRefund(job.data.escrowIdHex); + } + } + + private async handleCreate(paymentId: string): Promise { + const payment = await this.paymentRepository.findOne({ + where: { id: paymentId }, + }); + if (!payment) { + this.logger.warn(`submit(create) job for unknown payment ${paymentId}`); + return; + } + if (payment.status !== PaymentStatus.AWAITING_CONFIRMATION) { + // Already resolved by a previous attempt or by reconciliation — + // replay-safe no-op, never a duplicate submission. + return; + } + + const escrowId = deriveEscrowId(payment.id); + const walletStatus = await this.walletsService.getWalletStatus( + payment.userId, + ); + if (!walletStatus.account) { + this.logger.error( + `Payment ${payment.id}: user ${payment.userId} has no custodial wallet to fund escrow from`, + ); + return; // leave AWAITING_CONFIRMATION — surfaces via manual-review escalation + } + const payerAddress = walletStatus.account.address; + + try { + const tx = await this.contractClient.buildCreateTx( + payerAddress, + escrowId, + payerAddress, + this.sorobanConfig.beneficiaryAddress, + BigInt(payment.amount), + ); + const signature = await this.walletsService.signPayload( + payment.userId, + tx.hash(), + 'soroban-escrow-create', + ); + attachSignature(tx, payerAddress, signature); + + const { hash } = await this.contractClient.submit(tx); + const finality = await this.contractClient.pollFinality( + hash, + this.pollOptions(), + ); + + if (finality === 'NOT_FOUND') { + this.logger.warn( + `Payment ${payment.id}: create tx ${hash} not final within the bounded poll — leaving AWAITING_CONFIRMATION for reconciliation`, + ); + return; + } + + await this.resolveFromFreshState(payment, escrowId, hash); + } catch (error) { + await this.handleSubmissionError(payment, escrowId, error); + } + } + + /** + * The only path allowed to move a Payment to CONFIRMED (issue #1574's + * hard rule): a submission's tx-level SUCCESS only proves the call + * didn't revert, not that the escrow itself is in the state we expect — + * so this always re-asks the contract directly before applying anything. + */ + private async resolveFromFreshState( + payment: Payment, + escrowId: Buffer, + hashForLog: string, + ): Promise { + const status = await this.contractClient.getEscrowStatus( + this.sorobanConfig.treasuryPublicKey, + escrowId, + ); + + if (status === EscrowStatus.LOCKED || status === EscrowStatus.RELEASED) { + await this.applyOutcome(payment, escrowId, 'confirmed'); + return; + } + if (status === EscrowStatus.REFUNDED) { + await this.applyOutcome( + payment, + escrowId, + 'failed', + PaymentFailureReason.CONTRACT_REVERTED, + ); + return; + } + + this.logger.warn( + `Payment ${payment.id}: tx ${hashForLog} landed but a fresh read shows escrow status ${status} — leaving AWAITING_CONFIRMATION`, + ); + } + + private async handleSubmissionError( + payment: Payment, + escrowId: Buffer, + error: unknown, + ): Promise { + const mapping = mapSorobanError(error); + + if (mapping.alreadySucceeded) { + // A retried create against the contract's own idempotent guard — + // resolve from fresh state rather than assuming anything. + await this.resolveFromFreshState(payment, escrowId, 'retry'); + return; + } + + if (mapping.reason === null) { + // Indeterminate (network/timeout/RPC down) — NOT a definite verdict + // that the transaction didn't land. Never fail the payment and + // never resubmit here; reconciliation asks the chain directly, + // independent of this attempt's own bounded poll. + this.logger.warn( + `Payment ${payment.id}: create submission indeterminate (` + + (error instanceof Error ? error.message : String(error)) + + ') — leaving AWAITING_CONFIRMATION', + ); + return; + } + + await this.applyOutcome(payment, escrowId, 'failed', mapping.reason); + } + + private async applyOutcome( + payment: Payment, + escrowId: Buffer, + outcome: 'confirmed' | 'failed', + failureReason?: PaymentFailureReason, + ): Promise { + const escrowIdHex = escrowId.toString('hex'); + const rawPayloadHash = PaymentConfirmationService.hashPayload( + Buffer.from(JSON.stringify({ escrowId: escrowIdHex, outcome })), + ); + const applied = await this.confirmationService.apply( + escrowIdHex, + outcome, + ConfirmationSource.RECONCILIATION, + rawPayloadHash, + ); + if (applied?.status === PaymentStatus.FAILED && failureReason) { + await this.paymentRepository.update(payment.id, { failureReason }); + } + } + + private async handleRelease(escrowIdHex: string): Promise { + await this.submitTreasuryAction(escrowIdHex, 'release'); + } + + private async handleRefund(escrowIdHex: string): Promise { + await this.submitTreasuryAction(escrowIdHex, 'refund'); + } + + /** + * Best-effort on-chain execution for release/refund — mirrors + * RefundsService's existing philosophy (issue #1572): these don't drive + * Payment#status here (release happens after a Payment is already + * CONFIRMED; refund's ledger-level truth is committed by RefundsService + * before this ever runs), so a failure is logged, not retried blindly. + */ + private async submitTreasuryAction( + escrowIdHex: string, + kind: 'release' | 'refund', + ): Promise { + const escrowId = Buffer.from(escrowIdHex, 'hex'); + const treasuryKeypair = Keypair.fromSecret( + this.sorobanConfig.treasurySecretKey, + ); + + try { + const tx = + kind === 'release' + ? await this.contractClient.buildReleaseTx( + treasuryKeypair.publicKey(), + escrowId, + ) + : await this.contractClient.buildRefundTx( + treasuryKeypair.publicKey(), + escrowId, + ); + tx.sign(treasuryKeypair); + + const { hash } = await this.contractClient.submit(tx); + const finality = await this.contractClient.pollFinality( + hash, + this.pollOptions(), + ); + this.logger.log(`Escrow ${escrowIdHex} ${kind} tx ${hash}: ${finality}`); + } catch (error) { + const mapping = mapSorobanError(error); + if (mapping.alreadySucceeded) { + this.logger.log( + `Escrow ${escrowIdHex} ${kind}: contract reports already done — treating as success`, + ); + return; + } + this.logger.error( + `Escrow ${escrowIdHex} ${kind} failed: ` + + (error instanceof Error ? error.message : String(error)), + ); + } + } + + private pollOptions(): { timeoutMs: number; intervalMs: number } { + return { + timeoutMs: this.config.get('SOROBAN_POLL_TIMEOUT_MS', 15000), + intervalMs: this.config.get('SOROBAN_POLL_INTERVAL_MS', 2000), + }; + } +} diff --git a/backend/src/payments/soroban/soroban-config.spec.ts b/backend/src/payments/soroban/soroban-config.spec.ts new file mode 100644 index 00000000..e20997e1 --- /dev/null +++ b/backend/src/payments/soroban/soroban-config.spec.ts @@ -0,0 +1,66 @@ +import { Keypair } from '@stellar/stellar-sdk'; +import { loadSorobanConfig } from './soroban-config'; + +const TREASURY = Keypair.random(); + +function makeConfig(values: Record) { + return { + get: jest.fn((key: string, fallback?: string) => values[key] ?? fallback), + }; +} + +describe('loadSorobanConfig', () => { + it('returns null without reading anything else when disabled', () => { + const config = makeConfig({ SOROBAN_ENABLED: 'false' }); + + expect(loadSorobanConfig(config as any)).toBeNull(); + }); + + it('returns null when SOROBAN_ENABLED is unset', () => { + const config = makeConfig({}); + + expect(loadSorobanConfig(config as any)).toBeNull(); + }); + + it('throws naming every missing required variable when enabled', () => { + const config = makeConfig({ SOROBAN_ENABLED: 'true' }); + + expect(() => loadSorobanConfig(config as any)).toThrow( + /STELLAR_ESCROW_CONTRACT_ID.*STELLAR_SECRET_KEY.*STELLAR_NETWORK.*STELLAR_BENEFICIARY_ADDRESS/, + ); + }); + + it('loads a full config with comma-separated RPC urls when enabled', () => { + const config = makeConfig({ + SOROBAN_ENABLED: 'true', + STELLAR_ESCROW_CONTRACT_ID: 'CCONTRACT', + STELLAR_SECRET_KEY: TREASURY.secret(), + STELLAR_NETWORK: 'Test SDF Network ; September 2015', + STELLAR_BENEFICIARY_ADDRESS: 'GBENEFICIARY', + STELLAR_RPC_URLS: ' https://rpc-a.example, https://rpc-b.example ', + }); + + expect(loadSorobanConfig(config as any)).toEqual({ + contractId: 'CCONTRACT', + networkPassphrase: 'Test SDF Network ; September 2015', + treasurySecretKey: TREASURY.secret(), + treasuryPublicKey: TREASURY.publicKey(), + beneficiaryAddress: 'GBENEFICIARY', + rpcUrls: ['https://rpc-a.example', 'https://rpc-b.example'], + }); + }); + + it('falls back to a single endpoint from STELLAR_HORIZON_URL when STELLAR_RPC_URLS is unset', () => { + const config = makeConfig({ + SOROBAN_ENABLED: 'true', + STELLAR_ESCROW_CONTRACT_ID: 'CCONTRACT', + STELLAR_SECRET_KEY: TREASURY.secret(), + STELLAR_NETWORK: 'Test SDF Network ; September 2015', + STELLAR_BENEFICIARY_ADDRESS: 'GBENEFICIARY', + }); + + expect(loadSorobanConfig(config as any)?.rpcUrls).toEqual([ + 'https://soroban-testnet.stellar.org', + ]); + }); +}); diff --git a/backend/src/payments/soroban/soroban-config.ts b/backend/src/payments/soroban/soroban-config.ts new file mode 100644 index 00000000..6dc0b344 --- /dev/null +++ b/backend/src/payments/soroban/soroban-config.ts @@ -0,0 +1,63 @@ +import { ConfigService } from '@nestjs/config'; +import { Keypair } from '@stellar/stellar-sdk'; + +export interface SorobanConfig { + contractId: string; + networkPassphrase: string; + treasurySecretKey: string; + /** Derived once at load time — the treasury's own signing key never leaves loadSorobanConfig. */ + treasuryPublicKey: string; + beneficiaryAddress: string; + rpcUrls: string[]; +} + +const REQUIRED_KEYS = [ + 'STELLAR_ESCROW_CONTRACT_ID', + 'STELLAR_SECRET_KEY', + 'STELLAR_NETWORK', + 'STELLAR_BENEFICIARY_ADDRESS', +] as const; + +/** + * When SOROBAN_ENABLED=true, every variable this rail needs must be + * present or the app refuses to start — naming exactly what's missing — + * matching the promise already documented in .env.example. Leaving + * SOROBAN_ENABLED unset/false skips this entirely: no Stellar config is + * required and the on-chain rail is simply unavailable. + */ +export function loadSorobanConfig(config: ConfigService): SorobanConfig | null { + const enabled = config.get('SOROBAN_ENABLED', 'false') === 'true'; + if (!enabled) { + return null; + } + + const missing = REQUIRED_KEYS.filter((key) => !config.get(key)); + if (missing.length > 0) { + throw new Error( + `SOROBAN_ENABLED=true but missing required config: ${missing.join(', ')}`, + ); + } + + const rpcUrls = (config.get('STELLAR_RPC_URLS') ?? '') + .split(',') + .map((url) => url.trim()) + .filter(Boolean); + if (rpcUrls.length === 0) { + const fallback = config.get( + 'STELLAR_HORIZON_URL', + 'https://soroban-testnet.stellar.org', + ); + rpcUrls.push(fallback); + } + + const treasurySecretKey = config.get('STELLAR_SECRET_KEY')!; + + return { + contractId: config.get('STELLAR_ESCROW_CONTRACT_ID')!, + networkPassphrase: config.get('STELLAR_NETWORK')!, + treasurySecretKey, + treasuryPublicKey: Keypair.fromSecret(treasurySecretKey).publicKey(), + beneficiaryAddress: config.get('STELLAR_BENEFICIARY_ADDRESS')!, + rpcUrls, + }; +} diff --git a/backend/src/payments/soroban/soroban-error-mapping.spec.ts b/backend/src/payments/soroban/soroban-error-mapping.spec.ts new file mode 100644 index 00000000..4c25f523 --- /dev/null +++ b/backend/src/payments/soroban/soroban-error-mapping.spec.ts @@ -0,0 +1,73 @@ +import { mapSorobanError } from './soroban-error-mapping'; +import { PaymentFailureReason } from '../enums/payment-failure-reason.enum'; +import { + EscrowSubmissionError, + SimulationFailedError, +} from './escrow-contract.client'; + +describe('mapSorobanError', () => { + it('treats an "already released" guard as success, not a failure', () => { + expect(mapSorobanError(new Error('escrow already released'))).toEqual({ + alreadySucceeded: true, + reason: null, + }); + }); + + it('treats an "already refunded" guard as success, not a failure', () => { + expect(mapSorobanError(new Error('Error: already_refunded'))).toEqual({ + alreadySucceeded: true, + reason: null, + }); + }); + + it('maps a simulation failure to SIMULATION_FAILED', () => { + expect(mapSorobanError(new SimulationFailedError('boom'))).toEqual({ + alreadySucceeded: false, + reason: PaymentFailureReason.SIMULATION_FAILED, + }); + }); + + it('maps a sequence-number conflict to SEQUENCE_CONFLICT', () => { + expect(mapSorobanError(new Error('txBAD_SEQ'))).toEqual({ + alreadySucceeded: false, + reason: PaymentFailureReason.SEQUENCE_CONFLICT, + }); + }); + + it('maps an underpriced fee to INSUFFICIENT_FEE', () => { + expect(mapSorobanError(new Error('txINSUFFICIENT_FEE'))).toEqual({ + alreadySucceeded: false, + reason: PaymentFailureReason.INSUFFICIENT_FEE, + }); + }); + + it('maps an expired transaction to TRANSACTION_EXPIRED', () => { + expect(mapSorobanError(new Error('txTOO_LATE: expired'))).toEqual({ + alreadySucceeded: false, + reason: PaymentFailureReason.TRANSACTION_EXPIRED, + }); + }); + + it('maps a generic on-chain submission rejection to CONTRACT_REVERTED', () => { + expect( + mapSorobanError(new EscrowSubmissionError('{"code":"txFAILED"}')), + ).toEqual({ + alreadySucceeded: false, + reason: PaymentFailureReason.CONTRACT_REVERTED, + }); + }); + + it('treats an unrecognized error as indeterminate, never a definite failure', () => { + expect(mapSorobanError(new Error('ECONNRESET'))).toEqual({ + alreadySucceeded: false, + reason: null, + }); + }); + + it('treats a plain timeout as indeterminate', () => { + expect(mapSorobanError(new Error('Request timed out'))).toEqual({ + alreadySucceeded: false, + reason: null, + }); + }); +}); diff --git a/backend/src/payments/soroban/soroban-error-mapping.ts b/backend/src/payments/soroban/soroban-error-mapping.ts new file mode 100644 index 00000000..c91c16d4 --- /dev/null +++ b/backend/src/payments/soroban/soroban-error-mapping.ts @@ -0,0 +1,69 @@ +import { PaymentFailureReason } from '../enums/payment-failure-reason.enum'; +import { + EscrowSubmissionError, + SimulationFailedError, +} from './escrow-contract.client'; + +export interface SorobanErrorMapping { + /** + * True when what looks like a failure is actually the contract's own + * "already done" guard (issue #1574's retried-release edge case) — + * must be treated as success, not an error. + */ + alreadySucceeded: boolean; + /** + * Null means "indeterminate" (network error, RPC unreachable, timeout) + * — NOT a definite on-chain rejection. The caller must never fail a + * Payment on a null reason; the chain must be asked again later. + */ + reason: PaymentFailureReason | null; +} + +/** + * Maps a Soroban submission/simulation error to the failure taxonomy + * (issue #1574, feeding #1572's pattern). Pattern-matches on the error + * message because we don't have a live contract's exact error codes to + * match against structurally — once one exists, replace the regexes below + * with the contract's real typed error enum. + */ +export function mapSorobanError(error: unknown): SorobanErrorMapping { + const message = error instanceof Error ? error.message : String(error); + + if (/already[_ ]?(released|refunded)/i.test(message)) { + return { alreadySucceeded: true, reason: null }; + } + if (error instanceof SimulationFailedError) { + return { + alreadySucceeded: false, + reason: PaymentFailureReason.SIMULATION_FAILED, + }; + } + if (/bad_seq|sequence/i.test(message)) { + return { + alreadySucceeded: false, + reason: PaymentFailureReason.SEQUENCE_CONFLICT, + }; + } + if (/insufficient.*fee|underpriced|txINSUFFICIENT_FEE/i.test(message)) { + return { + alreadySucceeded: false, + reason: PaymentFailureReason.INSUFFICIENT_FEE, + }; + } + if (/expired|too_late|txTOO_LATE/i.test(message)) { + return { + alreadySucceeded: false, + reason: PaymentFailureReason.TRANSACTION_EXPIRED, + }; + } + if (error instanceof EscrowSubmissionError || /contract/i.test(message)) { + return { + alreadySucceeded: false, + reason: PaymentFailureReason.CONTRACT_REVERTED, + }; + } + + // Unrecognized — most likely a network/timeout/RPC-unreachable error, + // not a definite on-chain verdict. Never map this to a failure reason. + return { alreadySucceeded: false, reason: null }; +} diff --git a/backend/src/payments/soroban/soroban-rail.adapter.spec.ts b/backend/src/payments/soroban/soroban-rail.adapter.spec.ts new file mode 100644 index 00000000..96988ecb --- /dev/null +++ b/backend/src/payments/soroban/soroban-rail.adapter.spec.ts @@ -0,0 +1,167 @@ +import { SorobanRailAdapter } from './soroban-rail.adapter'; +import { EscrowStatus } from './escrow-status.enum'; +import { deriveEscrowId } from './escrow-id'; +import { Payment } from '../entities/payment.entity'; +import { PaymentRail } from '../enums/payment-rail.enum'; +import { PaymentStatus } from '../enums/payment-status.enum'; + +function makePayment(overrides: Partial = {}): Payment { + return { + id: 'payment-1', + bookingId: 'booking-1', + userId: 'user-1', + amount: 1000, + currency: 'USD', + rail: PaymentRail.STELLAR_CUSTODIAL, + provider: null, + providerReference: null, + status: PaymentStatus.INITIATED, + idempotencyKey: 'key-1', + metadata: null, + expiresAt: null, + failureReason: null, + reconciliationAttempts: 0, + providerErrorStreak: 0, + lastReconciledAt: null, + manualReviewReason: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as Payment; +} + +describe('SorobanRailAdapter', () => { + let queue: { add: jest.Mock }; + let contractClient: { getEscrowStatus: jest.Mock }; + let sorobanConfig: { treasuryPublicKey: string }; + let adapter: SorobanRailAdapter; + + beforeEach(() => { + queue = { add: jest.fn().mockResolvedValue(undefined) }; + contractClient = { getEscrowStatus: jest.fn() }; + sorobanConfig = { treasuryPublicKey: 'GTREASURY' }; + adapter = new SorobanRailAdapter( + queue as any, + contractClient as any, + sorobanConfig as any, + ); + }); + + describe('initiate', () => { + it('never calls the chain — it derives the escrow id and enqueues submission', async () => { + const payment = makePayment(); + + const result = await adapter.initiate(payment); + + expect(contractClient.getEscrowStatus).not.toHaveBeenCalled(); + expect(result.providerReference).toBe( + deriveEscrowId(payment.id).toString('hex'), + ); + expect(queue.add).toHaveBeenCalledWith( + 'submit', + { kind: 'create', paymentId: payment.id }, + expect.objectContaining({ jobId: `create:${payment.id}` }), + ); + }); + + it('is idempotent under a duplicate initiate — same jobId both times', async () => { + const payment = makePayment(); + + await adapter.initiate(payment); + await adapter.initiate(payment); + + const jobIds = queue.add.mock.calls.map((call) => call[2].jobId); + expect(new Set(jobIds).size).toBe(1); + }); + }); + + describe('verifyByReference', () => { + it('maps LOCKED (funds secured in escrow) to confirmed', async () => { + contractClient.getEscrowStatus.mockResolvedValue(EscrowStatus.LOCKED); + const result = await adapter.verifyByReference('ab'.repeat(32)); + expect(result).toEqual({ outcome: 'confirmed' }); + }); + + it('maps RELEASED to confirmed too — a later, separate action from create confirmation', async () => { + contractClient.getEscrowStatus.mockResolvedValue(EscrowStatus.RELEASED); + const result = await adapter.verifyByReference('ab'.repeat(32)); + expect(result).toEqual({ outcome: 'confirmed' }); + }); + + it('maps REFUNDED to failed', async () => { + contractClient.getEscrowStatus.mockResolvedValue(EscrowStatus.REFUNDED); + const result = await adapter.verifyByReference('ab'.repeat(32)); + expect(result).toEqual({ outcome: 'failed' }); + }); + + it('maps NOT_FOUND to pending, never a false failure', async () => { + contractClient.getEscrowStatus.mockResolvedValue(EscrowStatus.NOT_FOUND); + const result = await adapter.verifyByReference('ab'.repeat(32)); + expect(result).toEqual({ outcome: 'pending' }); + }); + + it('always performs a fresh chain read against the treasury account', async () => { + contractClient.getEscrowStatus.mockResolvedValue(EscrowStatus.LOCKED); + const escrowIdHex = 'cd'.repeat(32); + + await adapter.verifyByReference(escrowIdHex); + + expect(contractClient.getEscrowStatus).toHaveBeenCalledWith( + 'GTREASURY', + Buffer.from(escrowIdHex, 'hex'), + ); + }); + }); + + describe('refund', () => { + it('enqueues a submit-refund job keyed by the escrow reference', async () => { + await adapter.refund('ab'.repeat(32), 500); + + expect(queue.add).toHaveBeenCalledWith( + 'submit', + { kind: 'refund', escrowIdHex: 'ab'.repeat(32) }, + expect.objectContaining({ jobId: `refund:${'ab'.repeat(32)}` }), + ); + }); + }); + + describe('release', () => { + it('enqueues a submit-release job for a payment with an escrow reference', async () => { + const payment = makePayment({ providerReference: 'ab'.repeat(32) }); + + await adapter.release(payment); + + expect(queue.add).toHaveBeenCalledWith( + 'submit', + { + kind: 'release', + escrowIdHex: 'ab'.repeat(32), + paymentId: payment.id, + }, + expect.objectContaining({ jobId: `release:${payment.id}` }), + ); + }); + + it('refuses to release a payment with no escrow reference yet', async () => { + const payment = makePayment({ providerReference: null }); + + await expect(adapter.release(payment)).rejects.toThrow(); + expect(queue.add).not.toHaveBeenCalled(); + }); + }); + + describe('webhook methods', () => { + it('verifyWebhookSignature always returns false — there is no webhook channel', () => { + expect( + adapter.verifyWebhookSignature({ + rawBody: Buffer.from(''), + signatureHeader: 'x', + }), + ).toBe(false); + }); + + it('parseWebhookPayload always throws', () => { + expect(() => adapter.parseWebhookPayload(Buffer.from(''))).toThrow(); + }); + }); +}); diff --git a/backend/src/payments/soroban/soroban-rail.adapter.ts b/backend/src/payments/soroban/soroban-rail.adapter.ts new file mode 100644 index 00000000..94f4ea3b --- /dev/null +++ b/backend/src/payments/soroban/soroban-rail.adapter.ts @@ -0,0 +1,133 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bull'; +import { Queue } from 'bull'; +import { Payment } from '../entities/payment.entity'; +import { + PaymentInitiationResult, + PaymentRailAdapter, + PaymentVerificationResult, + WebhookPayload, + WebhookSignatureInput, +} from '../interfaces/payment-rail-adapter.interface'; +import { deriveEscrowId } from './escrow-id'; +import { EscrowContractClient } from './escrow-contract.client'; +import { EscrowStatus } from './escrow-status.enum'; +import { SorobanConfig } from './soroban-config'; +import { + ESCROW_CONTRACT_CLIENT, + SOROBAN_CONFIG, + SOROBAN_ESCROW_QUEUE, +} from './soroban.tokens'; + +/** + * Soroban escrow rail (issue #1574): create/release/refund/status against + * the deployed escrow contract. `initiate` never talks to the chain on + * the request thread — it derives the escrow_id, enqueues the actual + * submission, and returns immediately so the caller gets a fast + * AWAITING_CONFIRMATION Payment (see EscrowSubmissionProcessor for the + * build -> simulate -> sign -> submit -> poll pipeline). + * + * Soroban has no webhook channel — finality is only ever discovered by + * asking the chain (the processor's own poll, or reconciliation). The two + * webhook methods below exist only to satisfy PaymentRailAdapter and are + * never meant to be called; PaymentWebhookController is wired to the + * fiat/sandbox rail's webhook endpoint, not this one. + */ +@Injectable() +export class SorobanRailAdapter implements PaymentRailAdapter { + constructor( + @InjectQueue(SOROBAN_ESCROW_QUEUE) private readonly queue: Queue, + @Inject(ESCROW_CONTRACT_CLIENT) + private readonly contractClient: EscrowContractClient, + @Inject(SOROBAN_CONFIG) private readonly sorobanConfig: SorobanConfig, + ) {} + + async initiate(payment: Payment): Promise { + const escrowId = deriveEscrowId(payment.id); + await this.queue.add( + 'submit', + { kind: 'create', paymentId: payment.id }, + { jobId: `create:${payment.id}`, attempts: 1 }, + ); + return { + providerReference: escrowId.toString('hex'), + metadata: { rail: 'soroban', escrowId: escrowId.toString('hex') }, + }; + } + + verifyWebhookSignature(_input: WebhookSignatureInput): boolean { + return false; + } + + parseWebhookPayload(_rawBody: Buffer): WebhookPayload { + throw new Error( + 'The Soroban rail has no webhook channel — confirmation only ever comes from a fresh chain-state read', + ); + } + + /** + * Always a fresh, direct read of contract state (issue #1574's hard + * rule) — never derived from a submission response. This is what both + * the verify-on-return fast path and scheduled reconciliation call. + * + * Escrow status -> Payment outcome mapping: LOCKED means the payer's + * funds are secured in escrow — that's this rail's "charge succeeded" + * moment, same as a captured charge on the fiat rail, so it maps to + * `confirmed` (RELEASED is a later, separate business action layered on + * an already-CONFIRMED payment — see `release()` — and still counts as + * confirmed if observed here). REFUNDED means the escrow was returned to + * the payer before ever confirming — that's a failed payment. NOT_FOUND + * covers "not on-chain yet" (submission still in flight) as much as + * "genuinely doesn't exist" — treated as pending, not failed, so we + * never race a false negative against our own async submission + * pipeline. + */ + async verifyByReference( + providerReference: string, + ): Promise { + const escrowId = Buffer.from(providerReference, 'hex'); + const status = await this.contractClient.getEscrowStatus( + this.sorobanConfig.treasuryPublicKey, + escrowId, + ); + switch (status) { + case EscrowStatus.LOCKED: + case EscrowStatus.RELEASED: + return { outcome: 'confirmed' }; + case EscrowStatus.REFUNDED: + return { outcome: 'failed' }; + case EscrowStatus.NOT_FOUND: + return { outcome: 'pending' }; + } + } + + async refund(providerReference: string, _amount: number): Promise { + await this.queue.add( + 'submit', + { kind: 'refund', escrowIdHex: providerReference }, + { jobId: `refund:${providerReference}`, attempts: 1 }, + ); + } + + /** + * Escrow-specific action beyond the shared PaymentRailAdapter interface + * — releases funds to the beneficiary. Not wired to any automatic + * trigger yet (there's no booking-completion event in this codebase to + * hang it off); exposed here for an admin action or a future booking + * module to call. + */ + async release(payment: Payment): Promise { + if (!payment.providerReference) { + throw new Error(`Payment ${payment.id} has no escrow reference yet`); + } + await this.queue.add( + 'submit', + { + kind: 'release', + escrowIdHex: payment.providerReference, + paymentId: payment.id, + }, + { jobId: `release:${payment.id}`, attempts: 1 }, + ); + } +} diff --git a/backend/src/payments/soroban/soroban-rpc-client.spec.ts b/backend/src/payments/soroban/soroban-rpc-client.spec.ts new file mode 100644 index 00000000..82f65b7b --- /dev/null +++ b/backend/src/payments/soroban/soroban-rpc-client.spec.ts @@ -0,0 +1,73 @@ +import { SorobanRpcClient, SorobanRpcServerLike } from './soroban-rpc-client'; + +function makeServer(overrides: Partial = {}): SorobanRpcServerLike { + return { + getAccount: jest.fn(), + simulateTransaction: jest.fn(), + sendTransaction: jest.fn(), + getTransaction: jest.fn(), + ...overrides, + }; +} + +describe('SorobanRpcClient', () => { + it('rejects construction with zero endpoints', () => { + expect(() => new SorobanRpcClient([])).toThrow(); + }); + + it('uses the first healthy endpoint without touching the others', async () => { + const primary = makeServer({ + getAccount: jest.fn().mockResolvedValue({ id: 'account-1' }), + }); + const secondary = makeServer(); + const client = new SorobanRpcClient([primary, secondary]); + + const result = await client.getAccount('GADDR'); + + expect(result).toEqual({ id: 'account-1' }); + expect(secondary.getAccount).not.toHaveBeenCalled(); + }); + + it('fails over to the next endpoint when the primary is unreachable', async () => { + const primary = makeServer({ + getTransaction: jest.fn().mockRejectedValue(new Error('ECONNREFUSED')), + }); + const secondary = makeServer({ + getTransaction: jest.fn().mockResolvedValue({ status: 'SUCCESS' }), + }); + const client = new SorobanRpcClient([primary, secondary]); + + const result = await client.getTransaction('hash-1'); + + expect(result).toEqual({ status: 'SUCCESS' }); + expect(secondary.getTransaction).toHaveBeenCalledWith('hash-1'); + }); + + it('retries a TRY_AGAIN_LATER send within the same endpoint before succeeding', async () => { + const send = jest + .fn() + .mockResolvedValueOnce({ status: 'TRY_AGAIN_LATER' }) + .mockResolvedValueOnce({ status: 'PENDING', hash: 'h1' }); + const primary = makeServer({ sendTransaction: send }); + const client = new SorobanRpcClient([primary]); + + const result = await client.sendTransaction({} as any); + + expect(result).toEqual({ status: 'PENDING', hash: 'h1' }); + expect(send).toHaveBeenCalledTimes(2); + }); + + it('throws the last error when every endpoint fails', async () => { + const primary = makeServer({ + simulateTransaction: jest.fn().mockRejectedValue(new Error('down-1')), + }); + const secondary = makeServer({ + simulateTransaction: jest.fn().mockRejectedValue(new Error('down-2')), + }); + const client = new SorobanRpcClient([primary, secondary]); + + await expect(client.simulateTransaction({} as any)).rejects.toThrow( + 'down-2', + ); + }); +}); diff --git a/backend/src/payments/soroban/soroban-rpc-client.ts b/backend/src/payments/soroban/soroban-rpc-client.ts new file mode 100644 index 00000000..9871fef2 --- /dev/null +++ b/backend/src/payments/soroban/soroban-rpc-client.ts @@ -0,0 +1,88 @@ +import { Logger } from '@nestjs/common'; +import { SorobanRpc } from '@stellar/stellar-sdk'; +import { retryWithBackoff } from '../utils/retry-with-backoff'; + +/** + * The subset of SorobanRpc.Server's surface this module uses. Typed loosely + * (return values are `any`) deliberately: this is the one seam where we + * touch the SDK's RPC types directly, so a minor-version shape change + * shows up here, not scattered across every caller. + */ +export interface SorobanRpcServerLike { + getAccount(publicKey: string): Promise; + simulateTransaction(tx: any): Promise; + sendTransaction(tx: any): Promise; + getTransaction(hash: string): Promise; +} + +export function createSorobanRpcServer(url: string): SorobanRpcServerLike { + return new SorobanRpc.Server(url, { + allowHttp: url.startsWith('http://'), + }) as unknown as SorobanRpcServerLike; +} + +const RETRYABLE_SEND_STATUSES = new Set(['TRY_AGAIN_LATER']); + +/** + * RPC resilience (issue #1574): tries each configured endpoint in order, + * with retry/backoff (reusing #1572's utility) on transient failures + * within an endpoint before failing over to the next one. A endpoint that + * is simply down (connection error) fails over immediately rather than + * exhausting its retry budget. + */ +export class SorobanRpcClient { + private readonly logger = new Logger(SorobanRpcClient.name); + + constructor(private readonly servers: SorobanRpcServerLike[]) { + if (servers.length === 0) { + throw new Error('SorobanRpcClient needs at least one RPC endpoint'); + } + } + + getAccount(publicKey: string): Promise { + return this.withFailover((server) => server.getAccount(publicKey)); + } + + simulateTransaction(tx: any): Promise { + return this.withFailover((server) => server.simulateTransaction(tx)); + } + + sendTransaction(tx: any): Promise { + return this.withFailover(async (server) => { + const result = await server.sendTransaction(tx); + if (RETRYABLE_SEND_STATUSES.has(result?.status)) { + throw new TransientRpcError(`sendTransaction status ${result.status}`); + } + return result; + }); + } + + getTransaction(hash: string): Promise { + return this.withFailover((server) => server.getTransaction(hash)); + } + + private async withFailover( + call: (server: SorobanRpcServerLike) => Promise, + ): Promise { + let lastError: unknown; + for (const [index, server] of this.servers.entries()) { + try { + return await retryWithBackoff((attempt) => call(server), { + maxAttempts: index === this.servers.length - 1 ? 3 : 2, + baseDelayMs: 200, + maxDelayMs: 2000, + isRetryable: (error) => error instanceof TransientRpcError, + }); + } catch (error) { + lastError = error; + this.logger.warn( + `Soroban RPC endpoint ${index + 1}/${this.servers.length} failed: ` + + (error instanceof Error ? error.message : String(error)), + ); + } + } + throw lastError; + } +} + +export class TransientRpcError extends Error {} diff --git a/backend/src/payments/soroban/soroban.tokens.ts b/backend/src/payments/soroban/soroban.tokens.ts new file mode 100644 index 00000000..9b13f428 --- /dev/null +++ b/backend/src/payments/soroban/soroban.tokens.ts @@ -0,0 +1,14 @@ +/** + * DI token for the conditionally-registered Soroban rail adapter — a + * plain class token doesn't work here since the provider is only + * registered at all when SOROBAN_ENABLED=true (see PaymentsModule). + */ +export const SOROBAN_RAIL_ADAPTER = Symbol('SOROBAN_RAIL_ADAPTER'); + +/** DI token for the escrow contract client, built from SorobanConfig. */ +export const ESCROW_CONTRACT_CLIENT = Symbol('ESCROW_CONTRACT_CLIENT'); + +/** DI token for the resolved SorobanConfig (null when the rail is disabled). */ +export const SOROBAN_CONFIG = Symbol('SOROBAN_CONFIG'); + +export const SOROBAN_ESCROW_QUEUE = 'soroban-escrow'; diff --git a/backend/src/wallets/wallets.service.spec.ts b/backend/src/wallets/wallets.service.spec.ts index 43a6f7ee..2687f3c0 100644 --- a/backend/src/wallets/wallets.service.spec.ts +++ b/backend/src/wallets/wallets.service.spec.ts @@ -264,6 +264,55 @@ describe('WalletsService', () => { }); }); + describe('signPayload', () => { + it('delegates to KeyCustodyService for an active custodial wallet', async () => { + const account = makeAccount(); + walletAccountRepository.findOne.mockResolvedValueOnce(account); + const signature = Buffer.from('sig'); + keyCustody.sign.mockResolvedValueOnce(signature); + const payload = Buffer.from('tx-hash'); + + const result = await service.signPayload('user-1', payload, 'escrow-create'); + + expect(result).toBe(signature); + expect(keyCustody.sign).toHaveBeenCalledWith( + account.id, + payload, + 'SYSTEM', + 'escrow-create', + ); + }); + + it('rejects when the user has no custodial wallet', async () => { + walletAccountRepository.findOne.mockResolvedValueOnce(null); + + await expect( + service.signPayload('user-1', Buffer.from('x'), 'r'), + ).rejects.toThrow(BadRequestException); + expect(keyCustody.sign).not.toHaveBeenCalled(); + }); + + it('rejects when the wallet is external, not custodial', async () => { + walletAccountRepository.findOne.mockResolvedValueOnce( + makeAccount({ custodyType: WalletCustodyType.EXTERNAL }), + ); + + await expect( + service.signPayload('user-1', Buffer.from('x'), 'r'), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects when the custodial wallet is not active', async () => { + walletAccountRepository.findOne.mockResolvedValueOnce( + makeAccount({ status: WalletStatus.PENDING }), + ); + + await expect( + service.signPayload('user-1', Buffer.from('x'), 'r'), + ).rejects.toThrow(BadRequestException); + }); + }); + describe('createLinkChallenge', () => { it('issues a nonce with the configured TTL', async () => { config.get.mockReturnValue(120); diff --git a/backend/src/wallets/wallets.service.ts b/backend/src/wallets/wallets.service.ts index b4856a25..668b943b 100644 --- a/backend/src/wallets/wallets.service.ts +++ b/backend/src/wallets/wallets.service.ts @@ -95,6 +95,32 @@ export class WalletsService { return { account, balance, currency: LEDGER_ASSET }; } + /** + * The one sanctioned way for another module (e.g. the Soroban escrow + * rail, issue #1574) to get something signed by a user's custodial + * wallet. Resolves the user's wallet, then delegates to + * KeyCustodyService — callers never get a decrypted key, only a + * signature, and only for a CUSTODIAL wallet that's actually ACTIVE. + */ + async signPayload( + userId: string, + payload: Buffer, + reason: string, + ): Promise { + const account = await this.walletAccountRepository.findOne({ + where: { userId }, + }); + if (!account || account.custodyType !== WalletCustodyType.CUSTODIAL) { + throw new BadRequestException( + 'User has no custodial wallet to sign with', + ); + } + if (account.status !== WalletStatus.ACTIVE) { + throw new BadRequestException('Custodial wallet is not active'); + } + return this.keyCustody.sign(account.id, payload, 'SYSTEM', reason); + } + /** * Admin-only funding stub: records a ledger credit, it does not move * real on-chain funds. Enough to make the payment flows that depend on From 0ffa7905fa1f4585cbdef7d4451908cf72224d0d Mon Sep 17 00:00:00 2001 From: Leothosine Date: Sat, 22 Aug 2026 13:52:31 +0100 Subject: [PATCH 2/2] fix(soroban): use a real StrKey-encoded contract id in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../soroban/escrow-contract.client.spec.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/backend/src/payments/soroban/escrow-contract.client.spec.ts b/backend/src/payments/soroban/escrow-contract.client.spec.ts index 92cbaf39..0921d807 100644 --- a/backend/src/payments/soroban/escrow-contract.client.spec.ts +++ b/backend/src/payments/soroban/escrow-contract.client.spec.ts @@ -4,7 +4,9 @@ import { attachSignature, } from './escrow-contract.client'; import { EscrowStatus } from './escrow-status.enum'; -import { Account, Keypair } from '@stellar/stellar-sdk'; +import { Account, Keypair, StrKey } from '@stellar/stellar-sdk'; + +const CONTRACT_ID = StrKey.encodeContract(Buffer.alloc(32, 1)); /** * These tests deliberately stay at the boundary that doesn't require @@ -35,7 +37,7 @@ describe('EscrowContractClient', () => { .fn() .mockResolvedValue({ status: 'PENDING', hash: 'hash-1' }), }); - const client = new EscrowContractClient(rpc as any, 'C123', 'passphrase'); + const client = new EscrowContractClient(rpc as any, CONTRACT_ID, 'passphrase'); const result = await client.submit({} as any); @@ -49,7 +51,7 @@ describe('EscrowContractClient', () => { errorResult: { code: 'txBAD_SEQ' }, }), }); - const client = new EscrowContractClient(rpc as any, 'C123', 'passphrase'); + const client = new EscrowContractClient(rpc as any, CONTRACT_ID, 'passphrase'); await expect(client.submit({} as any)).rejects.toThrow( EscrowSubmissionError, @@ -62,7 +64,7 @@ describe('EscrowContractClient', () => { const rpc = makeRpc({ getTransaction: jest.fn().mockResolvedValue({ status: 'SUCCESS' }), }); - const client = new EscrowContractClient(rpc as any, 'C123', 'passphrase'); + const client = new EscrowContractClient(rpc as any, CONTRACT_ID, 'passphrase'); const status = await client.pollFinality('hash-1', { timeoutMs: 1000, @@ -81,7 +83,7 @@ describe('EscrowContractClient', () => { .mockResolvedValueOnce({ status: 'NOT_FOUND' }) .mockResolvedValueOnce({ status: 'SUCCESS' }), }); - const client = new EscrowContractClient(rpc as any, 'C123', 'passphrase'); + const client = new EscrowContractClient(rpc as any, CONTRACT_ID, 'passphrase'); const status = await client.pollFinality('hash-1', { timeoutMs: 5000, @@ -96,7 +98,7 @@ describe('EscrowContractClient', () => { const rpc = makeRpc({ getTransaction: jest.fn().mockResolvedValue({ status: 'NOT_FOUND' }), }); - const client = new EscrowContractClient(rpc as any, 'C123', 'passphrase'); + const client = new EscrowContractClient(rpc as any, CONTRACT_ID, 'passphrase'); const status = await client.pollFinality('hash-1', { timeoutMs: 20, @@ -117,7 +119,7 @@ describe('EscrowContractClient', () => { .fn() .mockResolvedValue({ error: 'Error(Contract, #1)' }), }); - const client = new EscrowContractClient(rpc as any, 'C123', 'passphrase'); + const client = new EscrowContractClient(rpc as any, CONTRACT_ID, 'passphrase'); const status = await client.getEscrowStatus( Keypair.random().publicKey(),