Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string>('REDIS_HOST', 'localhost'),
port: config.get<number>('REDIS_PORT', 6379),
password: config.get<string>('REDIS_PASSWORD') || undefined,
db: config.get<number>('REDIS_DB', 0),
},
}),
}),
AuthModule,
PaymentsModule,
WalletsModule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddSorobanFailureReasons1787401777618
implements MigrationInterface
{
name = 'AddSorobanFailureReasons1787401777618';

public async up(queryRunner: QueryRunner): Promise<void> {
// 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<void> {
// 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).
}
}
13 changes: 13 additions & 0 deletions backend/src/payments/enums/payment-failure-reason.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
4 changes: 3 additions & 1 deletion backend/src/payments/payment-confirmation.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -63,14 +64,15 @@ 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(
paymentRepository as any,
eventRepository as any,
paymentsService as any,
gateway as any,
railAdapter as any,
railRegistry as any,
config as any,
);
});
Expand Down
8 changes: 5 additions & 3 deletions backend/src/payments/payment-confirmation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -43,7 +43,7 @@ export class PaymentConfirmationService {
private readonly eventRepository: Repository<ConfirmationEvent>,
private readonly paymentsService: PaymentsService,
private readonly gateway: PaymentsGateway,
private readonly railAdapter: SandboxRailAdapter,
private readonly railRegistry: PaymentRailRegistry,
private readonly config: ConfigService,
) {}

Expand Down Expand Up @@ -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) {
Expand Down
36 changes: 36 additions & 0 deletions backend/src/payments/payment-rail-registry.spec.ts
Original file line number Diff line number Diff line change
@@ -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/,
);
});
});
39 changes: 39 additions & 0 deletions backend/src/payments/payment-rail-registry.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
71 changes: 70 additions & 1 deletion backend/src/payments/payments.module.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,42 @@
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';
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,
Expand All @@ -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<typeof loadSorobanConfig>) =>
sorobanConfig
? new SorobanRpcClient(
sorobanConfig.rpcUrls.map(createSorobanRpcServer),
)
: null,
},
{
provide: ESCROW_CONTRACT_CLIENT,
inject: [SOROBAN_CONFIG, SorobanRpcClient],
useFactory: (
sorobanConfig: ReturnType<typeof loadSorobanConfig>,
rpcClient: SorobanRpcClient | null,
) =>
sorobanConfig && rpcClient
? new EscrowContractClient(
rpcClient,
sorobanConfig.contractId,
sorobanConfig.networkPassphrase,
)
: null,
},
{
provide: SOROBAN_RAIL_ADAPTER,
inject: [SOROBAN_CONFIG, SorobanRailAdapter],
useFactory: (
sorobanConfig: ReturnType<typeof loadSorobanConfig>,
adapter: SorobanRailAdapter,
) => (sorobanConfig ? adapter : null),
},
SorobanRailAdapter,
EscrowSubmissionProcessor,
],
exports: [PaymentsService, PaymentConfirmationService, ReconciliationService],
})
Expand Down
4 changes: 3 additions & 1 deletion backend/src/payments/payments.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
);
});
Expand Down
6 changes: 3 additions & 3 deletions backend/src/payments/payments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -33,7 +33,7 @@ export class PaymentsService {
constructor(
@InjectRepository(Payment)
private readonly paymentRepository: Repository<Payment>,
private readonly railAdapter: SandboxRailAdapter,
private readonly railRegistry: PaymentRailRegistry,
private readonly config: ConfigService,
) {}

Expand Down Expand Up @@ -159,7 +159,7 @@ export class PaymentsService {
private async progressToAwaitingConfirmation(
payment: Payment,
): Promise<Payment> {
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);
Expand Down
Loading
Loading