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 @@ -135,6 +135,9 @@ STELLAR_HORIZON_URL=https://soroban-testnet.stellar.org
# (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
# Per-RPC-call timeout (ms). Defaults to 10000; a timeout is transient and
# triggers retry/failover to the next configured RPC endpoint.
SOROBAN_RPC_TIMEOUT_MS=10000
# 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.
Expand All @@ -148,6 +151,12 @@ BOOKING_PAYMENT_TTL_MINUTES=120
# Payments module (issue #1570)
# Minutes an INITIATED Payment may sit before it is eligible for expiry sweep
PAYMENT_INITIATED_TTL_MINUTES=30
# Optional, explicit rail failover for new initiations only. Each entry is
# FROM=TO; leave unset/empty to preserve the existing unavailable-rail error.
# Example: PAYMENT_RAIL_FAILOVER=STELLAR_CUSTODIAL=FIAT,FIAT=
# The rail actually used is stored on the Payment and the requested rail is
# kept in metadata; already-created Payments are never switched.
PAYMENT_RAIL_FAILOVER=

# Payments confirmation pipeline (issue #1571)
# Shared secret for verifying the sandbox rail's webhook HMAC signature
Expand Down
43 changes: 43 additions & 0 deletions backend/src/common/request-context.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {
currentRequestId,
outboundRequestHeaders,
runWithRequestContext,
withRequestId,
} from './request-context';

describe('request context', () => {
it('returns both accepted correlation header names inside a request', () => {
const headers = runWithRequestContext(
{ requestId: 'request-123' },
() => outboundRequestHeaders(),
);

expect(headers).toEqual({
'x-request-id': 'request-123',
'x-correlation-id': 'request-123',
});
});

it('returns no headers outside a request context', () => {
expect(currentRequestId()).toBeUndefined();
expect(outboundRequestHeaders()).toEqual({});
});

it('does not emit a placeholder for an empty request id', () => {
const headers = runWithRequestContext(
{ requestId: '' },
() => outboundRequestHeaders(),
);

expect(headers).toEqual({});
});

it('keeps the request id available to log formatting', () => {
const message = runWithRequestContext(
{ requestId: 'request-456' },
() => withRequestId('doing work'),
);

expect(message).toBe('[request:request-456] doing work');
});
});
22 changes: 22 additions & 0 deletions backend/src/common/request-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,28 @@ export function currentRequestId(): string | undefined {
return requestContext.getStore()?.requestId;
}

/**
* Builds the correlation headers for an outbound call made by the current
* request. The middleware already accepts `x-request-id` and
* `x-correlation-id` as inbound headers, so sending both names lets the
* identifier survive a full round trip through services that choose either
* convention. An empty object is returned when no request context is active;
* callers must not manufacture a placeholder identifier for background work
* or for code running outside an HTTP request. This helper can populate only
* an API that accepts custom headers; a provider that discards or does not
* expose them cannot be made traceable by adding fields to this object.
*/
export function outboundRequestHeaders(): Record<string, string> {
const requestId = currentRequestId();
if (!requestId) {
return {};
}
return {
'x-request-id': requestId,
'x-correlation-id': requestId,
};
}

export function withRequestId(message: string): string {
const requestId = currentRequestId();
return requestId ? `[request:${requestId}] ${message}` : message;
Expand Down
3 changes: 3 additions & 0 deletions backend/src/credits/split-allocation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ describe('allocateByBasisPoints', () => {
for (const amount of amounts) {
const allocations = allocateByBasisPoints(amount, config);
expect(total(allocations)).toBe(amount);
expect(
allocations.every((a) => Number.isInteger(a.amount)),
).toBe(true);
expect(allocations.every((a) => a.amount >= 0)).toBe(true);
// The remainder pass hands out strictly fewer units than there are
// recipients, so nobody can be topped up twice in one allocation.
Expand Down
58 changes: 58 additions & 0 deletions backend/src/payments/adapters/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Payment-rail failover

Payment initiation may use a different rail when the requested rail is not
available, but the policy is deliberately opt-in. `PaymentRailRegistry.get()`
is the strict lookup used after a payment exists; `resolve()` is used only by
`PaymentsService` while initiating a new payment.

## What availability means

Availability currently means that the adapter is registered and configured:

- `FIAT` is available through the sandbox adapter.
- `STELLAR_CUSTODIAL` and `STELLAR_EXTERNAL` are available only when the
Soroban adapter is configured.
- Resolution does not perform a live network health check. An adapter that
is registered can still fail when its provider call is made; switching after
that call could duplicate a charge or an escrow, so it is not attempted.

## Configuration

Set `PAYMENT_RAIL_FAILOVER` to a comma-separated list of explicit
`FROM=TO` mappings. The mappings form an ordered, auditable chain:

```dotenv
PAYMENT_RAIL_FAILOVER=STELLAR_CUSTODIAL=FIAT,FIAT=
```

The example means that an unavailable `STELLAR_CUSTODIAL` may fall back to
`FIAT`; the empty `FIAT=` target explicitly ends the chain. A longer chain
can be expressed by linking mappings:

```dotenv
PAYMENT_RAIL_FAILOVER=STELLAR_CUSTODIAL=FIAT,FIAT=STELLAR_EXTERNAL,STELLAR_EXTERNAL=
```

A source or target that is not a known `PaymentRail`, a duplicate source, or
a cycle is rejected with a configuration error. If the variable is unset or
empty, there is no fallback and the existing clear unavailable-rail error is
preserved.

## Stored-rail invariant

When a fallback is selected, `PaymentsService` stores the rail that actually
received the initiation call in `Payment.rail` and records the caller's
request as `metadata.requestedRail`. The original request is retained so an
idempotency-key replay compares against the requested payload rather than the
fallback implementation detail.

The stored rail is never rewritten later. Webhook confirmation,
reconciliation, verify-on-return, and refund provider calls resolve the
adapter from `Payment.rail`, so changing it after initiation could send a
provider reference to a different rail and break confirmation.

If no configured fallback is available, initiation fails with the existing
clear configuration error; it does not enter a silent degraded mode. Once a
provider call has started, an error is left to the normal payment error and
reconciliation paths rather than starting a second provider call on another
rail.
55 changes: 55 additions & 0 deletions backend/src/payments/payment-rail-registry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,59 @@ describe('PaymentRailRegistry', () => {
/SOROBAN_ENABLED/,
);
});

it('resolves an available requested rail without using a fallback', () => {
const sandbox = {} as any;
const config = {
get: jest.fn((key: string) =>
key === 'PAYMENT_RAIL_FAILOVER'
? 'STELLAR_CUSTODIAL=FIAT,FIAT='
: undefined,
),
};
const registry = new PaymentRailRegistry(
sandbox,
undefined,
config as any,
);

expect(registry.resolve(PaymentRail.FIAT)).toEqual({
adapter: sandbox,
rail: PaymentRail.FIAT,
usedFallback: false,
});
});

it('uses the first explicitly configured available fallback', () => {
const sandbox = {} as any;
const config = {
get: jest.fn((key: string) =>
key === 'PAYMENT_RAIL_FAILOVER'
? 'STELLAR_CUSTODIAL=FIAT,FIAT='
: undefined,
),
};
const registry = new PaymentRailRegistry(
sandbox,
undefined,
config as any,
);

expect(registry.resolve(PaymentRail.STELLAR_CUSTODIAL)).toEqual({
adapter: sandbox,
rail: PaymentRail.FIAT,
usedFallback: true,
});
});

it('keeps the strict stored-rail error when no fallback is configured', () => {
const sandbox = {} as any;
const registry = new PaymentRailRegistry(sandbox, undefined);

expect(registry.isAvailable(PaymentRail.FIAT)).toBe(true);
expect(registry.isAvailable(PaymentRail.STELLAR_CUSTODIAL)).toBe(false);
expect(() => registry.resolve(PaymentRail.STELLAR_CUSTODIAL)).toThrow(
/SOROBAN_ENABLED/,
);
});
});
Loading
Loading