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
8 changes: 8 additions & 0 deletions backend/src/payments/entities/payment.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
65 changes: 60 additions & 5 deletions backend/src/payments/providers/soroban-escrow.provider.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
Expand All @@ -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;

Expand Down Expand Up @@ -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 =
Expand All @@ -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,
});
});
});
});
107 changes: 107 additions & 0 deletions backend/src/payments/providers/soroban-escrow.provider.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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<string, unknown>,
): 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<EscrowOutcome> {
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,
};
}
}
}
Loading