The spend ledger
Introduction
Section titled “Introduction”An autonomous agent that can’t account for its spend can’t be trusted to spend. So every
PipRailClient keeps an in-memory ledger of every settled
payment, and exposes three read-only views over it: spent() for the
full record, budget() for the session leash, and
remaining() for the headroom per token. The same ledger powers
the lifetime cap: your policy.maxTotal is checked against it
before any on-chain send.
When you cap by cross-token grand total or payment count
(maxTotalPerDenom, maxPayments, …), the same ledger surfaces those leashes too:
budget().byDenom and
budget().counts (with the standalone
denomRemaining() / countStatus()
readers), plus spent().byDenom and
client.policy().
spent(): the full record
Section titled “spent(): the full record”client.spent() returns a SpendSummary: the total count, the cumulative spend per distinct
token, and the individual records, in order. It never throws and moves no funds.
import { PipRailClient } from '@piprail/sdk'
const client = new PipRailClient({ chain: 'base', wallet: { key: process.env.AGENT_KEY! },})
// …after three payments of 0.10 USDC each…const summary = client.spent()console.log(summary.count) // 3console.log(summary.byAsset[0].totalFormatted) // '0.30'console.log(summary.records[0].url) // 'https://api.example.com/report'// → { count: 3, byAsset: [ { symbol: 'USDC', totalFormatted: '0.30', … } ], records: [ … ] }interface SpendSummary { count: number // total settled payments byAsset: SpendAssetTotal[] // cumulative spend per (network, asset) byDenom: SpendDenomTotal[] // cumulative spend per denomination (USD, EUR, …) records: SpendRecord[] // every settled payment, in order}SpendRecord: one settled payment
Section titled “SpendRecord: one settled payment”| Field | Meaning |
|---|---|
url / host | The resource paid for, and its hostname. |
network | The chain, as a CAIP-2 id (e.g. eip155:8453). |
asset | The token paid (address or native marker). |
amountBase | Base units that count against the caps. For the metered upto rail this is the authorized MAX, not the merchant’s claimed actual. |
amountFormatted | Human-readable amountBase, e.g. '0.10'. |
settledBase | Merchant-claimed settled actual (upto rail only), clamped to ≤ amountBase. Informational: it does not feed the caps. Absent for onchain-proof / exact rails (where actual = amount). |
settledFormatted | Human-readable settledBase, when present. |
symbol | Token symbol, when known. |
decimals | Token decimals, when known, so a durable store rebuilds totals on reload without a second spend. |
denom | The token’s denomination ('USD', 'EUR', …), when it has one. undefined for native + unrecognised tokens. |
ref | Proof ref: EVM tx hash, Solana signature, TON locator, Stellar tx hash. |
at | ISO timestamp of settlement. |
The new decimals + denom fields are what let a SpendStore
replay the ledger and rebuild both the per-asset totals and the cross-token grand total exactly,
without waiting for the first live spend on a pair.
SpendAssetTotal: the per-token tally
Section titled “SpendAssetTotal: the per-token tally”Aggregation is keyed by (network, asset) because summing across different tokens is
unit-meaningless without a price oracle, which the SDK deliberately doesn’t add.
interface SpendAssetTotal { network: Caip2 asset: string symbol?: string decimals: number totalBase: string // cumulative base units totalFormatted: string // human units, e.g. '0.30' count: number // payments on this pair}spent().byDenom: the grand-total tally
Section titled “spent().byDenom: the grand-total tally”When tokens share a denomination, byDenom rolls them into one
unit-of-account line, so USDC + USDT + FDUSD + U all land in USD. It’s the sum the
maxTotalPerDenom cap is checked against, not a priced figure:
each token is counted 1:1 as the unit you labelled it. Native coins and unrecognised tokens have no
denomination, so they’re never in a row here.
const summary = client.spent()summary.byDenom[0] // → { denom: 'USD', totalFormatted: '0.30', count: 3, … }interface SpendDenomTotal { denom: string // the unit of account, e.g. 'USD' totalScaled: string // cumulative value at the DENOM_PRECISION fixed point, as a string totalFormatted: string // human units, e.g. '0.30' count: number // payments rolled into this denomination}budget(): the session leash
Section titled “budget(): the session leash”client.budget() composes the ledger with your configured policy into a SessionBudget: the
time envelope plus the per-asset money leash. This is how a headless (Mode A) agent sees
what’s left of its consent before paying, rather than discovering it by hitting a decline. It
never throws and moves no funds.
const b = client.budget()console.log(b.session.secondsRemaining) // 540 (or null, no time limit)console.log(b.byAsset[0].remainingFormatted) // '0.70' (or undefined, unbounded)// → { session: {…}, byAsset: [ SpendRemaining, … ], byDenom: [ DenomRemaining, … ], counts: { … } }interface SessionBudget { session: { start: string // session start, ISO expiresAt: string | null // deadline ISO, or null if no time limit secondsRemaining: number | null // clamped ≥ 0, or null } byAsset: SpendRemaining[] // the money half, per (network, asset) byDenom: DenomRemaining[] // the cross-token grand-total leash, per denomination counts: CountStatus // the payment-count leash}The session fields carry a real deadline only when the policy configures a time
envelope (ttlSeconds or expiresAt); otherwise expiresAt and
secondsRemaining are null. The byAsset rows are exactly what remaining() returns.
budget().byDenom: the grand-total leash
Section titled “budget().byDenom: the grand-total leash”One row per denomination you’ve capped with maxTotalPerDenom.
Unlike byAsset, these rows are present from the start, before any spend. The cap is a single
declared number, not a per-token total that has to be discovered, so a headless agent can preview
its full headroom up front.
const client = new PipRailClient({ chain: 'base', wallet: { key: process.env.AGENT_KEY! }, policy: { maxTotalPerDenom: { USD: '20.00' } },})
client.budget().byDenom// [{ denom: 'USD', spentFormatted: '0', capFormatted: '20', remainingFormatted: '20', fraction: 0 }]interface DenomRemaining { denom: string // the unit of account, e.g. 'USD' spentFormatted: string // human units spent so far across every token of this unit capFormatted: string // the maxTotalPerDenom cap, human units remainingFormatted: string // max(0, cap − spent), human units fraction: number // spent / cap, in [0, 1]}budget().counts: the payment-count leash
Section titled “budget().counts: the payment-count leash”The payment-count caps
(maxPayments, maxPaymentsPerWindow) need no oracle, so counts always reflects every settled
payment across every chain and token, including native coins. The cap and remaining fields appear
only for the caps you configured.
client.budget().counts// { settled: 3, lifetimeCap: 100, lifetimeRemaining: 97, windowCap: 10, windowSettled: 3, windowRemaining: 7 }interface CountStatus { settled: number // total settled payments, all chains + tokens lifetimeCap?: number // maxPayments, when set lifetimeRemaining?: number // max(0, lifetimeCap − settled) windowCap?: number // maxPaymentsPerWindow, when set windowSettled?: number // settled payments inside the current rolling window windowRemaining?: number // max(0, windowCap − windowSettled)}denomRemaining() and countStatus()
Section titled “denomRemaining() and countStatus()”Both leashes are also reachable directly, without the rest of the budget, which is handy when you only need one half:
client.denomRemaining() // → DenomRemaining[], same rows as budget().byDenomclient.countStatus() // → CountStatus, same as budget().countsBoth are pure, in-memory, and never throw.
policy(): read the policy back
Section titled “policy(): read the policy back”client.policy() returns the configured PaymentPolicy (or
undefined if none was set), so an agent can introspect its own consent, meaning what it’s allowed to do,
alongside budget()’s view of what’s left. It’s also on
MultiChainPayer and is part of the shared PayingClient
interface.
const p = client.policy()console.log(p?.maxTotalPerDenom) // { USD: '20.00' }console.log(p?.maxPayments) // 100remaining(): per-asset headroom
Section titled “remaining(): per-asset headroom”client.remaining() returns one SpendRemaining row per (network, asset) the ledger has
already seen: the money half of the leash. It’s pure and in-memory, never throws, and never
sums across tokens.
for (const r of client.remaining()) { console.log(r.symbol, r.spentBase, r.remainingFormatted) // 'USDC' '300000' '0.70'}interface SpendRemaining { network: Caip2 asset: string symbol?: string decimals: number spentBase: string // base units spent so far on this pair capBase?: string // the maxTotal cap, base units (undefined = unbounded) remainingBase?: string // max(0, cap − spent), base units remainingFormatted?: string // remainingBase in human units}The cap fields (capBase, remainingBase, remainingFormatted) are present only when
policy.maxTotal is set; with no cap configured the pair is unbounded and they are undefined.
How it feeds the lifetime cap
Section titled “How it feeds the lifetime cap”The ledger is more than a report. It’s the running total the policy checks against. Before any
on-chain send, the client reads the per-asset total from the ledger (ledger.totalFor) and passes
it to evaluatePolicy() as spentForAssetBase; if the new
payment would push it past policy.maxTotal, the client refuses with
PaymentDeclinedError and no funds move. The same totals back the
rolling-window check (windowSeconds + windowTotal), which
scans only records inside the window.