Skip to content

Route planner

The planner (@corridor/routing) turns a request into an ordered list of legs. It is pure: no I/O, fully unit-tested, and the only place a route is decided.

Nodes are positions (venue + asset). Edges are legs Corridor can execute, each with a cost and, where relevant, the environments it exists in.

flowchart LR
EURC["stellar · EURC"] -->|stellar.pathPayment| HUB(("stellar · USDC · HUB"))
MGUSD["stellar · MGUSD"] -->|stellar.pathPayment| HUB
HUB -->|stellar.pathPayment| EURC
F["partner_fiat · KES XOF TZS GHS ZAR EGP UGX USD EUR GBP CAD"] -->|partner.collect| HUB
HUB -->|partner.payout| F2["partner_fiat · 31 currencies"]
BASE["base · USDC"] -->|cctp.transfer, mainnet| HUB
SOL["solana · USDC"] -->|cctp.transfer, mainnet| HUB
HUB -->|cctp.transfer, mainnet| SOL
HUB -.->|bridge.simulated, testnet| SOL
SOL -->|partner.payout| NGN["partner_fiat · NGN"]
Leg kind From → to Environments
partner.payout stellar/USDC → partner_fiat/{31 currencies} both — the anchor is on the hub, so there is nothing in between
partner.collect partner_fiat/{11 currencies} → stellar/USDC both
stellar.pathPayment stellar/{EURC, MGUSD} ⇄ stellar/USDC both — on-ledger FX over Stellar’s own order books; declared, not built
cctp.transfer stellar/USDC ⇄ base/USDC, solana/USDC mainnet — declared, not built
bridge.simulated stellar/USDC ⇄ solana/USDC testnet; the Stellar side is real
partner.payout solana/USDC → partner_fiat/NGN both — the one currency whose licensed partner settles off Stellar
partner.collect partner_fiat/NGN → solana/USDC both

OFF_STELLAR_PAYOUT_CURRENCIES is the single list that decides which currencies need a hop off the hub. Today it is ["NGN"].

planRoute({
corridor: "EUR-KES",
purpose: "payout",
privacy: "public",
source: { venue: "stellar", asset: "USDC" },
destination: { venue: "partner_fiat", asset: "KES" },
env: "testnet",
});
// ledger.reserve → partner.payout

Validate

Payouts must be funded from the hub; collections must land on it and are always public. A shielded payout is refused outright, because SHIELDED_VENUES is empty — “no shielded venue is available: the confidential lane is not live yet” (ADR 0005).

Search

Cheapest path (Dijkstra) over the edges available in the requested environment. Graphs are small, so a linear scan is fine.

Bracket

Payouts start with ledger.reserve, which moves the amount into the customer’s hold; collections end with ledger.credit, which credits the customer’s hub balance.

Scope references

Each step gets a reference scope (payout, batch or item), so a future batch can use one batch reference on its funding legs and an item reference per recipient.

Flow Testnet Mainnet
EUR → KES payout reserve → partner.payout reserve → partner.payout
Funded in EURC reserve → stellar.pathPayment (EURC → USDC) → partner.payout — plans, does not run same
KES collection partner.collect → ledger.credit same
EUR → NGN payout reserve → bridge.simulated (Stellar → Solana) → partner.payout reserve → cctp.transfer (Stellar → Solana) → partner.payout
NGN collection partner.collect → bridge.simulated (Solana → Stellar) → ledger.credit partner.collect → cctp.transfer → ledger.credit
packages/routing/src/routing.test.ts
expect(() => planRoute({ ...req, source: { venue: "solana", asset: "USDC" } }))
.toThrow(NoRouteError);

Eleven tests cover hub enforcement, every route shape above, the shielded refusal and the off-Stellar list. See ADR 0002.