Skip to content

The PaymentDriver architecture

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.

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 · index

The 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:

MethodSideWhat it does
resolveToken / describeAssetbothTurn a TokenInput into { asset, decimals, symbol }, and back.
assertValidPayTobothThrow if payTo isn’t valid for this family.
bindWallet / send / confirmagentWrap a wallet, broadcast a payment, wait for confirmations.
estimateCostagentBest-effort gas in the native coin. Powers estimateCost().
balanceOf / recipientReadyagentRead-only affordability + receive-readiness. Powers planPayment().
verifyserverVerify 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.

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.

MethodAvailableEnables
payExactEVM + EIP-3009Buying on a standard exact rail.
exactDomain / settleExactSelfEVM onlySelling a standard exact rail.
discoverySignerEVM todaySIWX 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.

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'

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.

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() needed
requirePayment({ 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 chain family is a fixed shape, and the registry is built for it:

  1. Implement the PaymentDriver + ResolvedNetwork contract under drivers/<family>/, mirroring the chains · wallet · pay · verify · index files.
  2. Add one entry to the loader map in drivers/index.ts — its dynamic import() + 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.