The PaymentDriver architecture
Introduction
Section titled “Introduction”PipRail supports many chains through one parameter — chain: 'base' | 'solana' | … — without
shipping an allowlist. The trick is a plug-in design: the protocol layer knows nothing about any
chain, and each chain family is a self-contained driver behind a single contract. This page is
the map of that design, so you know where everything lives before you read a chain page or
add a family.
The layering
Section titled “The layering”There are three layers, and they only ever depend downward:
protocol layer index · server · client · x402 · policy · ledger · agent · discovery (chain-agnostic — ZERO viem / @solana / @ton / … imports) │ depends only on … ▼driver contract drivers/types.ts (PaymentDriver / ResolvedNetwork) ▲ implemented by …chain drivers drivers/<family>/ chains · wallet · pay · verify · indexThe protocol layer — server.ts (requirePayment / createPaymentGate),
client.ts (PipRailClient), and x402.ts (the
wire envelopes) — imports only drivers/types.ts and pure utils.
It contains zero viem and zero @solana/web3.js. A chain library is reached exclusively through
a driver.
The contract — PaymentDriver and ResolvedNetwork
Section titled “The contract — PaymentDriver and ResolvedNetwork”A PaymentDriver is tiny and stateless. The registry hands it the developer’s chain selector,
and it either binds a concrete network or declines:
interface PaymentDriver { readonly family: ChainFamily resolve(opts: ResolveOptions): ResolvedNetwork | null // null → let another try}The real surface is the ResolvedNetwork it returns — a driver bound to one network. The protocol
layer calls only these methods, identical across every family:
| Method | Side | What it does |
|---|---|---|
resolveToken / describeAsset | both | Turn a TokenInput into { asset, decimals, symbol }, and back. |
assertValidPayTo | both | Throw if payTo isn’t valid for this family. |
bindWallet / send / confirm | agent | Wrap a wallet, broadcast a payment, wait for confirmations. |
estimateCost | agent | Best-effort gas in the native coin. Powers estimateCost(). |
balanceOf / recipientReady | agent | Read-only affordability + receive-readiness. Powers planPayment(). |
verify | server | Verify a proof against accept, RPC-only, in-process. |
Identifiers cross this boundary as plain strings — CAIP-2 networks, base-unit amounts,
0x…/base58 addresses — so the protocol layer never touches a chain-native type. The one
intentional unknown is WalletHandle._native, where each driver stashes its own wallet object.
Optional methods
Section titled “Optional methods”A handful of contract methods are optional (?) and gate advanced rails per family. Omitting one
means that family simply doesn’t offer the feature — the protocol layer skips it.
| Method | Available | Enables |
|---|---|---|
payExact | EVM + EIP-3009 | Buying on a standard exact rail. |
exactDomain / settleExactSelf | EVM only | Selling a standard exact rail. |
discoverySigner | EVM today | SIWX registration on open indexes. |
Because these are optional, adding one to EVM does not trigger the “implement in all families” rule that required methods carry.
One folder per family
Section titled “One folder per family”Each chain family is a self-contained driver under drivers/<family>/, and the families mirror
each other file-for-file:
drivers/evm/ solana/ ton/ stellar/ xrpl/ tron/ near/ sui/ aptos/ algorand/ chains · wallet · pay · verify · index (in every folder)Functions are family-suffixed — payEvm / paySolana, verifyEvm / verifyStellar — so the
symmetry is visible at a glance. Adding a contract method means implementing it in all families.
The current set lives in ChainFamily:
type ChainFamily = | 'evm' | 'solana' | 'ton' | 'stellar' | 'xrpl' | 'tron' | 'sui' | 'near' | 'aptos' | 'algorand'Routing — registry.ts
Section titled “Routing — registry.ts”registry.ts is the only place the families meet. familyForChain(chain) is pure and
synchronous: it reads the chain value and decides a family — string prefixes ('solana',
'stellar', …) route to non-EVM families, and everything else (a name, a viem Chain, or
{ id, rpcUrl }) is EVM.
familyForChain('base') // 'evm'familyForChain('solana') // 'solana'familyForChain({ id: 8453 }) // 'evm'resolveNetwork(opts) then looks up the registered driver for that family and asks it to bind the
network. Drivers register themselves with registerDriver(driver) — the registry never imports a
driver itself.
Lazy auto-mount — index.ts
Section titled “Lazy auto-mount — index.ts”EVM is registered eagerly because viem is a hard peer dependency that’s always present. Every
other family loads itself the first time you name its chain — there is no enableSolana()
step. Naming chain: 'solana' just works, exactly like chain: 'base':
// mounts the Solana driver on first use — no enableSolana() neededrequirePayment({ chain: 'solana', token: 'USDC', amount: '0.10', payTo: 'YourSolanaAddr' })drivers/index.ts holds a loader map keyed by family. Each loader does one dynamic import() — a
separate code-split chunk — then calls registerDriver. So a pure-EVM consumer never pulls in
@solana/web3.js; it loads only when a Solana chain is actually used. The async resolveNetwork
that the gate and client use calls ensureDriver(family) first, which auto-imports and caches.
Adding a family
Section titled “Adding a family”Adding a chain family is a fixed shape, and the registry is built for it:
- Implement the
PaymentDriver+ResolvedNetworkcontract underdrivers/<family>/, mirroring thechains · wallet · pay · verify · indexfiles. - Add one entry to the loader map in
drivers/index.ts— its dynamicimport()+registerDriver.
That’s the whole wiring. The protocol layer needs no change, because it only ever spoke to the contract. The full procedure — verifying token addresses on-chain, the test contract, and shipping the logo on piprail.com — is the Driver SPI reference.