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.

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) // 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)
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 paid, already scaled by decimals.
amountFormattedHuman-readable amount, e.g. '0.10'.
symbolToken symbol, when known.
refProof ref — EVM tx hash, Solana signature, TON locator, Stellar tx hash.
atISO timestamp of settlement.

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
}

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.

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