Skip to content

Bound Soroban RPC calls, add an explicit rail failover policy, and propagate request ids - #1915

Merged
yusuftomilola merged 2 commits into
DistinctCodes:mainfrom
Maryermarh:feature/currency-integer-math-rail-failover
Sep 25, 2026
Merged

yusuftomilola merged 2 commits into
DistinctCodes:mainfrom
Maryermarh:feature/currency-integer-math-rail-failover

Conversation

@Maryermarh

Copy link
Copy Markdown
Contributor

Summary

Closes four issues assigned to this account, all in the payments module.

Soroban RPC timeout (#1784)
Every RPC call — getAccount, simulateTransaction, sendTransaction, getTransaction — is now bounded, so a hung testnet/mainnet endpoint can no longer hold a request open indefinitely. The call is wrapped in the existing withTimeout helper and the budget comes from SOROBAN_RPC_TIMEOUT_MS (default 10000), validated at construction so a malformed value fails fast instead of creating an unbounded client.

A timeout is deliberately treated as transient, not terminal: a hung endpoint is an outage, not a verdict that the payment failed, so it retries within that endpoint's budget and then fails over to the next configured endpoint exactly like a connection error. SorobanRpcTimeoutError extends TransientRpcError so it flows through the existing isRetryable predicate. The docs and the log line both say so explicitly.

Rail failover policy (#1783)
The issue's framing was accurate: the registry threw, and the request just failed. Failover is now an explicit, opt-in policy rather than an implicit behaviour.

The important constraint is that a Payment stores its rail, and both the webhook and reconciliation paths resolve the adapter from that stored value. Switching rails for a payment that already exists would send its confirmation to the wrong adapter. So failover applies at initiation only:

  • PaymentRailRegistry gains isAvailable(rail) and resolve(rail), which follows ordered PAYMENT_RAIL_FAILOVER edges (FROM=TO, comma-separated) and returns the first available rail.
  • The Payment row stores the rail that was actually used; the requested rail is retained in metadata.requestedRail, and a WARN is logged when they differ.
  • The strict get(rail) path is untouched and is still what webhook, reconciliation and refund use, so those always resolve the adapter that initiated the payment. getPaymentsService also compares the requested rail on an idempotency replay, so a replayed request is not mistaken for a different payload.
  • Cycles, duplicate mappings and unknown rail names are rejected with explicit errors rather than silently ignored.

payments/adapters/README.md documents the policy: what failover means, when it applies, how to configure it, the degraded behaviour when no fallback is available, and what is deliberately not covered.

Request ID propagation (#1782)
Added outboundRequestHeaders() as the single source of truth. It returns both header names the middleware already accepts on inbound (x-request-id and x-correlation-id), so an id survives a full round trip, and returns an empty object when no request context is active — a placeholder id is never manufactured for background work. It is applied to:

  • the manual-review SMTP alert, via nodemailer's message headers (a genuinely verifiable outbound propagation);
  • the Soroban RPC transport, through the seam the pinned SDK exposes, with a documented type-guard so a future SDK that removes it degrades to log-only correlation instead of breaking.

The JSDoc is explicit that a third-party provider which does not accept custom headers cannot be made traceable by adding fields to that object.

Currency math audit (#1785)
The honest finding, rather than a manufactured bug: payments/utils contained no currency math (only with-timeout and retry-with-backoff), and the real basis-point allocation in credits/split-allocation.ts already used guarded integer arithmetic (Math.floor/% with a MAX_SAFE_INTEGER check). So no correct code was rewritten.

Instead:

  • The audit was run across the whole backend for parseFloat, Number(), toFixed, Math.round, percentage and money arithmetic. No floating-point currency defect was found; the remaining Number(...) sites convert database integers or an enum, and toFixed/retry-jitter/TTL math are non-currency.
  • payments/utils/minor-units.ts adds a strict, integer-only primitive so future payment code has a safe, audited helper instead of reaching for float math: it rejects non-integer/negative/NaN/unsafe values with a typed error, rejects an amount that cannot be scaled exactly rather than silently losing precision, and apportions by largest remainder so allocations always sum exactly to the input.
  • credits/split-allocation.spec.ts gained regression assertions locking in the existing integer guarantee.

minor-units.ts is intentionally a separate primitive from split-allocation.ts — the latter is a configurable, tie-broken allocation with sort-order semantics and is left alone.

Configuration

Variable Default Purpose
SOROBAN_RPC_TIMEOUT_MS 10000 Per-RPC-call timeout; a timeout is transient and triggers failover
PAYMENT_RAIL_FAILOVER unset Ordered FROM=TO failover edges, initiation only

Testing

Per the task constraints, no install, build, lint, or test run was performed — the maintainer runs those manually. No new dependencies were added, so package-lock.json is unaffected.

New specs: payments/utils/minor-units.spec.ts, common/request-context.spec.ts. Extended: payment-rail-registry.spec.ts, payments.service.spec.ts, reconciliation.service.spec.ts, soroban-rpc-client.spec.ts, credits/split-allocation.spec.ts.

Reviewer notes

  • A timed-out RPC call cannot cancel the underlying HTTP request: the caller stops waiting and fails over, but a late completion on the abandoned endpoint is still possible. That is inherent to abandoning a promise and is documented in the code.
  • Rail availability means "configured and registered", not a live health probe. The adapters have no health-probe contract, and a provider error after initiation is deliberately not switched to another rail, since that could duplicate a side effect.
  • Soroban header propagation depends on a seam exposed by the pinned stellar-sdk version; the type guard means removing it degrades to log-only correlation rather than failing.

Closing issues

Closes #1785
Closes #1784
Closes #1783
Closes #1782

…currency math to integers

Soroban RPC calls are now bounded. Every getAccount, simulate, send and status read runs inside the existing withTimeout helper with a configurable SOROBAN_RPC_TIMEOUT_MS (default 10000, validated at construction). A timeout is treated as transient: it retries within that endpoint's budget and then fails over to the next endpoint exactly like a connection outage, because a hung endpoint is not a terminal provider verdict.

Rail failover is now an explicit, opt-in policy rather than an implicit failure. PaymentRailRegistry gains an availability-aware resolve() that follows ordered PAYMENT_RAIL_FAILOVER edges, and it is used at initiation only. The Payment row stores the rail that was actually used and the requested rail is retained in metadata, so webhook, reconciliation and refund lookups keep resolving the same adapter that initiated the payment. The strict get() path is unchanged for every post-initiation caller. See the new payments/adapters/README.md for the policy and its documented limits.

Request correlation is propagated to outbound calls through a single outboundRequestHeaders() helper that returns both accepted inbound header names and never emits a placeholder id. It is applied to the manual-review SMTP alert, and to the Soroban RPC transport through the SDK seam the pinned version exposes, with an explicit note where a provider cannot accept custom headers.

Currency arithmetic was audited across the backend. payments/utils contained no money math, and the existing basis-point allocation already used guarded integer arithmetic, so no correct code was rewritten. Instead payments/utils/minor-units.ts adds a strict integer-only primitive for future payment code, and regression assertions lock in the existing allocation's exactness.

Closes DistinctCodes#1785
Closes DistinctCodes#1784
Closes DistinctCodes#1783
Closes DistinctCodes#1782
@vercel

vercel Bot commented Sep 24, 2026

Copy link
Copy Markdown

@Maryermarh is attempting to deploy a commit to the naijabuz's projects Team on Vercel.

A member of the Team first needs to authorize it.

@drips-wave

drips-wave Bot commented Sep 24, 2026

Copy link
Copy Markdown

@Maryermarh Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

…ger-math-rail-failover

# Conflicts:
#	backend/.env.example
#	backend/src/payments/payments.module.ts
#	backend/src/payments/payments.service.spec.ts
#	backend/src/payments/payments.service.ts
#	backend/src/payments/reconciliation.service.spec.ts
#	backend/src/payments/reconciliation.service.ts
@yusuftomilola
yusuftomilola merged commit 55ab2fb into DistinctCodes:main Sep 25, 2026
1 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants