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

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.

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

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, which is 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, 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) // 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 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.