Skip to content

evaluatePolicy()

evaluatePolicy() is the testable core of the spend policy. It takes a PaymentIntent (the facts about one payment the client is about to make) and a PaymentPolicy, and returns a PolicyDecisionallowed plus a typed code and a human-readable reason when it refuses. It is pure and chain-agnostic: it imports nothing from any driver, never touches the network, and never throws.

The client calls it for you on every payment and throws PaymentDeclinedError on a refusal, so you rarely call it directly. You call it to unit-test your own policy without a wallet, an RPC, or a live 402.

import { evaluatePolicy, type PaymentIntent, type PaymentPolicy } from '@piprail/sdk'
// The facts about one payment, as the client would build them from a 402's accept.
const intent: PaymentIntent = {
host: 'api.example.com',
chain: 'base',
network: 'eip155:8453',
asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // Base USDC
amountBase: 100_000n, // 0.10 USDC (6 decimals)
decimals: 6,
symbol: 'USDC',
recognized: true,
}
const policy: PaymentPolicy = { maxAmount: '0.10', tokens: ['USDC'], chains: ['base'] }
const decision = evaluatePolicy(intent, policy, 0n)
// → { allowed: true }
if (!decision.allowed) console.log(decision.code, decision.reason)

The intent is the value object the policy reasons over — what the client builds from the chosen accept after it has resolved the asset against the driver. The cardinal rule lives here: amounts are checked against the token’s true decimals (the SDK’s own, via the driver), never the server-stated extra.decimals, so a server can’t slip past a cap by claiming a cheap-looking amount.

interface PaymentIntent {
host: string // host of the gated URL (for the hosts allowlist)
chain: ChainSelector // the selector the client is configured with
network: Caip2 // e.g. 'eip155:8453'
asset: string // token address, or 'native'
amountBase: bigint // server-stated base units — what actually transfers
decimals: number // TRUE decimals if recognised, else server-stated
symbol?: string // TRUE symbol if recognised, else server-stated
recognized: boolean // did the driver's describeAsset recognise this asset?
}
interface PolicyDecision {
allowed: boolean
reason?: string // why it was refused (only when allowed === false)
code?: PolicyDenyCode // which guard fired, as a typed enum
}

Branch on code, never on the prose. The reason is for humans and logs; the code is the stable contract — it won’t break when the wording is tweaked.

Each guard refuses with one typed PolicyDenyCode, matching the field it enforces.

CodeFired when
SESSION_EXPIREDThe session’s TTL or deadline has elapsed — refuses every payment, amount-blind.
CHAINintent.chain / network is not in policy.chains.
HOSTintent.host is not in policy.hosts (exact or *.suffix wildcard).
UNKNOWN_TOKENThe asset isn’t one the SDK can price and allowUnknownTokens is off.
TOKENThe true symbol (or 'native') is not in policy.tokens.
MAX_AMOUNTThe payment exceeds policy.maxAmount (per-payment ceiling).
MAX_TOTALThis payment would push per-asset lifetime spend past policy.maxTotal.
MAX_TOTAL_DENOMThis payment would push the cross-token grand total for its denomination past policy.maxTotalPerDenom (e.g. all USD-unit tokens summed).
MAX_PAYMENTSThe lifetime count of settled payments would exceed policy.maxPayments.
WINDOW_TOTALThis payment would exceed policy.windowTotal within the rolling window.
WINDOW_COUNTThe number of payments in the rolling window would exceed policy.maxPaymentsPerWindow.

Checks run in a pinned, deterministic order, first-failure-wins, so the reported reason is the most specific one:

session expiry → chains → hosts → unknown-token → tokens → maxAmount
→ maxTotal → maxTotalPerDenom → maxPayments → windowTotal → maxPaymentsPerWindow

Expiry is first because it’s session-global, not asset-scoped — an expired session must always report expiry, not whichever other gate also happens to fail. The budget caps run cheapest-first: the per-asset maxTotal, then the cross-token maxTotalPerDenom (MAX_TOTAL_DENOM) and the lifetime maxPayments count (MAX_PAYMENTS), and the two rolling-window checks — windowTotal and the window count maxPaymentsPerWindow (WINDOW_COUNT) — last, because they’re the heaviest.

The third argument is the running total already spent on this (network, asset) pair, in base units — the client supplies it from its spend ledger. It powers the maxTotal cap. With no policy maxTotal, pass 0n.

// already spent 0.07 USDC (6 decimals) on this network+asset; a further 0.10 → 0.17 > 0.10
evaluatePolicy({ ...intent, amountBase: 100_000n }, { maxTotal: '0.10' }, 70_000n)
// → { allowed: false, code: 'MAX_TOTAL', reason: 'this payment would push spend on USDC past …' }

maxTotal is per distinct asset, not a grand total across tokens — summing different tokens is meaningless without a price oracle (which the SDK deliberately omits). Pair it with tokens: ['USDC'] for a true single-currency budget.

An asset the SDK can’t recognise can’t have its decimals verified, so it can’t be priced safely — and an unpriceable asset is declined by default. Set allowUnknownTokens: true to opt in to trusting the server-stated decimals (the explicit risk).

evaluatePolicy({ ...intent, recognized: false }, {}, 0n)
// → { allowed: false, code: 'UNKNOWN_TOKEN', reason: "asset … isn't a token the SDK can price …" }
evaluatePolicy({ ...intent, recognized: false }, { allowUnknownTokens: true }, 0n)
// → { allowed: true }

Omit the policy (or pass undefined) and every payment is allowed — the leash is opt-in.

evaluatePolicy(intent, undefined, 0n)
// → { allowed: true }

The third argument carries only the per-asset spend (spentForAssetBase). Several caps need more than that — a clock, the cross-token grand totals, or the settled-payment counts — and the client supplies all of it through the same private context it uses for the clock. There is no public way to pass it, so calling evaluatePolicy directly (as in a unit test) skips these checks entirely; behaviour is byte-identical to a policy without those fields. The context-dependent guards are:

  • Session expiry (ttlSeconds / expiresAt) and the rolling window (windowTotal + windowSeconds) — need a clock; surface as SESSION_EXPIRED and WINDOW_TOTAL.
  • Cross-token grand total (maxTotalPerDenom) — needs the per-denomination sums the client rolls up from its spend ledger; surfaces as MAX_TOTAL_DENOM.
  • Payment counts (maxPayments, maxPaymentsPerWindow) — need the settled-payment count (lifetime and per-window); surface as MAX_PAYMENTS and WINDOW_COUNT.

So SESSION_EXPIRED, WINDOW_TOTAL, MAX_TOTAL_DENOM, MAX_PAYMENTS, and WINDOW_COUNT all fire only through the live client, never from a bare evaluatePolicy call.

To test these leashes, drive them through the client’s session surfaces — see the time envelope page.

evaluatePolicy returns the fine-grained PolicyDenyCode (every code in the table above), which the client surfaces unchanged on a read-only quote() as quote.policyCode. But when a live payment is actually refused, the thrown PaymentDeclinedError carries a coarser DeclineReasonCode on .reasonCode — the client maps the policy codes down to five:

PolicyDenyCode (read-only)DeclineReasonCode (catch)
SESSION_EXPIREDSESSION_EXPIRED (terminal — restart/extend the TTL, don’t retry)
WINDOW_TOTAL / WINDOW_COUNTOUTSIDE_WINDOW
MAX_TOTAL / MAX_TOTAL_DENOM / MAX_PAYMENTSBUDGET
CHAIN / HOST / UNKNOWN_TOKEN / TOKEN / MAX_AMOUNTPOLICY
import { PaymentDeclinedError } from '@piprail/sdk'
try {
await client.fetch('https://api.example.com/report')
} catch (err) {
if (err instanceof PaymentDeclinedError) {
console.log(err.reasonCode) // 'POLICY' | 'BUDGET' | 'OUTSIDE_WINDOW' | 'SESSION_EXPIRED' | 'APPROVAL'
if (err.reasonCode === 'SESSION_EXPIRED' || err.reasonCode === 'APPROVAL') return // terminal — don't retry
} else {
throw err
}
}

The fine-grained policyCode is for read-only inspection; the coarse reasonCode is what a catch sees. See Payment policy for the full field-by-field reference.