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 it 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), so it’s 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, so 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
}