Skip to content

estimateCost()

estimateCost(url) answers the second question an autonomous agent must ask: not just what does this cost to pay, but what does it cost to send? The payment leaves the wallet in the token (USDC, USDT, native coin); the gas to broadcast it leaves in the chain’s native coin (ETH, SOL, TRX, …). Those are two different numbers, and a 402’s price tells you only the first.

estimateCost returns both. It does the initial request and, if it’s a 402, gives you a PipRailQuote plus a CostEstimate. It reads RPC where that’s cheap, but it never pays — and returns null when the URL isn’t payment-gated.

// `client` is the PipRailClient you configured earlier — see /making-payments/piprail-client/
const result = await client.estimateCost('https://api.example.com/report')
if (result) {
console.log(result.quote.amountFormatted, result.quote.symbol) // '0.10' 'USDC' — the payment
console.log(result.cost.feeFormatted, result.cost.feeSymbol) // '0.000021' 'ETH' — the gas
}
// → null when the URL isn't payment-gated (no 402)
// → { quote: PipRailQuote, cost: CostEstimate } when it is

The return value pairs the priced requirement with the gas to settle it:

interface PipRailCostQuote {
quote: PipRailQuote // what the payment is — amount, token, chain, recipient, policy
cost: CostEstimate // the network fee (gas) to send it, in the native coin
}

quote is exactly what quote() returns. cost is the new piece.

Every driver computes its chain’s fee — EVM gas × price, Solana lamports, Tron energy × price, XRPL drops — as a bigint of native base units, then shapes it through util/cost.ts’s nativeCost() helper, so the fields are identical on every chain:

FieldTypeMeaning
feeSymbolstringThe native fee coin’s ticker — ETH, BNB, SOL, TON, XLM, XRP, TRX, …
feeDecimalsnumberThe native coin’s decimals (18 EVM, 9 Solana/TON, 7 Stellar, 6 XRPL/Tron).
feestringEstimated fee in native base units (a non-negative integer string).
feeFormattedstringThe same fee, human-readable — e.g. '0.000021'.
basis'estimated' | 'heuristic'How the number was derived (see below).
detail?stringOptional note on what’s included — e.g. 'gas ~21000 @ 12 gwei'.

basis labels the estimate’s source so you can decide how much margin to keep:

basisMeaning
'estimated'Derived from a live RPC read (EVM gas price, XRPL fee) — sharp.
'heuristic'A typical-cost constant, used when an RPC read would be slow or unavailable.
const result = await client.estimateCost(url)
if (result && result.cost.basis === 'heuristic') {
// the gas figure is a ballpark — keep a wider native-coin margin
}

Together, the two numbers let an agent reason about the total before any funds move: the token amount must be affordable, and there must be enough native coin left over for gas.

const result = await client.estimateCost('https://api.example.com/report')
if (result) {
console.log(`Pay ${result.quote.amountFormatted} ${result.quote.symbol}`)
console.log(`Gas ~${result.cost.feeFormatted} ${result.cost.feeSymbol} (${result.cost.basis})`)
}
// → Pay 0.10 USDC
// → Gas ~0.000021 ETH (estimated)

This is the middle step of the read-only trio: quote() learns the price, estimateCost() adds the gas, and planPayment() checks your balances against both and tells you whether the whole thing is settleable. If you want the verdict — not just the numbers — reach for planPayment().

Returns null when there’s nothing to cost — and what it does throw

Section titled “Returns null when there’s nothing to cost — and what it does throw”

estimateCost is safe to call speculatively. A transient RPC issue falls back to a 'heuristic' constant rather than raising, so a flaky endpoint degrades the estimate’s sharpness instead of crashing your agent.

It returns null in exactly two cases: a non-402 response (the URL isn’t payment-gated), or a malformed / unparseable 402 envelope (an unparseable PAYMENT-REQUIRED body, or a parseable-but-malformed rail with a bad amount/asset/decimals). So unlike quote() — which throws an InvalidEnvelopeError on a malformed 402 — estimateCost degrades a bad envelope to null, and a caller just checks for it.

It is not unconditionally throw-free, though. When the 402 is well-formed but offers no rail this client can pay on its chain + enabled schemes, estimateCost throws NoCompatibleAcceptError (or UnsupportedSchemeError) — a deliberate “this 402 isn’t for you” signal, since that’s a routing fact, not a cost-read failure. Guard it the way you would any payment call:

import { NoCompatibleAcceptError } from '@piprail/sdk'
try {
const result = await client.estimateCost('https://api.example.com/report')
if (result) {
// a bad RPC yields cost.basis: 'heuristic', not an exception
console.log(result.cost.feeFormatted, result.cost.feeSymbol)
} else {
// null = non-402, or a malformed/un-costable 402 envelope — nothing to estimate
console.warn('No cost to estimate (not gated, or a malformed 402).')
}
} catch (err) {
if (err instanceof NoCompatibleAcceptError) {
// the 402 is well-formed but offers no rail this client can pay — "not for you"
console.warn('This 402 offers nothing this client can pay:', err.message)
} else throw err
}