Skip to content

The spend ledger

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().

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) // 3
console.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
}
FieldMeaning
url / hostThe resource paid for, and its hostname.
networkThe chain, as a CAIP-2 id (e.g. eip155:8453).
assetThe token paid (address or native marker).
amountBaseBase units that count against the caps — for the metered upto rail this is the authorized MAX, not the merchant’s claimed actual.
amountFormattedHuman-readable amountBase, e.g. '0.10'.
settledBaseMerchant-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).
settledFormattedHuman-readable settledBase, when present.
symbolToken symbol, when known.
decimalsToken decimals, when known — so a durable store rebuilds totals on reload without a second spend.
denomThe token’s denomination ('USD', 'EUR', …), when it has one — undefined for native + unrecognised tokens.
refProof ref — EVM tx hash, Solana signature, TON locator, Stellar tx hash.
atISO 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.

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
}

When tokens share a denomination, byDenom rolls them into one unit-of-account line — 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
}

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)
}

Both leashes are also reachable directly, without the rest of the budget — handy when you only need one half:

client.denomRemaining() // → DenomRemaining[], same rows as budget().byDenom
client.countStatus() // → CountStatus, same as budget().counts

Both are pure, in-memory, and never throw.

client.policy() returns the configured PaymentPolicy (or undefined if none was set), so an agent can introspect its own consent — 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) // 100

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.

The ledger isn’t only 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.