diff --git a/backend/src/payments/entities/payment.entity.ts b/backend/src/payments/entities/payment.entity.ts index 963524ab..90f73943 100644 --- a/backend/src/payments/entities/payment.entity.ts +++ b/backend/src/payments/entities/payment.entity.ts @@ -71,6 +71,14 @@ export class Payment { @Column({ type: 'enum', enum: PaymentStatus, default: PaymentStatus.PENDING }) status: PaymentStatus; + /** Last reconciled Soroban escrow outcome. */ + @Column({ type: 'varchar', length: 20, nullable: true }) + escrowStatus: 'pending' | 'confirmed' | 'failed' | 'timed_out' | null; + + /** When the escrow state was last reconciled with the chain. */ + @Column({ type: 'timestamptz', nullable: true }) + escrowReconciledAt: Date | null; + @Column({ type: 'timestamptz', nullable: true }) paidAt: Date; diff --git a/backend/src/payments/providers/soroban-escrow.provider.spec.ts b/backend/src/payments/providers/soroban-escrow.provider.spec.ts index dbcd678c..47b2e2be 100644 --- a/backend/src/payments/providers/soroban-escrow.provider.spec.ts +++ b/backend/src/payments/providers/soroban-escrow.provider.spec.ts @@ -1,5 +1,8 @@ import { ConfigService } from '@nestjs/config'; -import { SorobanEscrowProvider } from './soroban-escrow.provider'; +import { + EscrowOutcomeStatus, + SorobanEscrowProvider, +} from './soroban-escrow.provider'; describe('SorobanEscrowProvider', () => { const configValues: Record = { @@ -10,8 +13,9 @@ describe('SorobanEscrowProvider', () => { function createProvider() { const configService = { - get: jest.fn((key: string, defaultValue?: unknown) => - configValues[key] ?? defaultValue, + get: jest.fn( + (key: string, defaultValue?: unknown) => + configValues[key] ?? defaultValue, ), } as unknown as ConfigService; @@ -57,8 +61,8 @@ describe('SorobanEscrowProvider', () => { }); it.each([ - ['releaseEscrow', 'soroban_release_stub-booking-123'], - ['refundEscrow', 'soroban_refund_stub-booking-123'], + ['releaseEscrow', 'soroban_release_stub_booking-123'], + ['refundEscrow', 'soroban_refund_stub_booking-123'], ])('returns the transaction hash for %s', async (operation, expectedHash) => { const { provider } = createProvider(); const result = @@ -77,4 +81,55 @@ describe('SorobanEscrowProvider', () => { status: 'stub', }); }); + + describe('reconcileEscrow', () => { + it('keeps stub status pending and non-final', async () => { + const { provider } = createProvider(); + await expect(provider.reconcileEscrow('e1')).resolves.toMatchObject({ + status: EscrowOutcomeStatus.PENDING, + final: false, + retryable: true, + }); + }); + + it.each([ + ['confirmed', EscrowOutcomeStatus.CONFIRMED, true, false], + ['failed', EscrowOutcomeStatus.FAILED, true, false], + ['timed_out', EscrowOutcomeStatus.TIMED_OUT, false, true], + ])('maps %s', async (raw, status, final, retryable) => { + const { provider } = createProvider(); + jest + .spyOn(provider, 'getEscrowStatus') + .mockResolvedValue({ status: raw }); + await expect(provider.reconcileEscrow('e1')).resolves.toMatchObject({ + status, + final, + retryable, + }); + }); + + it('treats transient RPC failures as retryable, never final', async () => { + const { provider } = createProvider(); + jest + .spyOn(provider, 'getEscrowStatus') + .mockRejectedValue(new Error('503 Service Unavailable')); + await expect(provider.reconcileEscrow('e1')).resolves.toMatchObject({ + status: EscrowOutcomeStatus.PENDING, + final: false, + retryable: true, + }); + }); + + it('reports non-transient errors as final failures', async () => { + const { provider } = createProvider(); + jest + .spyOn(provider, 'getEscrowStatus') + .mockRejectedValue(new Error('contract trapped: invalid escrow')); + await expect(provider.reconcileEscrow('e1')).resolves.toMatchObject({ + status: EscrowOutcomeStatus.FAILED, + final: true, + retryable: false, + }); + }); + }); }); diff --git a/backend/src/payments/providers/soroban-escrow.provider.ts b/backend/src/payments/providers/soroban-escrow.provider.ts index 7721c445..9c5bc6cb 100644 --- a/backend/src/payments/providers/soroban-escrow.provider.ts +++ b/backend/src/payments/providers/soroban-escrow.provider.ts @@ -1,6 +1,32 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +export enum EscrowOutcomeStatus { + PENDING = 'pending', + CONFIRMED = 'confirmed', + FAILED = 'failed', + TIMED_OUT = 'timed_out', +} + +export interface EscrowOutcome { + escrowId: string; + status: EscrowOutcomeStatus; + /** True only for confirmed or definitively failed settlement. */ + final: boolean; + /** True when the caller may safely retry reconciliation. */ + retryable: boolean; + reason?: string; +} + +const TRANSIENT_ERROR_PATTERN = + /timeout|timed out|etimedout|econnreset|econnrefused|enotfound|socket hang up|network|rate limit|too many requests|\b(429|500|502|503|504)\b|try_again_later|unavailable/i; + +/** Whether an RPC error is transient and therefore retryable. */ +export function isTransientRpcError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return TRANSIENT_ERROR_PATTERN.test(message); +} + /** * Bridges long-term booking payments to the Soroban payment escrow contract. * @@ -65,4 +91,85 @@ export class SorobanEscrowProvider { this.logger.log(`[Soroban] getEscrowStatus: ${escrowId}`); return { escrowId, status: 'stub' }; } + + /** + * Maps a raw escrow status payload to a normalized outcome. Unknown or stub + * statuses stay pending so they are never reported as final settlement. + */ + normalizeOutcome( + escrowId: string, + raw: Record, + ): EscrowOutcome { + const value = String(raw?.status ?? '').toLowerCase(); + switch (value) { + case 'confirmed': + case 'success': + case 'released': + case 'refunded': + return { + escrowId, + status: EscrowOutcomeStatus.CONFIRMED, + final: true, + retryable: false, + }; + case 'failed': + case 'error': + return { + escrowId, + status: EscrowOutcomeStatus.FAILED, + final: true, + retryable: false, + reason: raw?.reason ? String(raw.reason) : undefined, + }; + case 'timed_out': + case 'timeout': + return { + escrowId, + status: EscrowOutcomeStatus.TIMED_OUT, + final: false, + retryable: true, + }; + default: + return { + escrowId, + status: EscrowOutcomeStatus.PENDING, + final: false, + retryable: true, + }; + } + } + + /** + * Fetches the escrow state and returns a consistent outcome. Transient RPC + * failures yield a retryable, non-final pending outcome; non-transient + * failures yield a final failed outcome. + */ + async reconcileEscrow(escrowId: string): Promise { + try { + const raw = await this.getEscrowStatus(escrowId); + return this.normalizeOutcome(escrowId, raw); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + if (isTransientRpcError(error)) { + this.logger.warn( + `[Soroban] transient RPC failure for ${escrowId}: ${reason}`, + ); + return { + escrowId, + status: EscrowOutcomeStatus.PENDING, + final: false, + retryable: true, + reason, + }; + } + this.logger.error(`[Soroban] escrow ${escrowId} failed: ${reason}`); + return { + escrowId, + status: EscrowOutcomeStatus.FAILED, + final: true, + retryable: false, + reason, + }; + } + } }