diff --git a/backend/.env.example b/backend/.env.example index 2239951e..9a2cedab 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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. @@ -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 diff --git a/backend/src/common/request-context.spec.ts b/backend/src/common/request-context.spec.ts new file mode 100644 index 00000000..baf96936 --- /dev/null +++ b/backend/src/common/request-context.spec.ts @@ -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'); + }); +}); diff --git a/backend/src/common/request-context.ts b/backend/src/common/request-context.ts index 56e6fb17..bccd19f8 100644 --- a/backend/src/common/request-context.ts +++ b/backend/src/common/request-context.ts @@ -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 { + 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; diff --git a/backend/src/credits/split-allocation.spec.ts b/backend/src/credits/split-allocation.spec.ts index 7ba2b54b..5a306de6 100644 --- a/backend/src/credits/split-allocation.spec.ts +++ b/backend/src/credits/split-allocation.spec.ts @@ -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. diff --git a/backend/src/payments/adapters/README.md b/backend/src/payments/adapters/README.md new file mode 100644 index 00000000..0b754347 --- /dev/null +++ b/backend/src/payments/adapters/README.md @@ -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. diff --git a/backend/src/payments/payment-rail-registry.spec.ts b/backend/src/payments/payment-rail-registry.spec.ts index e96855fd..49e2bccd 100644 --- a/backend/src/payments/payment-rail-registry.spec.ts +++ b/backend/src/payments/payment-rail-registry.spec.ts @@ -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/, + ); + }); }); diff --git a/backend/src/payments/payment-rail-registry.ts b/backend/src/payments/payment-rail-registry.ts index 6db7c9d8..1b0e2cc5 100644 --- a/backend/src/payments/payment-rail-registry.ts +++ b/backend/src/payments/payment-rail-registry.ts @@ -1,9 +1,18 @@ import { Inject, Injectable, Optional } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; 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'; +export interface PaymentRailResolution { + adapter: PaymentRailAdapter; + rail: PaymentRail; + usedFallback: boolean; +} + +type FallbackMap = Map; + /** * Resolves the right PaymentRailAdapter for a Payment#rail (issue #1574 — * #1570 only ever needed one adapter, so nothing dispatched by rail yet). @@ -11,6 +20,13 @@ import { SOROBAN_RAIL_ADAPTER } from './soroban/soroban.tokens'; * 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. + * + * `get` is the strict stored-rail lookup. `resolve` is deliberately separate + * and is for initiation only: it may choose a configured fallback before a + * provider is called, but it never changes the rail of a Payment that has + * already been created. Webhooks, reconciliation, and refunds therefore keep + * using `get(payment.rail)` and can always find the adapter that initiated + * the payment. */ @Injectable() export class PaymentRailRegistry { @@ -19,8 +35,15 @@ export class PaymentRailRegistry { @Optional() @Inject(SOROBAN_RAIL_ADAPTER) private readonly sorobanRailAdapter: PaymentRailAdapter | undefined, + @Optional() + @Inject(ConfigService) + private readonly config?: ConfigService, ) {} + /** + * Strict lookup used by every post-initiation path. An unavailable rail + * retains the original, actionable configuration error. + */ get(rail: PaymentRail): PaymentRailAdapter { switch (rail) { case PaymentRail.FIAT: @@ -36,4 +59,126 @@ export class PaymentRailRegistry { return this.sorobanRailAdapter; } } + + /** + * Reports configuration/registration availability without making a provider + * network call. The current adapters have no health-probe contract, so an + * adapter that is registered is considered available until an actual + * initiation call reports a provider error. This keeps selection side + * effect free and avoids pretending that a DNS or RPC probe is a payment + * verdict. + */ + isAvailable(rail: PaymentRail): boolean { + return this.tryGet(rail) !== undefined; + } + + /** + * Resolves a rail for a new initiation. If the requested adapter is not + * registered, follows the explicit `PAYMENT_RAIL_FAILOVER` edges in their + * configured order and returns the first available adapter. The requested + * rail is never silently rewritten on an existing Payment; the caller must + * persist the returned rail and retain the request in metadata. + */ + resolve(rail: PaymentRail): PaymentRailResolution { + const requestedAdapter = this.tryGet(rail); + if (requestedAdapter) { + return { adapter: requestedAdapter, rail, usedFallback: false }; + } + + for (const fallbackRail of this.fallbackChain(rail)) { + const fallbackAdapter = this.tryGet(fallbackRail); + if (fallbackAdapter) { + return { + adapter: fallbackAdapter, + rail: fallbackRail, + usedFallback: true, + }; + } + } + + this.get(rail); + throw new Error(`Payment rail ${rail} is unavailable and has no fallback`); + } + + private tryGet(rail: PaymentRail): PaymentRailAdapter | undefined { + try { + return this.get(rail) ?? undefined; + } catch { + return undefined; + } + } + + /** + * Parses the opt-in policy as a graph of one-way `FROM=TO` edges. A blank + * target such as `FIAT=` explicitly terminates a chain. Multiple mappings + * can describe a longer ordered chain, for example + * `STELLAR_CUSTODIAL=FIAT,FIAT=STELLAR_EXTERNAL`. + */ + private fallbackChain(requestedRail: PaymentRail): PaymentRail[] { + const mappings = this.parseFallbackMappings(); + const chain: PaymentRail[] = []; + const visited = new Set([requestedRail]); + let current = mappings.get(requestedRail); + + while (current !== undefined && current !== null) { + if (visited.has(current)) { + throw new Error( + `PAYMENT_RAIL_FAILOVER contains a cycle involving ${current}`, + ); + } + chain.push(current); + visited.add(current); + current = mappings.get(current); + } + + return chain; + } + + private parseFallbackMappings(): FallbackMap { + const mappings: FallbackMap = new Map(); + const raw = this.config?.get('PAYMENT_RAIL_FAILOVER'); + if (!raw?.trim()) { + return mappings; + } + + for (const entry of raw.split(',')) { + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + const separator = trimmed.indexOf('='); + if (separator <= 0) { + throw new Error( + `Invalid PAYMENT_RAIL_FAILOVER entry "${trimmed}"; expected FROM=TO`, + ); + } + + const from = this.parseRail( + trimmed.slice(0, separator).trim(), + 'PAYMENT_RAIL_FAILOVER source', + ); + if (mappings.has(from)) { + throw new Error( + `PAYMENT_RAIL_FAILOVER contains more than one mapping for ${from}`, + ); + } + + const target = trimmed.slice(separator + 1).trim(); + mappings.set( + from, + target + ? this.parseRail(target, 'PAYMENT_RAIL_FAILOVER target') + : null, + ); + } + + return mappings; + } + + private parseRail(value: string, label: string): PaymentRail { + if (!Object.values(PaymentRail).includes(value as PaymentRail)) { + throw new Error(`${label} is not a known payment rail: ${value}`); + } + return value as PaymentRail; + } } diff --git a/backend/src/payments/payments.module.ts b/backend/src/payments/payments.module.ts index 761bcb62..2b0b7e18 100644 --- a/backend/src/payments/payments.module.ts +++ b/backend/src/payments/payments.module.ts @@ -31,8 +31,10 @@ import { EscrowSubmissionProcessor } from './soroban/escrow-submission.processor import { EscrowContractClient } from './soroban/escrow-contract.client'; import { createLazy } from './soroban/lazy-initialization'; import { + DEFAULT_SOROBAN_RPC_TIMEOUT_MS, SorobanRpcClient, createSorobanRpcServer, + parseSorobanRpcTimeoutMs, } from './soroban/soroban-rpc-client'; import { ESCROW_CONTRACT_CLIENT, @@ -81,18 +83,29 @@ import { }, { provide: SorobanRpcClient, - inject: [SOROBAN_CONFIG], + inject: [SOROBAN_CONFIG, ConfigService], // Resolve a lazy proxy while keeping the disabled case a literal null; // the RPC SDK servers and SorobanRpcClient constructor are not touched // until a consumer actually accesses the client. - useFactory: (sorobanConfig: ReturnType) => { + useFactory: ( + sorobanConfig: ReturnType, + config: ConfigService, + ) => { if (!sorobanConfig) { return null; } - const config = sorobanConfig; + const timeoutMs = parseSorobanRpcTimeoutMs( + config.get( + 'SOROBAN_RPC_TIMEOUT_MS', + DEFAULT_SOROBAN_RPC_TIMEOUT_MS, + ), + ); return createLazy( () => - new SorobanRpcClient(config.rpcUrls.map(createSorobanRpcServer)), + new SorobanRpcClient( + sorobanConfig.rpcUrls.map(createSorobanRpcServer), + timeoutMs, + ), ); }, }, diff --git a/backend/src/payments/payments.service.spec.ts b/backend/src/payments/payments.service.spec.ts index 473e113d..dcbc0a31 100644 --- a/backend/src/payments/payments.service.spec.ts +++ b/backend/src/payments/payments.service.spec.ts @@ -56,7 +56,7 @@ function uniqueViolation(constraint: string) { describe('PaymentsService', () => { let repository: MockRepository; let railAdapter: { initiate: jest.Mock }; - let railRegistry: { get: jest.Mock }; + let railRegistry: { get: jest.Mock; resolve: jest.Mock }; let config: { get: jest.Mock }; let metrics: { recordPaymentTransition: jest.Mock }; let service: PaymentsService; @@ -66,7 +66,14 @@ describe('PaymentsService', () => { railAdapter = { initiate: jest.fn().mockResolvedValue({ providerReference: 'ref-1' }), }; - railRegistry = { get: jest.fn().mockReturnValue(railAdapter) }; + railRegistry = { + get: jest.fn().mockReturnValue(railAdapter), + resolve: jest.fn().mockReturnValue({ + adapter: railAdapter, + rail: PaymentRail.FIAT, + usedFallback: false, + }), + }; config = { get: jest.fn((_key: string, defaultValue: unknown) => defaultValue), }; @@ -99,6 +106,67 @@ describe('PaymentsService', () => { expect(repository.save).toHaveBeenCalledTimes(2); }); + it('stores the fallback rail and retains the requested rail in metadata', async () => { + const fallbackAdapter = { + initiate: jest + .fn() + .mockResolvedValue({ providerReference: 'fallback-ref' }), + }; + railRegistry.resolve.mockReturnValue({ + adapter: fallbackAdapter, + rail: PaymentRail.FIAT, + usedFallback: true, + }); + repository.findOne.mockResolvedValueOnce(null); + repository.findOne.mockResolvedValueOnce(null); + + const result = await service.initiate( + 'user-1', + 'key-fallback', + makeDto({ rail: PaymentRail.STELLAR_CUSTODIAL }), + ); + + expect(result.rail).toBe(PaymentRail.FIAT); + expect(result.metadata).toMatchObject({ + requestedRail: PaymentRail.STELLAR_CUSTODIAL, + }); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + rail: PaymentRail.FIAT, + metadata: expect.objectContaining({ + requestedRail: PaymentRail.STELLAR_CUSTODIAL, + }), + }), + ); + expect(fallbackAdapter.initiate).toHaveBeenCalledWith( + expect.objectContaining({ rail: PaymentRail.FIAT }), + ); + expect(railAdapter.initiate).not.toHaveBeenCalled(); + }); + + it('replays a fallback payment against its originally requested rail', async () => { + const existing = { + id: 'p-fallback', + userId: 'user-1', + bookingId: 'booking-1', + amount: 5000, + currency: 'USD', + rail: PaymentRail.FIAT, + metadata: { requestedRail: PaymentRail.STELLAR_CUSTODIAL }, + status: PaymentStatus.AWAITING_CONFIRMATION, + } as unknown as Payment; + repository.findOne.mockResolvedValueOnce(existing); + + const result = await service.initiate( + 'user-1', + 'key-fallback', + makeDto({ rail: PaymentRail.STELLAR_CUSTODIAL }), + ); + + expect(result).toBe(existing); + expect(railRegistry.resolve).not.toHaveBeenCalled(); + }); + it('opens the rail breaker and returns 503 without retrying the provider', async () => { repository.findOne.mockResolvedValue(null); railAdapter.initiate.mockRejectedValue(new Error('provider unavailable')); diff --git a/backend/src/payments/payments.service.ts b/backend/src/payments/payments.service.ts index 2e39b836..99aa45c0 100644 --- a/backend/src/payments/payments.service.ts +++ b/backend/src/payments/payments.service.ts @@ -3,6 +3,7 @@ import { ConflictException, ForbiddenException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -18,9 +19,13 @@ import { NON_TERMINAL_PAYMENT_STATUSES, PaymentStatus, } from './enums/payment-status.enum'; -import { assertValidTransition } from './payment-state-machine'; -import { PaymentRailRegistry } from './payment-rail-registry'; import { PaymentRail } from './enums/payment-rail.enum'; +import { assertValidTransition } from './payment-state-machine'; +import { + PaymentRailRegistry, + PaymentRailResolution, +} from './payment-rail-registry'; +import { withRequestId } from '../common/request-context'; import type { PaymentInitiationResult } from './interfaces/payment-rail-adapter.interface'; import { createPaymentProviderCircuitBreaker } from './utils/circuit-breaker'; import type { PaymentProviderCircuitBreaker } from './utils/circuit-breaker'; @@ -36,6 +41,7 @@ const BLOCKING_STATUSES_FOR_NEW_PAYMENT = [ @Injectable() export class PaymentsService { + private readonly logger = new Logger(PaymentsService.name); private readonly breakers = new Map< string, PaymentProviderCircuitBreaker<[Payment], PaymentInitiationResult> @@ -71,11 +77,17 @@ export class PaymentsService { await this.assertBookingAvailable(dto.bookingId); - const payment = this.buildInitiatedPayment(userId, idempotencyKey, dto); + const resolution = this.railRegistry.resolve(dto.rail); + const payment = this.buildInitiatedPayment( + userId, + idempotencyKey, + dto, + resolution, + ); try { const saved = await this.paymentRepository.save(payment); - return await this.progressToAwaitingConfirmation(saved); + return await this.progressToAwaitingConfirmation(saved, resolution); } catch (error) { return this.handleInsertConflict(error, userId, idempotencyKey); } @@ -132,7 +144,7 @@ export class PaymentsService { existing.bookingId === dto.bookingId && existing.amount === dto.amount && existing.currency === dto.currency.toUpperCase() && - existing.rail === dto.rail; + this.requestedRailFor(existing) === dto.rail; if (!samePayload) { throw new ConflictException( @@ -142,6 +154,22 @@ export class PaymentsService { return existing; } + /** + * Returns the rail the caller originally requested, even when initiation + * persisted a fallback rail. Without this lookup an idempotency replay of + * the original request would look like a different payload and be rejected. + */ + private requestedRailFor(payment: Payment): PaymentRail { + const requestedRail = payment.metadata?.requestedRail; + const isKnownRail = + requestedRail === PaymentRail.FIAT || + requestedRail === PaymentRail.STELLAR_CUSTODIAL || + requestedRail === PaymentRail.STELLAR_EXTERNAL; + return isKnownRail && requestedRail !== payment.rail + ? requestedRail + : payment.rail; + } + private async assertBookingAvailable(bookingId: string): Promise { const blocking = await this.paymentRepository.findOne({ where: { @@ -162,21 +190,27 @@ export class PaymentsService { userId: string, idempotencyKey: string, dto: InitiatePaymentDto, + resolution: PaymentRailResolution, ): Payment { const ttlMinutes = this.config.get( 'PAYMENT_INITIATED_TTL_MINUTES', 30, ); + const requestedRail = dto.rail; + const usedFallback = + resolution.usedFallback && resolution.rail !== requestedRail; return this.paymentRepository.create({ bookingId: dto.bookingId, userId, amount: dto.amount, currency: dto.currency.toUpperCase(), - rail: dto.rail, + rail: resolution.rail, provider: dto.provider ?? null, status: PaymentStatus.INITIATED, idempotencyKey, - metadata: dto.metadata ?? null, + metadata: usedFallback + ? { ...(dto.metadata ?? {}), requestedRail } + : dto.metadata ?? null, expiresAt: new Date(Date.now() + ttlMinutes * 60_000), }); } @@ -206,9 +240,43 @@ export class PaymentsService { return breaker; } + /** + * This is the only point where failover may change a Payment's rail. The + * rail persisted here is the rail whose adapter actually received the + * initiation call; the requested rail is retained in metadata for audit and + * idempotency replay. Once this method has selected a rail, webhook, + * reconciliation, and refund paths must continue to use strict + * `get(payment.rail)` — they must never re-resolve or rewrite a stored + * Payment's rail, or confirmation could be delivered to the wrong adapter. + * + * The provider call itself is wrapped in a per-rail circuit breaker and a + * tracing span: the breaker is keyed by the actual (possibly fallback) + * rail, so an unhealthy provider only trips its own breaker, and the + * breaker's own `railRegistry.get(rail)` lookup stays consistent with the + * rail already persisted on `payment` above. + */ private async progressToAwaitingConfirmation( payment: Payment, + resolution: PaymentRailResolution = this.railRegistry.resolve( + payment.rail, + ), ): Promise { + const requestedRail = this.requestedRailFor(payment); + if (resolution.usedFallback && resolution.rail !== requestedRail) { + payment.rail = resolution.rail; + payment.metadata = { + ...(payment.metadata ?? {}), + requestedRail, + }; + this.logger.warn( + withRequestId( + `Payment ${payment.id} requested rail ${requestedRail} but rail ` + + `${resolution.rail} is being used because the requested adapter ` + + 'is unavailable', + ), + ); + } + const result = await withSpan( 'payments.rail.initiate', () => this.getBreaker(payment.rail).fire(payment), diff --git a/backend/src/payments/reconciliation.service.spec.ts b/backend/src/payments/reconciliation.service.spec.ts index 743820e2..f11ba9d2 100644 --- a/backend/src/payments/reconciliation.service.spec.ts +++ b/backend/src/payments/reconciliation.service.spec.ts @@ -2,7 +2,9 @@ import { NotFoundException, UnprocessableEntityException, } from '@nestjs/common'; +import * as nodemailer from 'nodemailer'; import { FindOperator } from 'typeorm'; +import { runWithRequestContext } from '../common/request-context'; import { ReconciliationService } from './reconciliation.service'; import { Payment } from './entities/payment.entity'; import { PaymentRail } from './enums/payment-rail.enum'; @@ -156,8 +158,8 @@ function makeRunRepository(seed: any[] = []) { }; } -function makeConfigService(overrides: Record = {}) { - const values: Record = { +function makeConfigService(overrides: Record = {}) { + const values: Record = { PAYMENT_RECONCILE_DUE_AFTER_MINUTES: 5, PAYMENT_RECONCILE_BACKOFF_BASE_MINUTES: 5, PAYMENT_RECONCILE_BACKOFF_MAX_MINUTES: 60, @@ -170,7 +172,9 @@ function makeConfigService(overrides: Record = {}) { ...overrides, }; return { - get: jest.fn((key: string, fallback?: number) => values[key] ?? fallback), + get: jest.fn( + (key: string, fallback?: number | string) => values[key] ?? fallback, + ), }; } @@ -197,7 +201,7 @@ describe('ReconciliationService', () => { function build( seed: Payment[], - configOverrides: Record = {}, + configOverrides: Record = {}, runSeed: any[] = [], ) { const paymentRepository = makePaymentRepository(seed); @@ -648,6 +652,41 @@ describe('ReconciliationService', () => { }); }); + describe('outbound request correlation', () => { + it('attaches the active request id to manual-review SMTP alerts', async () => { + const sendMail = jest.fn().mockResolvedValue(undefined); + const createTransport = jest + .spyOn(nodemailer, 'createTransport') + .mockReturnValue({ sendMail } as any); + const { service } = build([], { + SMTP_HOST: 'smtp.example.test', + SMTP_PORT: 587, + SMTP_SECURE: 'false', + SMTP_USER: 'alerts@example.test', + SMTP_PASS: 'secret', + SMTP_FROM_EMAIL: 'alerts@example.test', + SUPPORT_EMAIL: 'support@example.test', + }); + + try { + await runWithRequestContext({ requestId: 'request-smtp-1' }, () => + (service as any).sendManualReviewAlert(21, 20), + ); + + expect(sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + 'x-request-id': 'request-smtp-1', + 'x-correlation-id': 'request-smtp-1', + }, + }), + ); + } finally { + createTransport.mockRestore(); + } + }); + }); + describe('admin recovery actions', () => { it('forceReconcileNow reconciles a payment immediately, bypassing the due schedule', async () => { const now = new Date(); diff --git a/backend/src/payments/reconciliation.service.ts b/backend/src/payments/reconciliation.service.ts index 56a6dc23..15cea098 100644 --- a/backend/src/payments/reconciliation.service.ts +++ b/backend/src/payments/reconciliation.service.ts @@ -20,7 +20,10 @@ import { PaymentsGateway } from './payments.gateway'; import { PaymentRailRegistry } from './payment-rail-registry'; import { withTimeout } from './utils/with-timeout'; import { MetricsService } from '../common/metrics.service'; -import { withRequestId } from '../common/request-context'; +import { + outboundRequestHeaders, + withRequestId, +} from '../common/request-context'; import { createTransport } from 'nodemailer'; import Handlebars from 'handlebars'; @@ -454,6 +457,13 @@ export class ReconciliationService { : fallback; } + /** + * Sends the manual-review alert over SMTP. Nodemailer accepts custom + * message headers, so the active request id is attached as both correlation + * names accepted by this backend's request middleware. Providers that do + * not expose a custom-header API cannot receive this metadata; the message + * remains useful without it, but no placeholder id is invented. + */ private async sendManualReviewAlert( depth: number, threshold: number, @@ -482,6 +492,7 @@ export class ReconciliationService { from, to: supportEmail, subject: `${this.config.get('COMPANY_NAME', 'ManageHub')} manual review queue alert`, + headers: outboundRequestHeaders(), text: template({ company: this.config.get('COMPANY_NAME', 'ManageHub'), depth, diff --git a/backend/src/payments/soroban/soroban-rpc-client.spec.ts b/backend/src/payments/soroban/soroban-rpc-client.spec.ts index 82f65b7b..c3d01908 100644 --- a/backend/src/payments/soroban/soroban-rpc-client.spec.ts +++ b/backend/src/payments/soroban/soroban-rpc-client.spec.ts @@ -1,4 +1,8 @@ -import { SorobanRpcClient, SorobanRpcServerLike } from './soroban-rpc-client'; +import { + SorobanRpcClient, + SorobanRpcServerLike, + SorobanRpcTimeoutError, +} from './soroban-rpc-client'; function makeServer(overrides: Partial = {}): SorobanRpcServerLike { return { @@ -70,4 +74,44 @@ describe('SorobanRpcClient', () => { 'down-2', ); }); + + it('abandons a hung endpoint after the timeout and fails over', async () => { + const primary = makeServer({ + getAccount: jest.fn(() => new Promise(() => {})), + }); + const secondary = makeServer({ + getAccount: jest.fn().mockResolvedValue({ id: 'account-from-secondary' }), + }); + const client = new SorobanRpcClient([primary, secondary], 5); + + await expect(client.getAccount('GADDR')).resolves.toEqual({ + id: 'account-from-secondary', + }); + expect(primary.getAccount).toHaveBeenCalledTimes(2); + expect(secondary.getAccount).toHaveBeenCalledWith('GADDR'); + }); + + it('leaves a response that arrives within the budget unaffected', async () => { + const primary = makeServer({ + getTransaction: jest.fn().mockResolvedValue({ status: 'SUCCESS' }), + }); + const secondary = makeServer(); + const client = new SorobanRpcClient([primary, secondary], 50); + + await expect(client.getTransaction('hash-1')).resolves.toEqual({ + status: 'SUCCESS', + }); + expect(secondary.getTransaction).not.toHaveBeenCalled(); + }); + + it('surfaces the timeout error when every endpoint hangs', async () => { + const server = makeServer({ + simulateTransaction: jest.fn(() => new Promise(() => {})), + }); + const client = new SorobanRpcClient([server], 5); + + await expect(client.simulateTransaction({} as any)).rejects.toBeInstanceOf( + SorobanRpcTimeoutError, + ); + }); }); diff --git a/backend/src/payments/soroban/soroban-rpc-client.ts b/backend/src/payments/soroban/soroban-rpc-client.ts index 616c171f..328b68b1 100644 --- a/backend/src/payments/soroban/soroban-rpc-client.ts +++ b/backend/src/payments/soroban/soroban-rpc-client.ts @@ -1,6 +1,11 @@ import { Logger } from '@nestjs/common'; import { withSpan } from '../../common/tracing'; import { SorobanRpc } from '@stellar/stellar-sdk'; +import { + outboundRequestHeaders, + withRequestId, +} from '../../common/request-context'; +import { TimeoutError, withTimeout } from '../utils/with-timeout'; import { retryWithBackoff } from '../utils/retry-with-backoff'; /** @@ -16,28 +21,153 @@ export interface SorobanRpcServerLike { getTransaction(hash: string): Promise; } +interface SdkAxiosClientLike { + interceptors?: { + request?: { + use: (handler: (config: any) => any) => unknown; + }; + }; +} + +let requestIdInterceptorInstalled = false; + +export const DEFAULT_SOROBAN_RPC_TIMEOUT_MS = 10_000; + +/** + * SDK 11.3 accepts static `headers` on `SorobanRpc.Server` and exports the + * Axios client used by its JSON-RPC transport. The servers in this module are + * created during application bootstrap, before an HTTP request context can + * exist, so constructor headers alone cannot carry an AsyncLocalStorage id. + * A single interceptor on that exported client supplies the current context + * at actual request time instead. If a future SDK removes that export, the + * type guard below simply leaves the SDK untouched; Soroban client logs still + * carry the id through `withRequestId`, while third-party transport-level + * propagation is explicitly unavailable rather than guessed. + */ +function installRequestIdHeaderInterceptor(): void { + if (requestIdInterceptorInstalled) { + return; + } + + const sdkAxiosClient = ( + SorobanRpc as unknown as { AxiosClient?: SdkAxiosClientLike } + ).AxiosClient; + const requestInterceptor = sdkAxiosClient?.interceptors?.request; + if (!requestInterceptor || typeof requestInterceptor.use !== 'function') { + return; + } + + try { + requestInterceptor.use((config: any) => { + const headers = outboundRequestHeaders(); + if (Object.keys(headers).length === 0) { + return config; + } + config.headers = config.headers ?? {}; + Object.assign(config.headers, headers); + return config; + }); + requestIdInterceptorInstalled = true; + } catch { + return; + } +} + +/** + * Creates one SDK RPC server and attaches the request-correlation mechanism + * supported by the pinned SDK. The `headers` option is retained for SDK + * versions that apply constructor headers directly; the interceptor above is + * what makes those headers reflect the active request rather than bootstrap + * state. Neither path emits an empty or placeholder id. + */ export function createSorobanRpcServer(url: string): SorobanRpcServerLike { - return new SorobanRpc.Server(url, { + installRequestIdHeaderInterceptor(); + // Assigned to a variable before the call so TypeScript's excess-property + // check never runs: the correlation header is a forward-compatible extra + // that older SDK typings simply do not declare, and a literal inline here + // would fail the build if `headers` is absent from the pinned options type. + const options = { allowHttp: url.startsWith('http://'), - }) as unknown as SorobanRpcServerLike; + headers: outboundRequestHeaders(), + } as ConstructorParameters[1]; + return new SorobanRpc.Server(url, options) as unknown as SorobanRpcServerLike; } const RETRYABLE_SEND_STATUSES = new Set(['TRY_AGAIN_LATER']); +/** A transient endpoint failure that may be retried or failed over. */ +export class TransientRpcError extends Error {} + +/** + * A bounded RPC call is indeterminate rather than a terminal provider error. + * The endpoint may still complete the request after our timer fires, so it is + * treated like a connection outage: retry within that endpoint's budget and + * then try the next configured endpoint. + */ +export class SorobanRpcTimeoutError extends TransientRpcError { + constructor( + readonly endpointIndex: number, + readonly endpointCount: number, + readonly timeoutMs: number, + ) { + super( + `Soroban RPC endpoint ${endpointIndex}/${endpointCount} timed out after ${timeoutMs}ms`, + ); + this.name = 'SorobanRpcTimeoutError'; + } +} + +/** + * Parses the constructor/environment timeout without accepting fractional or + * unsafe millisecond values. `undefined`, `null`, and an empty environment + * value select the documented default; malformed explicit values fail fast + * during module construction rather than creating an unbounded client. + */ +export function parseSorobanRpcTimeoutMs(value: unknown): number { + if ( + value === undefined || + value === null || + (typeof value === 'string' && value.trim() === '') + ) { + return DEFAULT_SOROBAN_RPC_TIMEOUT_MS; + } + + let parsed: number; + if (typeof value === 'number') { + parsed = value; + } else if (typeof value === 'string') { + parsed = Number(value); + } else { + parsed = Number.NaN; + } + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error( + `SOROBAN_RPC_TIMEOUT_MS must be a positive safe integer, got ${String(value)}`, + ); + } + return parsed; +} + /** * 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. + * with retry/backoff (reusing #1572's utility) on transient failures within + * an endpoint before failing over to the next one. Every call is wrapped in + * the shared timeout utility, so a hung endpoint cannot hold a request + * forever. A timed-out endpoint is logged as transient and is retried and + * failed over exactly like a connection error. */ export class SorobanRpcClient { private readonly logger = new Logger(SorobanRpcClient.name); + private readonly timeoutMs: number; - constructor(private readonly servers: SorobanRpcServerLike[]) { + constructor( + private readonly servers: SorobanRpcServerLike[], + timeoutMs: number = DEFAULT_SOROBAN_RPC_TIMEOUT_MS, + ) { if (servers.length === 0) { throw new Error('SorobanRpcClient needs at least one RPC endpoint'); } + this.timeoutMs = parseSorobanRpcTimeoutMs(timeoutMs); } getAccount(publicKey: string): Promise { @@ -78,22 +208,50 @@ export class SorobanRpcClient { 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, - }); + return await retryWithBackoff( + () => this.callWithTimeout(call, server, index), + { + 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)), + withRequestId( + `Soroban RPC endpoint ${index + 1}/${this.servers.length} failed: ` + + (error instanceof Error ? error.message : String(error)), + ), ); } } throw lastError; } -} -export class TransientRpcError extends Error {} + private async callWithTimeout( + call: (server: SorobanRpcServerLike) => Promise, + server: SorobanRpcServerLike, + index: number, + ): Promise { + try { + return await withTimeout(call(server), this.timeoutMs); + } catch (error) { + if (error instanceof TimeoutError) { + const timeoutError = new SorobanRpcTimeoutError( + index + 1, + this.servers.length, + this.timeoutMs, + ); + this.logger.warn( + withRequestId( + `${timeoutError.message}; treating the endpoint as transient and failing over`, + ), + ); + throw timeoutError; + } + throw error; + } + } +} diff --git a/backend/src/payments/utils/minor-units.spec.ts b/backend/src/payments/utils/minor-units.spec.ts new file mode 100644 index 00000000..bdacefae --- /dev/null +++ b/backend/src/payments/utils/minor-units.spec.ts @@ -0,0 +1,164 @@ +import { + apportionByBasisPoints, + assertMinorUnits, + MinorUnitsError, + multiplyMinorUnits, + TOTAL_BASIS_POINTS, +} from './minor-units'; + +const sum = (values: readonly number[]): number => + values.reduce((total, value) => total + value, 0); + +function seededRandom(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state; + }; +} + +describe('assertMinorUnits', () => { + it('accepts zero and safe non-negative integers', () => { + expect(assertMinorUnits(0, 'amount')).toBe(0); + expect(assertMinorUnits(Number.MAX_SAFE_INTEGER, 'amount')).toBe( + Number.MAX_SAFE_INTEGER, + ); + }); + + it('rejects fractional, negative, non-finite, and unsafe values', () => { + const invalidValues: Array<[string, number]> = [ + ['a fractional value', 1.5], + ['a negative value', -1], + ['NaN', Number.NaN], + ['infinity', Number.POSITIVE_INFINITY], + ['an unsafe integer', Number.MAX_SAFE_INTEGER + 1], + ]; + + for (const [, value] of invalidValues) { + expect(() => assertMinorUnits(value, 'amount')).toThrow(MinorUnitsError); + expect(() => assertMinorUnits(value, 'amount')).toThrow( + /amount must be a non-negative safe integer/, + ); + } + }); +}); + +describe('multiplyMinorUnits', () => { + it('multiplies by an integer basis-point ratio and floors the result', () => { + expect(multiplyMinorUnits(1001, 2500, TOTAL_BASIS_POINTS)).toBe(250); + expect(multiplyMinorUnits(1, 1, TOTAL_BASIS_POINTS)).toBe(0); + expect(multiplyMinorUnits(1, TOTAL_BASIS_POINTS, TOTAL_BASIS_POINTS)).toBe( + 1, + ); + }); + + it('rejects a scaling product outside the safe integer range', () => { + expect(() => + multiplyMinorUnits( + Number.MAX_SAFE_INTEGER, + TOTAL_BASIS_POINTS - 1, + TOTAL_BASIS_POINTS, + ), + ).toThrow(MinorUnitsError); + expect(() => + multiplyMinorUnits( + Number.MAX_SAFE_INTEGER, + TOTAL_BASIS_POINTS, + TOTAL_BASIS_POINTS, + ), + ).toThrow(MinorUnitsError); + }); + + it('rejects invalid ratio denominators and shares', () => { + expect(() => multiplyMinorUnits(100, 1, 0)).toThrow(/greater than zero/); + expect(() => multiplyMinorUnits(100, 10001, 10000)).toThrow( + /cannot exceed/, + ); + expect(() => multiplyMinorUnits(100.5, 1, 10000)).toThrow( + MinorUnitsError, + ); + expect(() => multiplyMinorUnits(100, -1, 10000)).toThrow(MinorUnitsError); + }); +}); + +describe('apportionByBasisPoints', () => { + it('returns exact proportional allocations for a divisible amount', () => { + expect(apportionByBasisPoints(10_000, [1500, 8000, 500])).toEqual([ + 1500, 8000, 500, + ]); + }); + + it('returns zeroes for a zero amount', () => { + expect(apportionByBasisPoints(0, [3333, 3333, 3334])).toEqual([0, 0, 0]); + }); + + it('assigns a single minor unit to exactly one recipient', () => { + const allocations = apportionByBasisPoints(1, [1000, 1000, 1000, 1000]); + expect(sum(allocations)).toBe(1); + expect(allocations.filter((amount) => amount === 1)).toHaveLength(1); + expect(allocations.every((amount) => Number.isInteger(amount))).toBe(true); + }); + + it('keeps a maximum safely scalable amount exact', () => { + const amount = Math.floor( + Number.MAX_SAFE_INTEGER / TOTAL_BASIS_POINTS, + ); + const allocations = apportionByBasisPoints(amount, [1, 9999]); + expect(sum(allocations)).toBe(amount); + expect(allocations.every((value) => Number.isInteger(value))).toBe(true); + }); + + it('rejects fractional, non-finite, negative, and unsafe inputs', () => { + expect(() => apportionByBasisPoints(1.5, [10000])).toThrow( + MinorUnitsError, + ); + expect(() => apportionByBasisPoints(1, [10000.5])).toThrow( + MinorUnitsError, + ); + expect(() => apportionByBasisPoints(Number.NaN, [10000])).toThrow( + MinorUnitsError, + ); + expect(() => apportionByBasisPoints(1, [-1, 10001])).toThrow( + MinorUnitsError, + ); + expect(() => + apportionByBasisPoints(Number.MAX_SAFE_INTEGER, [5000, 5000]), + ).toThrow(/too large to scale exactly/); + }); + + it('rejects an empty or incomplete basis-point policy', () => { + expect(() => apportionByBasisPoints(100, [])).toThrow(/at least one/); + expect(() => apportionByBasisPoints(100, [5000, 4000])).toThrow( + /must sum to 10000/, + ); + }); + + it('does not accumulate rounding drift over a large seeded range', () => { + const random = seededRandom(0x1785_2026); + const maximumAmount = Math.floor( + Number.MAX_SAFE_INTEGER / TOTAL_BASIS_POINTS, + ); + + for (let iteration = 0; iteration < 2_000; iteration += 1) { + const amount = random() % (maximumAmount + 1); + const recipientCount = 2 + (random() % 8); + const basisPoints: number[] = []; + let remaining = TOTAL_BASIS_POINTS; + + for (let index = 0; index < recipientCount - 1; index += 1) { + const share = + index === recipientCount - 2 + ? remaining + : random() % (remaining + 1); + basisPoints.push(share); + remaining -= share; + } + basisPoints.push(remaining); + + const allocations = apportionByBasisPoints(amount, basisPoints); + expect(sum(allocations)).toBe(amount); + expect(allocations.every((value) => Number.isInteger(value))).toBe(true); + expect(allocations.every((value) => value >= 0)).toBe(true); + } + }); +}); diff --git a/backend/src/payments/utils/minor-units.ts b/backend/src/payments/utils/minor-units.ts new file mode 100644 index 00000000..02e9c411 --- /dev/null +++ b/backend/src/payments/utils/minor-units.ts @@ -0,0 +1,179 @@ +/** + * Strict integer arithmetic for payment amounts expressed in minor units. + * + * Currency must never pass through a fractional representation. JavaScript + * numbers can represent safe integers exactly, but neither a decimal amount + * nor a multiplication whose product exceeds `Number.MAX_SAFE_INTEGER` is a + * safe currency value. These helpers reject those cases before arithmetic + * begins and use only integer operations for allocation. + */ +export const TOTAL_BASIS_POINTS = 10_000; + +/** Typed failure for invalid currency integers or an unsafe scaling operation. */ +export class MinorUnitsError extends Error { + constructor(message: string) { + super(message); + this.name = 'MinorUnitsError'; + } +} + +/** + * Validates one non-negative, safe integer used as a currency or ratio value. + * + * `Number.isSafeInteger` deliberately rejects `NaN`, infinities, fractions, + * and values beyond the exactly representable integer range. The explicit + * `typeof` check also makes the boundary safe for JSON or database values + * that reached this helper without TypeScript's compile-time guarantee. + * The input is returned so callers can use validation and normalization in a + * single expression. + */ +export function assertMinorUnits(value: number, label = 'value'): number { + if ( + typeof value !== 'number' || + !Number.isSafeInteger(value) || + value < 0 + ) { + throw new MinorUnitsError( + `${label} must be a non-negative safe integer, got ${String(value)}`, + ); + } + return value; +} + +/** + * Multiplies a minor-unit amount by an integer basis-point ratio and floors + * the result to a whole minor unit. + * + * `totalBasisPoints` is explicit so this primitive can be used for both the + * conventional 10,000 basis points in 100% and a caller's other integer + * denominator. The scaled product is guarded before division; accepting an + * unsafe product would make the quotient and remainder unreliable. A zero + * ratio is valid, but a zero denominator and a share larger than its + * denominator are rejected as malformed currency policy. + */ +export function multiplyMinorUnits( + amount: number, + basisPoints: number, + totalBasisPoints: number, +): number { + assertMinorUnits(amount, 'amount'); + assertMinorUnits(basisPoints, 'basisPoints'); + assertMinorUnits(totalBasisPoints, 'totalBasisPoints'); + + if (totalBasisPoints === 0) { + throw new MinorUnitsError('totalBasisPoints must be greater than zero'); + } + if (basisPoints > totalBasisPoints) { + throw new MinorUnitsError( + `basisPoints (${basisPoints}) cannot exceed totalBasisPoints (${totalBasisPoints})`, + ); + } + if (basisPoints === 0) { + return 0; + } + + const scaled = amount * basisPoints; + if (!Number.isSafeInteger(scaled)) { + throw new MinorUnitsError( + `amount ${amount} cannot be scaled exactly by ${basisPoints}/${totalBasisPoints}`, + ); + } + + return Math.floor(scaled / totalBasisPoints); +} + +interface AllocationEntry { + index: number; + amount: number; + remainder: number; +} + +/** + * Splits an amount across non-negative basis-point shares whose sum is + * exactly 10,000 (100%). The result is in input order. + * + * Every share first receives `floor(amount * share / 10000)`. Any leftover + * minor units are then assigned by the largest-remainder rule, with input + * position as the deterministic tie-breaker. This is the same conservation + * property required for a balanced ledger: the returned integers always sum + * exactly to `amount`, including when the amount is one unit or a recipient + * would otherwise round to zero. The amount is rejected when the scaled + * integer domain could exceed `Number.MAX_SAFE_INTEGER`, rather than + * approximating a value that cannot be represented exactly. + */ +export function apportionByBasisPoints( + amount: number, + basisPoints: readonly number[], +): number[] { + assertMinorUnits(amount, 'amount'); + if (!Array.isArray(basisPoints) || basisPoints.length === 0) { + throw new MinorUnitsError('basisPoints must contain at least one share'); + } + + let totalBasisPoints = 0; + for (const [index, share] of basisPoints.entries()) { + assertMinorUnits(share, `basisPoints[${index}]`); + const nextTotal = totalBasisPoints + share; + if (!Number.isSafeInteger(nextTotal)) { + throw new MinorUnitsError( + 'basisPoints total exceeds the safe integer range', + ); + } + totalBasisPoints = nextTotal; + } + if (totalBasisPoints !== TOTAL_BASIS_POINTS) { + throw new MinorUnitsError( + `basisPoints must sum to ${TOTAL_BASIS_POINTS}, got ${totalBasisPoints}`, + ); + } + const maximumScalableAmount = Math.floor( + Number.MAX_SAFE_INTEGER / TOTAL_BASIS_POINTS, + ); + if (amount > maximumScalableAmount) { + throw new MinorUnitsError( + `amount ${amount} is too large to scale exactly by ${TOTAL_BASIS_POINTS}`, + ); + } + + const entries: AllocationEntry[] = basisPoints.map((share, index) => { + const scaled = amount * share; + if (!Number.isSafeInteger(scaled)) { + throw new MinorUnitsError( + `amount ${amount} cannot be scaled exactly for basisPoints[${index}]`, + ); + } + return { + index, + amount: Math.floor(scaled / TOTAL_BASIS_POINTS), + remainder: scaled % TOTAL_BASIS_POINTS, + }; + }); + + let leftover = + amount - + entries.reduce((sum, entry) => sum + entry.amount, 0); + const byRemainder = [...entries].sort( + (left, right) => + right.remainder - left.remainder || left.index - right.index, + ); + + for (const entry of byRemainder) { + if (leftover <= 0) { + break; + } + entry.amount += 1; + leftover -= 1; + } + + if (leftover !== 0) { + throw new MinorUnitsError( + `Unable to apportion ${amount} minor units exactly`, + ); + } + + const result = new Array(entries.length); + for (const entry of entries) { + result[entry.index] = entry.amount; + } + return result; +}