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.
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: { privateKey: 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) 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 paid, already scaled by decimals. |
amountFormatted | Human-readable amount, e.g. '0.10'. |
symbol | Token symbol, when known. |
ref | Proof ref — EVM tx hash, Solana signature, TON locator, Stellar tx hash. |
at | ISO timestamp of settlement. |
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}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: { start, expiresAt, secondsRemaining }, byAsset: [ SpendRemaining, … ] }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)}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.
remaining() — 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 isn’t only a report — it’s the running total the policy checks against. Before any
on-chain send, evaluatePolicy() reads the per-asset total
from the ledger; 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.