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
resolveTokenbothTurn a TokenInput into a ResolvedToken{ asset, decimals, symbol? } for this network.
describeAssetbothThe inverse for known assets — a resolved on-chain asset id back to its trusted { symbol?, decimals }, or null when the SDK doesn’t recognise it.
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
resolveExactRail / settleExactSelf / payExactEVM + Solana + Algorand + Aptos + NEARAdvertising, buying, and selling a standard exact rail (EVM EIP-3009/Permit2; Solana SVM; Algorand / Aptos fee-payer sponsored tx; NEAR NEP-366 SignedDelegateAction relayed by a fee-payer).
resolveUptoRail / payUpto / settleUptoSelfEVM-Permit2 onlyAdvertising, buying, and selling a standard metered upto rail — the buyer signs a max, the merchant self-settles the actual via a Permit2 witness transfer.
signReceiptEVM onlyTier-2 service-delivery attestation — sign the official x402 offer-receipt EIP-712 message so a buyer can verify the resource was served.
exactDomain / exactPermit2SupportedEVM onlyThe EVM method-selection (EIP-3009 vs Permit2).
discoverySignerEVM todaySIWX registration on open indexes.

Because these are optional, adding one to a family does not trigger the “implement in all families” rule that required methods carry. resolveExactRail is the seam that lets a family add exact support (Solana was added this way) without the chain-agnostic protocol layer learning a new 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'

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.