The error model
Introduction
Section titled “Introduction”PipRail reports every failure through exactly two chain-agnostic channels, and only two. An
EVM, Solana, TON, or Stellar failure looks identical to you. You always get a typed reason, never
a raw viem / @solana / @ton / @stellar error for a condition the SDK recognises.
| Channel | Shape | For | |
|---|---|---|---|
| 1 | THROWN | a typed PipRailError subclass with a stable .code | config / flow / wallet / registry / affordability, the things you act on |
| 2 | RETURNED | a VerifyErrorCode on { ok: false, error, detail } | the outcome of verifying an on-chain proof, server side |
The rule of thumb: config / flow / wallet / registry / affordability → throw; proof-verification outcome → return. This page is the map; the dedicated pages drill into each channel.
Channel 1: a thrown PipRailError
Section titled “Channel 1: a thrown PipRailError”The imperative path (paying, binding a wallet, resolving a token, talking to the registry)
throws a typed error. Every one extends the abstract PipRailError
base class, so you filter SDK-originated failures from arbitrary ones in one check:
import { PipRailClient, PipRailError } from '@piprail/sdk'
const client = new PipRailClient({ chain: 'base', wallet: { key: process.env.AGENT_KEY! },})
try { const res = await client.fetch('https://api.example.com/report') const data = await res.json()} catch (err) { if (err instanceof PipRailError) console.log(err.code, err.message) else throw err}Or branch directly on .code, a stable SCREAMING_SNAKE string that never changes:
try { await client.fetch('https://api.example.com/report')} catch (err) { if (err instanceof PipRailError && err.code === 'PAYMENT_DECLINED') { // over policy / refused before any send, so nothing moved } else throw err}Every PipRailError supports the standard { cause } option, so the untouched chain error stays
attached on .cause for deeper debugging while the message reads in plain language. All concrete
classes are exported from @piprail/sdk. The full set of classes and their .code values is the
error hierarchy.
Channel 2: a returned VerifyErrorCode
Section titled “Channel 2: a returned VerifyErrorCode”A driver’s verify() reports why a proof was rejected without throwing. It returns a
VerifyResult. This is the one place verification fails closed instead of throwing.
type VerifyResult = | { ok: true; receipt: X402Receipt } | { ok: false; error: VerifyErrorCode; detail: string }error is a closed snake_case union the compiler enforces (tx_not_found, amount_too_low,
tx_reverted, and so on) so a driver can’t invent a code, and the same condition uses the same
code everywhere. The gate turns a rejection into a conformant 402 re-challenge (carrying
accepts[] so a standard client can retry, the reason in error, the machine code in
extensions.piprail.{code,detail}). See the verify error code page
for the full union and which driver emits each.
The gate exposes both verdicts as fully-isolated observability hooks, the mirror pair
onPaid and onFailed: onPaid fires with the
enriched receipt on a settled proof, onFailed fires with a FailedPayment on a rejected one,
carrying the same VerifyErrorCode the buyer’s client is told (so both sides see one reason). A
throw inside either is caught and routed to its …Error seam, so neither can break the request.
Affordability always converges on one error
Section titled “Affordability always converges on one error”“Wallet can’t pay” always surfaces as a single thrown
InsufficientFundsError (.code === 'INSUFFICIENT_FUNDS'), no matter
which chain detected it. The detection differs per family, the result never does:
try { await client.fetch('https://api.example.com/report')} catch (err) { if (err instanceof PipRailError && err.code === 'INSUFFICIENT_FUNDS') { // fund the payer with more token, native gas, or reserve } else throw err}Message-regex drivers (Solana, TON) match the chain library’s “can’t afford it” message via the
exported toInsufficientFundsError(err) helper; structured-error drivers (EVM, Stellar) detect it
from typed data (viem’s BaseError chain, Horizon result_codes) and also fall through to the
same helper as a backstop, so the two paths can’t drift in vocabulary.
Recovering a broadcast proof, and never re-paying
Section titled “Recovering a broadcast proof, and never re-paying”Once a payment broadcasts, funds may have moved. If the broadcast succeeds but the server then
times out or keeps returning 402, the client throws PaymentTimeoutError or
MaxRetriesExceededError, and only these two carry the already-broadcast proof on .ref.
Narrow on the concrete class before reading it (the PipRailError base has no .ref):
import { PaymentTimeoutError, MaxRetriesExceededError } from '@piprail/sdk'
try { await client.fetch('https://api.example.com/report')} catch (err) { if ( (err instanceof PaymentTimeoutError || err instanceof MaxRetriesExceededError) && err.ref ) { // re-verify or RE-SUBMIT err.ref. Never start a fresh payment } else throw err}The proof stays redeemable until the server’s maxTimeoutSeconds recency window elapses (default
600s). A fresh payment would double-spend, so the recovery rule for these two codes is: read
.ref, re-submit it, never re-pay. A ConfirmationTimeoutError from your own confirm() poll
behaves the same way (the tx may still land) but it carries no .ref; re-check the proof
ref you already hold from send(), don’t expect one on the error.
For the full picture of what happens to a payment when a request doesn’t finish, on both the client and the gate side, see Replay protection & recovery.
Branching on a decline reason
Section titled “Branching on a decline reason”A PaymentDeclinedError is thrown before any send: the quote exceeded your
spend policy, or an onBeforePay hook said no. Its .code
stays 'PAYMENT_DECLINED', but it carries an optional typed .reasonCode so an agent can branch
on why without parsing prose:
import { PaymentDeclinedError } from '@piprail/sdk'
try { await client.fetch('https://api.example.com/report')} catch (err) { if (err instanceof PaymentDeclinedError) { // err.reasonCode: 'POLICY' | 'BUDGET' | 'OUTSIDE_WINDOW' | 'SESSION_EXPIRED' | 'APPROVAL' } else throw err}'SESSION_EXPIRED' and 'APPROVAL' are terminal, so every payment this process makes is now
refused, so don’t auto-retry; restart or extend the time envelope
first. This adds no new .code and no new class; it’s a hint layered on the always-reliable
two-channel model.
What an AI agent receives
Section titled “What an AI agent receives”The two channels are designed so a model never sees a raw exception. The
agent toolkit funnels everything: its piprail_pay_request tool
catches every PipRailError and returns a structured { ok: false, code, reason, explain, ref?, reasonCode?, declined? } instead of letting it crash the loop, so a broadcast-but-unconfirmed
PAYMENT_TIMEOUT reaches the model with its .ref and the never-re-pay rule attached. Only a
genuine non-PipRailError bug rethrows.
Where read-only methods sit
Section titled “Where read-only methods sit”The read-only completion of the trio, planPayment() (and its
inputs estimateCost(), plus the drivers’ balanceOf /
recipientReady SPI it composes), deliberately never throw. The driver-SPI balanceOf /
recipientReady are surfaced only via planPayment().options[].balance / .recipient, never as
client methods. Like verify(),
they return their outcome: a transient read becomes a rail in state: 'unknown' with a warning,
an unsettleable rail carries typed blockers, and a missing field comes back null (never a false
0). The throws on that path are InvalidEnvelopeError (an unparseable challenge) and
WalletRequiredError (planPayment() / canAfford() called on a read-only client built with no
wallet, since they read YOUR balance, gas, and recipient-readiness), plus
fetch(url, { autoRoute: true }), the one place a plan
turns into a thrown PaymentDeclinedError when nothing is settleable.
Swapping follows the same split. quoteSwap() is a read and
never throws for a read problem: no route, no liquidity, a dead node or a hostile response all
return null, and on a read-only client it returns null rather than demanding a wallet. The one
thing it throws is a malformed slippageBps, as a plain RangeError before any read, because that
is a bug in your code and hiding it behind null would read as “no route”. swap() is the write:
WalletRequiredError on a read-only client, UnsupportedNetworkError on a chain with no route (the
message names every venue that does exist) or for a quote from another network, and
InsufficientFundsError when the wallet cannot cover it or the chain rejects the swap, which
includes the market moving past the slippage cap. Nothing is swapped in that case; chains that
charge for a reverted transaction (EVM, Aptos, Tron) still take the gas.
Discovery is read-style too: client.discover() reports []
for a dead index rather than throwing, and client.register() returns one { ok, detail } outcome
per target, surfaced and never swallowed.
This whole model is the SDK’s single standard; it is specified in sdk/ERRORS.md, and every module
and chain driver conforms to it by construction.