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.

Four 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. Today they’re EVM-only.

MethodPurpose
payExact(wallet, accept)Buyer side. Build + EIP-712-sign an EIP-3009 transferWithAuthorization so a PipRail agent can pay any standard x402 exact server. Omitting it means no exact rail is ever gathered/paid on that family.
exactDomain(asset)Server side. Read an EIP-3009 token’s on-chain EIP-712 domain (name/version); null when the asset isn’t EIP-3009, so the gate refuses to advertise an exact rail for it.
settleExactSelf(input)Server side (Mode A). Verify a standard exact payment locally, then self-settle by broadcasting from the merchant’s relayer wallet.
discoverySigner(wallet)A signer for discovery only (ownership proofs / SIWX index registration), never the payment path.

The three 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.

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
}