Skip to content

Driver SPI — bring your own family

PipRail’s protocol layer — requirePayment, createPaymentGate, PipRailClient, the wire codecs — depends on one interface and nothing else: the PaymentDriver contract in drivers/types.ts. It never imports viem, @solana/web3.js, or any chain library directly. That seam is public. You can implement the contract for a chain PipRail doesn’t ship and register it at runtime, and every protocol feature — gating, paying, planning, the agent tools — works against it unchanged.

This is the advanced escape hatch. For built-in chains you never touch it; see Payment-driver architecture for how the shipped families use the same contract.

A PaymentDriver is stateless: a family tag plus a resolve() that recognises a chain selector and binds it to one concrete network. registerDriver() adds it to the registry.

import { registerDriver, type PaymentDriver } from '@piprail/sdk'
const myDriver: PaymentDriver = {
family: 'evm', // a ChainFamily tag
resolve(opts) {
if (!recognise(opts.chain)) return null // let another driver try
return bindNetwork(opts) // a ResolvedNetwork
},
}
registerDriver(myDriver)

resolve() returns null when it doesn’t recognise the selector, so the registry can fall through to another driver. Returning a ResolvedNetwork claims it.

interface PaymentDriver {
readonly family: ChainFamily
resolve(opts: ResolveOptions): ResolvedNetwork | null
}
interface ResolveOptions {
chain: unknown // the developer-supplied `chain` selector
rpcUrl?: string
}

resolve() hands back a ResolvedNetwork — a driver bound to one network, carrying its family and resolved CAIP-2 network id. This is the full surface the gate and client call. Each method’s error behaviour is fixed by the error standard: resolution methods throw typed PipRailErrors; verify returns a result and never throws on a transient RPC read.

MethodSideWhat it does
supports(network)bothDoes this bound network handle that CAIP-2 string?
resolveToken(token)bothTurn a TokenInput into a ResolvedToken.
describeAsset(asset)bothThe SDK’s own trusted decimals/symbol for a known asset, or null. Pure, no RPC.
assertValidPayTo(payTo)bothThrow if payTo is invalid for this family. (A general family-validity check; called server-side today.)
bindWallet(wallet)payerValidate + wrap the user’s wallet into a WalletHandle.
send(wallet, accept)payerBroadcast payment; resolve to the proof ref (tx hash / signature).
confirm(ref, minConfirmations)payerWait until ref reaches confirmations; resolve a ConfirmInfo.
estimateCost(accept, opts?)payerBest-effort gas estimate in the native coin. Never throws.
balanceOf(wallet, asset)payerThe bound wallet’s token + native balance. Never throws.
recipientReady(payTo, asset)payerCan payTo receive yet? The chain’s one-time prerequisite. Never throws.
verify(ref, accept)serverVerify ref satisfies accept, RPC-only, in-process. Returns a VerifyResult.

The three required reads on the payer side — estimateCost, balanceOf, recipientReady — plus the server-side verify are what power planPayment(), estimateCost(), and local verification. A new contract method is implemented in all families, never just one.

verify is the heart of the server side. It re-derives every checked field from the trusted accept — never the client-supplied ref — and returns a discriminated VerifyResult. A transient RPC read is not an error: it comes back as a tx_not_found VerifyErrorCode, so a momentary hiccup reads as “not yet”, not a forgery.

async verify(ref, accept): Promise<VerifyResult> {
// re-derive payTo / amount / asset from `accept`, read the chain, match
// return { ok: true, receipt }
// | { ok: false, error: 'amount_too_low', detail: 'paid 0.05, required 0.10' }
// → on a `{ ok: false }` result `detail` is REQUIRED (a human-readable string);
// `error` must be a VerifyErrorCode member (e.g. 'amount_too_low', 'wrong_recipient',
// 'transfer_not_found', 'tx_not_found') — see /errors/verify-error-code/.
}

estimateCost, balanceOf, and recipientReady are payer-side, informational, and never throw. When a read is unavailable they degrade truthfully rather than guessing:

  • balanceOf returns { token, native } in base units, with a field null (unknown) when its read failed — never 0, which would falsely read “broke”.
  • estimateCost falls back to a 'heuristic' constant (vs 'estimated' from a live read).
  • recipientReady returns { ready: 'unknown' } on a transient probe failure.
interface WalletBalance {
token: bigint | null // payment-token balance, base units, or null if unreadable
native: bigint | null // native gas-coin balance; equals `token` when asset is 'native'
}

resolveToken(token) accepts a TokenInput'native', a symbol string ('USDC'), or a per-family token object — and returns a uniform ResolvedToken:

interface ResolvedToken {
asset: string // 0x… | base58 mint | 'native'
decimals: number
symbol?: string
}

Each family declares its own object shape for a token-by-identifier. A driver accepts the shape that matches its family:

FamilyShapeKey fields
EVMEvmTokenaddress: 0x…, decimals
SolanaSolanaTokenmint, decimals
TONTonTokenmaster, decimals
StellarStellarTokenissuer, code, decimals
XRPLXrplTokenissuer, currencyHex, decimals
TronTronTokenaddress (Base58 T…), decimals
SuiSuiTokencoinType (package::module::TYPE), decimals
NEARNearTokencontractId, decimals
AptosAptosTokenmetadata (object address), decimals
AlgorandAlgorandTokenassetId (numeric), decimals

describeAsset(asset) is the inverse for known assets: a pure, synchronous lookup of the SDK’s own decimals/symbol, used to enforce the spend budget against a token’s true decimals (so a server can’t understate a price by lying about extra.decimals) and to flag symbol mismatches. It returns null for an asset the SDK can’t safely price.

estimateCost(accept, opts?) returns a CostEstimate denominated in the chain’s native coin — the gas token, distinct from the payment token (you pay in USDC but burn ETH / SOL / TRX for gas). opts.from (the payer address) sharpens fees that depend on the sender, notably Tron energy; omit it for a typical estimate.

interface CostEstimate {
feeSymbol: string // 'ETH' | 'SOL' | 'TRX' | …
feeDecimals: number // 18 EVM, 9 Solana/TON, 7 Stellar, 6 XRPL/Tron
fee: string // native base units, non-negative integer string
feeFormatted: string // '0.000021'
basis: 'estimated' | 'heuristic'
detail?: string // 'gas ~21000 @ 12 gwei'
}

Build it with the shared nativeCost() helper so every family’s shape stays identical. A standard exact rail estimates the buyer’s gas as ~0 — the server or facilitator broadcasts the signed authorization.

recipientReady(payTo, asset) reports the chain’s one-time receive prerequisite — the thing a recipient must do before it can hold an asset. Families with no prerequisite (EVM, Solana, TON, Tron, Sui, Aptos, and every native coin) report ready: 'n/a'.

async recipientReady(payTo, asset):
Promise<{ ready: boolean | 'n/a' | 'unknown'; reason?: RecipientReason }>

When a prerequisite is missing, return { ready: false, reason } so the planner tells the agent to fix the recipient, not its own balance:

RecipientReasonFamilyMeaning
NO_TRUSTLINEStellar / XRPLThe account holds no trustline for this asset.
NOT_REGISTEREDNEARpayTo isn’t storage_deposit-registered on the NEP-141 token.
NOT_OPTED_INAlgorandpayTo hasn’t opted into the ASA.
INACTIVEStellar / XRPLThe account doesn’t exist / isn’t reserve-funded yet.

Several methods carry an optional ?. Because they’re optional, omitting one leaves today’s behaviour and does not trigger the “implement in all families” rule for required methods — a family ships them only when its chain supports them. The exact-rail methods are implemented by EVM, Solana, Algorand, Aptos, and NEAR today; exactDomain / exactPermit2Supported, signReceipt, and the upto trio (resolveUptoRail / payUpto / settleUptoSelf) are EVM-specific.

MethodPurpose
resolveExactRail(input)Server side. Resolve the chain-specific exact rail descriptor for an asset — { method, extra } (EVM eip3009/permit2 + EIP-712 name/version; Solana svm + feePayer/tokenProgram; Algorand algorand + feePayer; Aptos aptos + feePayer; NEAR near + feePayer), or null when the asset/chain can’t carry exact. This is what keeps server.ts family-agnostic — it never names a family, it just merges the returned extra into the rail.
payExact(wallet, accept)Buyer side. Sign the exact payment so a PipRail agent can pay any standard x402 exact server: an EIP-3009 transferWithAuthorization (EVM), a partial-signed TransferChecked transaction (Solana, plus a spec-required SPL-Memo for uniqueness), a sender-signed fee-payer (sponsored) transfer (Algorand, Aptos), or a NEP-366 SignedDelegateAction wrapping one NEP-141 ft_transfer that a relayer prepays gas (+ 1 yocto) and submits (NEAR). Omitting it means no exact rail is ever gathered/paid on that family.
settleExactSelf(input)Server side (Mode A). Verify a standard exact payment locally, then self-settle by broadcasting from the merchant’s relayer wallet (EVM: transferWithAuthorization / the Permit2 proxy; Solana: co-sign as fee payer; Algorand / Aptos: add the fee-payer signature and submit; NEAR: relay the buyer’s SignedDelegateAction via the merchant’s relayer, which prepays gas + the 1 yocto so the buyer stays gasless).
resolveUptoRail(input)Server side (EVM-Permit2). Advertise a metered upto rail for an asset — the metered sibling of resolveExactRail; returns an UptoRailInfo (the relayer becomes the bound witness.facilitator), or null when the asset/chain can’t carry upto.
payUpto(wallet, accept)Buyer side (EVM-Permit2). Sign a Permit2 PermitWitnessTransferFrom witness for the signed MAX (accept.amount) — the metered counterpart to payExact; returns the signed payload, the rail echo, the payer address, and the Permit2 nonce.
settleUptoSelf(input)Server side (EVM-Permit2). Verify a standard upto payment locally, then self-settle the actual (settleAmount ≤ the signed max) through the upto proxy from the merchant’s relayer — the metered counterpart to settleExactSelf.
signReceipt(wallet, input)Server side (EVM). Sign the official x402 offer-receipt §5.2 EIP-712 message (ReceiptInputSignedReceipt) with the bound wallet, so a buyer can prove the resource was served — verified by PipRailClient.verifyAttestation. Never part of the payment path.
exactDomain(asset)Server side (EVM). Read an EIP-3009 token’s on-chain EIP-712 domain (name/version); null when the asset isn’t EIP-3009.
exactPermit2Supported()Server side (EVM). Whether the x402 Permit2 proxy is deployed on this chain (gates the Permit2 fallback).
discoverySigner(wallet)A signer for discovery only (ownership proofs / SIWX index registration), never the payment path.

The exact methods are covered for buyers under paying an exact rail, for sellers under selling an exact rail, and at the wire level on the low-level exact page. resolveExactRail lets a new family add exact support without touching the chain-agnostic protocol layer.

discoverySigner(wallet) returns the bound wallet’s public address plus a message signer used only for open-index registration and ownership proofs — never to move funds. It returns null if the bound wallet can’t sign. A family ships it only once an open index verifies its signatures (EVM today).

interface DiscoverySigner {
address: string
signMessage(message: string): Promise<string> // EVM: eip191 hex
}

bindWallet(wallet) validates the user’s wallet config and wraps it in an opaque WalletHandle. The single intentional unknown in the contract is _native: each driver stashes its own wallet object there, and only that driver reads it back out in send / balanceOf / discoverySigner.

interface WalletHandle {
readonly _native: unknown // your driver's own wallet object lives here
}

confirm resolves a minimal ConfirmInfo — a numeric-agnostic height string (block number on EVM, slot on Solana) — so the protocol layer never assumes a chain’s height type.

interface ConfirmInfo {
height: string
}