Skip to content

PipRailError hierarchy

Everything the SDK throws is a PipRailError — an abstract base class with one contract: a stable SCREAMING_SNAKE .code and a .name set to the subclass name. A thrown error is always a config, flow, wallet, registry, or affordability problem the caller can act on. (A rejected proof is the other channel — a returned VerifyErrorCode, never a throw.)

Catch SDK-originated errors and ignore the rest with one guard:

import { PipRailClient, PipRailError } from '@piprail/sdk'
const client = new PipRailClient({
chain: 'base',
wallet: { privateKey: process.env.AGENT_KEY! },
})
const url = 'https://api.example.com/report'
try {
await client.fetch(url)
} catch (err) {
if (err instanceof PipRailError) console.error(err.code, err.message)
else throw err // not ours — rethrow
}

The base PipRailError and every one of the sixteen concrete subclasses below is a named export from @piprail/sdk, so you can either branch on err.code after a broad instanceof PipRailError catch, or import the specific class and narrow on it directly (err instanceof PaymentDeclinedError).

PipRailError is abstract, so you never construct it — you catch it. Every subclass extends Error, supports { cause } (the untouched chain-library error is preserved there), and exposes a readonly code. Branch on .code for stable, machine-readable handling; read .message for the human reason.

abstract class PipRailError extends Error {
abstract readonly code: string
}

Every class below is exported from the package root and is caught by err instanceof PipRailError. The .code is the stable string to branch on.

Class.codeMeaning
InsufficientFundsErrorINSUFFICIENT_FUNDSThe payer can’t cover the transfer plus fees / reserve / its own trustline.
RecipientNotReadyErrorRECIPIENT_NOT_READYThe recipient (payTo) isn’t set up to receive on this chain (not activated / no trustline / not registered).
WrongChainErrorWRONG_CHAINA bring-your-own walletClient is on a different chain than configured.
WrongFamilyErrorWRONG_FAMILYThe wallet, payTo, or token was given in another family’s shape (e.g. an 0x… address on Solana).
UnknownTokenErrorUNKNOWN_TOKENA built-in token symbol the chosen chain doesn’t ship (e.g. token: 'DOGE').
MissingDriverErrorMISSING_DRIVERA family’s optional peer deps aren’t installed — message names the exact npm install.
UnsupportedNetworkErrorUNSUPPORTED_NETWORKNo registered driver recognised the given chain value.
PaymentTimeoutErrorPAYMENT_TIMEOUTBroadcast confirmed, but the server didn’t return 200 in time — carries .ref.
ConfirmationTimeoutErrorCONFIRMATION_TIMEOUTBroadcast OK, but the tx didn’t confirm within the driver’s window — re-check the ref.
MaxRetriesExceededErrorMAX_RETRIES_EXCEEDEDPaid, retried, still 402 — the server rejected the proof on every attempt; carries .ref.
PaymentDeclinedErrorPAYMENT_DECLINEDThe client refused to pay before any send — over policy, or an onBeforePay hook said no.
InvalidEnvelopeErrorINVALID_ENVELOPEThe server returned 402 but the PAYMENT-REQUIRED envelope was missing or malformed.
NoCompatibleAcceptErrorNO_COMPATIBLE_ACCEPTThe challenge offered no accepts[] entry the client can pay on its chain + enabled schemes.
UnsupportedSchemeErrorUNSUPPORTED_SCHEMEAsked to pay a scheme the bound family / asset / signer can’t settle, with no fallback rail.
NonReplayableBodyErrorNON_REPLAYABLE_BODYinit.body was provided but isn’t replayable (e.g. a one-shot ReadableStream).
SettlementErrorSETTLEMENT_FAILEDAn exact-rail payment was valid but settlement failed server-side — the gate throws this so the adapter returns 5xx, never 402.

Affordability always converges — InsufficientFundsError

Section titled “Affordability always converges — InsufficientFundsError”

“Wallet can’t pay” always surfaces as one error, no matter the chain. The detection differs per family (EVM walks viem’s BaseError, Stellar reads Horizon result_codes, Solana and TON match the message), but every path throws the same InsufficientFundsError with .code === 'INSUFFICIENT_FUNDS'.

try {
await client.fetch(url)
} catch (err) {
if (err instanceof PipRailError && err.code === 'INSUFFICIENT_FUNDS') {
// fund the payer: more token, native gas, or reserve
} else throw err
}

toInsufficientFundsError(err) is the shared backstop that powers this — it’s exported for custom drivers. Inside a custom driver’s send() catch block, it matches a chain library’s “can’t afford it” message and returns an InsufficientFundsError, or null on a miss so the original error propagates unchanged:

import { toInsufficientFundsError } from '@piprail/sdk'
// inside a custom PaymentDriver's send():
try {
await broadcast(tx) // the chain library's send/broadcast call
} catch (err) {
throw toInsufficientFundsError(err) ?? err
}

PaymentTimeoutError and MaxRetriesExceededError mean the transaction is already on-chain; these are the only two classes that carry .ref, the broadcast proof. The base PipRailError and ConfirmationTimeoutError have no .ref, so narrow on the two concrete classes before reading it. The recovery rule is the same for each: re-verify or re-submit ref — never re-pay. A fresh payment would double-spend. The proof stays redeemable until the server’s maxTimeoutSeconds recency window elapses (default 600s).

import { PaymentTimeoutError, MaxRetriesExceededError } from '@piprail/sdk'
try {
await client.fetch(url)
} catch (err) {
if (err instanceof PaymentTimeoutError || err instanceof MaxRetriesExceededError) {
const ref = err.ref // the already-broadcast proof
// re-submit ref to the server — do NOT re-pay
} else throw err
}

For an exact rail, MaxRetriesExceededError.ref is the EIP-3009 authorization nonce (a 0x… value, not a tx hash) — re-present the same signed authorization, and check the token’s authorizationState(from, nonce) before assuming it didn’t settle.

Why a payment was declined — DeclineReasonCode

Section titled “Why a payment was declined — DeclineReasonCode”

PaymentDeclinedError fires before any send when a spend policy or an onBeforePay hook refuses the quote. Its .code is always 'PAYMENT_DECLINED'; the optional .reasonCode is a typed hint so an agent can branch on the cause without parsing the message.

reasonCodeMeaningRetry?
'POLICY'A chain / host / token / per-payment cap refused it.After fixing the target.
'BUDGET'The per-(network, asset) lifetime maxTotal cap.No — budget exhausted.
'OUTSIDE_WINDOW'The rolling windowTotal cap.Yes — once the window slides.
'SESSION_EXPIRED'The session TTL elapsed. Terminal.No — restart / extend the TTL.
'APPROVAL'An onBeforePay hook (e.g. MCP human-in-the-loop) declined. Terminal.No — don’t auto-retry.
import { PaymentDeclinedError } from '@piprail/sdk'
try {
await client.fetch(url)
} catch (err) {
if (err instanceof PaymentDeclinedError && err.reasonCode === 'SESSION_EXPIRED') {
// terminal for this process — extend the session, don't retry the payment
} else throw err
}

DeclineReasonCode is exported as a type for use in your own handlers.

A deliberate split: MISSING_DRIVER means a family’s lazy import() failed because its optional peer deps aren’t installed (the message names the exact npm install and sets { cause }); UNSUPPORTED_NETWORK means no driver recognised the chain value at all. Don’t reuse one for the other — see Chains and tokens for the family peer deps.