Skip to content

Multi-chain buying

A PipRailClient is bound to one chain and one wallet — and that’s deliberate: keys are chain-family-specific, so an EVM private key can’t sign a Solana transaction and an XRPL seed can’t sign on Base (pass the wrong shape and you get a WrongFamilyError). So how does a buyer pay a merchant that demands whatever chain it happens to offer — Base today, Solana tomorrow, a multi-rail 402 that lists both?

You give the buyer one wallet per chain and let MultiChainPayer route. It builds one single-chain client per chain, surveys every chain you hold a key for when it hits a 402, and pays the first funded chain (in the order you listed them) that can actually settle — through each client’s own spend policy, approval hook, retries, and replay-protection. No price oracle, no backend, no custody — it’s a thin, chain-agnostic composition over primitives you can already call by hand.

┌──────────────────────────────────────────────┐
one 402 ───▶ │ MultiChainPayer │
│ ├─ PipRailClient(base) ← { key } │
│ ├─ PipRailClient(solana) ← { key } │
│ └─ PipRailClient(xrpl) ← { key } │
└──────────────────────────────────────────────┘
│ plan on every client in parallel
merge rails → pick the FIRST funded chain that can settle
│ (within that chain: cheapest-gas rail)
pay on its owning client (its policy / approval / retry / replay)

One payer, many single-chain clients, one shared budget. Each client is exactly the client you’d build yourself — MultiChainPayer only decides which one handles a given 402 and aggregates their reads. It adds no new payment logic.

The ergonomic path: a { chain → wallet } map plus one shared policy.

import { MultiChainPayer } from '@piprail/sdk'
const payer = MultiChainPayer.fromWallets({
wallets: {
base: { key: process.env.EVM_KEY }, // one EVM key works on every EVM chain
polygon: { key: process.env.EVM_KEY },
solana: { key: process.env.SOLANA_KEY }, // every chain takes the same field: { key }
xrpl: { key: process.env.XRPL_SEED },
},
// ONE spend policy guards every chain — the buyer can never exceed it, whatever chain it pays on.
policy: { maxAmount: '1.00', maxTotal: '10.00', tokens: ['USDC', 'USDT'] },
})
// Read-only: survey every chain, ranked payable-first. Moves nothing.
const plan = await payer.planPayment('https://api.example.com/report')
// Pay: settles on the first funded chain that can afford it, on its own chain.
const res = await payer.get('https://api.example.com/report')

Every wallet value is the same shape — { key } (the chain’s secret as a string) — with NEAR also taking an accountId. The key holds that chain’s standard secret (0x-hex, base58, an s… seed, a 24-word mnemonic, …); see Wallets by family for the per-chain format:

FamiliesWallet shape
Every chain (EVM, solana, ton, tron, sui, aptos, algorand, stellar, xrpl){ key }
near{ accountId, key }

One key per family — this map is just how a single buyer carries the keys for every chain it’s willing to pay on. (A single EVM key is reused across every EVM chain; it’s the same address on all of them.)

The iteration order of wallets is your chain preference: when more than one chain can settle, the first one you listed wins. There’s no oracle to compare gas across different native coins, so the SDK never guesses which is “cheaper” in dollars — you decide by ordering. List your preferred chain first, or fund only it.

wallets is the only required field. Everything else is one setting that fans out to every chain’s client identically — so you configure spend caps, the gasless rail, approval, and retries once and they apply across the whole bundle:

OptionTypeDefaultWhat it does
wallets (required)Record<string, WalletInput>One wallet per chain, keyed by chain selector. Iteration order = your chain preference (first that can settle wins).
policyPaymentPolicynone (unbounded)One spend policy applied to every chain’s client — the buyer can never exceed it whatever chain it pays on. Each client still keeps its own per-(network, asset) ledger (no cross-token sum — there’s no price oracle).
schemesPaymentScheme[]['onchain-proof']Which rails every client may settle. Add 'exact' to enable the gasless rail on every chain that supports it.
rpcUrlsRecord<string, string>public presetsPer-chain RPC overrides, keyed by the same chain selector as wallets. Pass your own for anything serious — the public presets are rate-limited.
onBeforePay(quote) => boolean | Promise<boolean>always allowFinal approval hook on every client; fires after the policy passes and before any send. Return false (or throw) to veto.
onEvent(event) => voidnoneObservability hook on every client — receives each PipRailEvent (quote, pay, verify, retry…).
maxPaymentRetriesnumber3Retry budget for the post-broadcast leg, per client.
retryTimeoutMsnumber30000Timeout (ms) for the retry leg, per client.

A chain you only want to read from (quote/estimateCost/discover, never pay) just needs its wallet omitted — but fromWallets always builds a paying client per key, so for a read-only chain use the array constructor with a wallet-less PipRailClient.

For a custom EVM chain configured by a viem Chain object, build the client yourself and use the array form: new MultiChainPayer([clientA, clientB, …]) (order = preference).

fromWallets applies one shared policy to every chain. When you want a different policy per chain — generous caps on your main rail, a tight leash on a hot key — build each PipRailClient yourself (each with its own policy, onBeforePay, schemes, …) and pass them to the array constructor. MultiChainPayer stores them verbatim; every payment runs through its owning client’s own policy and hooks:

import { PipRailClient, MultiChainPayer } from '@piprail/sdk'
const payer = new MultiChainPayer([
// Base — your main rail, generous caps
new PipRailClient({
chain: 'base',
wallet: { key: process.env.EVM_KEY! },
policy: { maxAmount: '5.00', maxTotal: '100.00', tokens: ['USDC'] },
}),
// Solana — a hot key on a tight leash
new PipRailClient({
chain: 'solana',
wallet: { key: process.env.SOLANA_KEY! },
policy: { maxAmount: '0.25', maxTotal: '5.00', tokens: ['USDC'] },
}),
])
// Array ORDER is still your chain preference; spent()/budget() still aggregate across both.

For a per-token cap within a single chain (USDC ≤ 5 but native ≤ 0.001, say), use that client’s onBeforePay hook.

Policy refusals reroute; onBeforePay vetoes don’t

Section titled “Policy refusals reroute; onBeforePay vetoes don’t”

A subtlety worth internalising, because the two gates behave differently when more than one chain could pay:

  • A per-chain policy refusal is a plan signal. planPayment evaluates each client’s policy, so a chain whose policy refuses surfaces as state: 'blocked' with an OUTSIDE_POLICY blocker — not payable — and routing simply skips it and settles on the next chain that can. (So a chain you cap out, or list last, won’t be auto-charged while another rail is viable.)
  • An onBeforePay veto is a pay-time gate on the chosen chain only. best is chosen from the policy-aware plan first, then the owning client runs its hook; if it returns false, the payment is declined outright (PaymentDeclinedError, reasonCode: 'APPROVAL') — the payer does not silently fall back to another chain.

Rule of thumb: use policy (maxAmount/tokens/…) for which chain may pay and how much (it’s routing-aware), and onBeforePay for a last-mile yes/no on the rail that was actually chosen.

This is the one thing to internalise, because it’s not “cheapest across chains”:

  1. Plan every funded chain in parallel. Each client reads its own balance, gas, and recipient-readiness for the rails the 402 offers on its chain, and ranks them payable-first / cheapest-gas-first within that chain (its own native coin — a valid comparison).
  2. Merge across chains by state only, preserving your listed order. Payable rails come before unknown, unknown before blocked — but rails are never fee-compared across chains. Base-unit gas magnitudes aren’t comparable between different native coins (0.0001 ETH vs 0.5 SOL vs 2 XRP say nothing about dollar cost without an oracle, and there is no oracle).
  3. best = the first chain you listed that can settle. Within that one chain, the cheapest-gas rail.

So with wallets: { base, polygon, solana }, a 402 payable on all three settles on Base — not because Base is cheapest, but because you listed it first. Reorder to { polygon, base, solana } and Polygon wins. This keeps routing predictable and oracle-free, in line with PipRail’s no-backend design.

When nothing can settle — the decline message

Section titled “When nothing can settle — the decline message”

If no funded chain can pay, the failure is made explicit and actionable — for an AI agent parsing fields and for a human reading the sentence. planPayment returns payable: false with a merged fundingHint that names every funded chain’s own blocker (not just the first), and fetch/get/post throw PaymentDeclinedError carrying the same sentence — before any on-chain send:

Can't settle on base: top up 0.05 USDC (to pay 0.05 USDC). · Can't settle on polygon: top
up 0.05 USDC and add ~0.018 POL for gas (to pay 0.05 USDC). · Can't settle on arbitrum: add
~0.0000013 ETH for gas (to pay 0.05 USDC).

Each rail also carries a machine-readable blockers array so an agent can branch programmatically instead of parsing prose:

BlockerMeaningThe fix
INSUFFICIENT_TOKENNot enough of the payment tokenTop up that token on that chain (shortfall.token)
INSUFFICIENT_GASNot enough native coin for gasAdd native coin on that chain (shortfall.native)
RECIPIENT_NOT_READYThe recipient can’t receive yetA recipient-side fix (trustline / opt-in / activation) — not your balance
OUTSIDE_POLICYRefused by your spend policyRaise the cap or allow the token; the amount/asset exceeded your policy
OUTSIDE_WINDOWBudget window or session exhaustedWait for the window, raise windowTotal, or restart the session

The “noise” notes (a chain the 402 didn’t offer at all) are dropped when at least one chain is actually close, so the message stays focused on what you can actually act on. If the 402 offers only chains you don’t hold, the hint instead tells you which chains it is payable on. A single chain whose RPC is merely throttled comes back as state: 'unknown' (not a false “blocked”), so a transient hiccup never reads as “you can’t pay.”

MultiChainPayer mirrors the single-chain client, but across all your chains. It satisfies the same PayingClient interface, so anything that takes a PipRailClient takes this too.

MethodWhat it does
planPayment(url, init?)Survey every funded chain; return one merged PaymentPlan (best = first funded chain that can settle). null if the URL needs no payment. Throws only on a total outage (every chain unreachable).
canAfford(url, init?)true if any funded chain can settle it now (a free resource is trivially affordable).
quote(url, init?)The chosen chain’s quote (price + recipient + policy verdict); falls back to the first offered rail’s quote, else surfaces the same informative NoCompatibleAcceptError a single client would.
fetch / get / postPay on the chosen chain and return the unlocked response. post accepts a string/FormData/URLSearchParams/ArrayBuffer/Blob (sent as-is) or a plain object (JSON), exactly like PipRailClient.post.
discover(opts?)Find payable resources across every funded chain, deduped by URL (a network-scoped query is chain-independent, so one client answers it).
register(url, opts?)List a resource you run (goes through your first chain; pass opts.network to advertise a specific chain).
spent() / budget()Aggregated across all chains. With a shared ledger (the fromWallets path) they read that one ledger, so byDenom is the cross-token grand total and count caps span every chain as one budget (byAsset stays per-(network, asset)). With independent (array) clients they concatenate per-(network, asset) rows and sum counts. budget() reports the soonest-expiring window.
clientsThe underlying single-chain clients, in preference order, for chain-specific reads (estimateCost, per-chain budget(), discoverySigner) that don’t make sense merged.

Because it implements the same PayingClient interface a PipRailClient does, the agent toolkit (paymentTools) and the MCP server wrap it unchanged — point an LLM at one wallet or at a whole bundle and the tools are identical.

The optional exact scheme — the standard x402 rail that lets the buyer pay gaslessly (sign an EIP-3009 / Permit2 authorization; the merchant or a facilitator submits and pays the gas) — is applied to every chain in the bundle. Pass schemes once and it propagates to each chain’s client:

const payer = MultiChainPayer.fromWallets({
wallets: { base: { key: EVM_KEY }, bnb: { key: EVM_KEY } },
schemes: ['onchain-proof', 'exact'], // every chain may now settle the gasless exact rail
policy: { maxAmount: '1.00', tokens: ['USDC'] },
})

When a 402 offers both rails on a chain, the gasless exact rail is preferred within that chain (it costs the buyer no gas, so it’s the cheaper rail). The cross-chain rule is unchanged — the first funded chain you listed that can settle still wins. In the MCP server, set PIPRAIL_SCHEMES=onchain-proof,exact and it applies to every chain in PIPRAIL_CHAINS. See Gasless payments for which chains/tokens support it.

Three EVM chains funded with one key, hitting a 402 that offers USDC on each (wallets: { base, arbitrum, polygon }, in that order):

PLAN: READY
rail (network · token) state gas amount
────────────────────────────────────────────────────────────────────────
eip155:8453 · USDC payable 0.000021 ETH 0.05 USDC ◀ best
eip155:42161 · USDC payable 0.000004 ETH 0.05 USDC
eip155:137 · USDC payable 0.012 POL 0.05 USDC

All three are payable, so Base wins because it’s listed first — even though Arbitrum’s gas is numerically smaller (the SDK won’t compare ETH-gas against POL-gas without an oracle). get() then settles only that rail. To prefer Arbitrum, list it first. See the runnable examples/basics/multi-chain.

MultiChainPayer is a thin composition over two exported functions, for when you already hold an array of single-chain clients and want to skip the wrapper:

  • planAcross(clients, url, init?) — run each client’s planPayment in parallel and merge the rails into one plan, ranked payable-first in client order. Returns null only if the URL isn’t gated for any client; throws only if every client fails to reach the resource (a single chain being down just drops that chain).
  • fetchAcross(clients, url, init?) — the execution counterpart: plan across the clients, then pay on the owning client of the rail planAcross names as best. Throws PaymentDeclinedError with a merged, per-chain funding hint when no chain can settle — before any on-chain send.
import { PipRailClient, planAcross, fetchAcross } from '@piprail/sdk'
const clients = [
new PipRailClient({ chain: 'base', wallet: { key: EVM_KEY } }),
new PipRailClient({ chain: 'solana', wallet: { key: SOLANA_KEY } }),
]
const plan = await planAcross(clients, url) // read-only survey (Base preferred — listed first)
const res = await fetchAcross(clients, url) // pay the winner on its owning client

fetchAcross is best-effort at pay time: the owning client re-reads its balances and gas when it actually pays (via autoRoute), so if balances shift between plan and pay it can pick another settleable rail on the same chain, or decline — its spend policy and onBeforePay still gate whatever is paid.

  • One budget, per-chain ledgers. The shared policy applies to every chain, but each client keeps its own per-(network, asset) spend ledger — maxTotal is enforced per token (there’s no price oracle to sum USDC-on-Base against SOL). Because the cap is per (network, asset), the same token across N chains gets N independent capsmaxTotal: '20' with USDC on Base and Polygon allows 20 USDC on each. payer.spent() / payer.budget() give the merged view.
  • Caps are in the paid token’s units, not dollars. maxAmount/maxTotal are scaled by the token’s true on-chain decimals (decimal-spoof-proof) — so '1.00' is $1 for USDC/USDT (≈$1 stablecoins) but 1.0 of a native coin on a native rail ($1000s for ETH), since there’s no oracle. If you include 'native' in tokens, set the cap with that in mind — or keep tokens to stablecoins for a ~dollar cap.
  • Which chain is chosen — your order, not the cheapest. Across different native coins there’s no price oracle, so chains are not fee-compared against each other: it pays the first chain you list that can settle. Within a single chain it still prefers the cheapest-gas rail. List your preferred chain first (or fund only it).
  • Read-only chains are fine. A client with no wallet contributes nothing to paying (it can still quote/estimateCost/discoverplanPayment needs a wallet, since it reads your balance). The payer pays on whichever funded chain wins.
  • Total outage propagates; one chain down doesn’t. If a single chain’s RPC is unreachable it’s dropped from the plan; if every chain is unreachable, planPayment / canAfford throw rather than falsely reporting “free” or “affordable”.
  • No 402 → straight through. A free resource is returned verbatim; nothing moves.
  • At most one client per chain. Two clients on the same network would double-count in spent()/budget()fromWallets can’t produce that (its keys are unique chains); only the array constructor can, so don’t.

The @piprail/mcp server does this for any AI client — list several chains and give each its own key:

"env": {
"PIPRAIL_CHAINS": "base,polygon,solana",
"PIPRAIL_BASE_KEY": "0x…", "PIPRAIL_POLYGON_KEY": "0x…", "PIPRAIL_SOLANA_KEY": "<base58-secret>",
"PIPRAIL_MAX_AMOUNT": "1.00"
}

The model’s piprail_pay_request then pays whichever chain a 402 asks for — preferring the first chain you listed — under one shared budget across every chain you funded. Full setup in Configuration → pay on several chains.