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.
WINDOW_TOTALThis payment would exceed policy.windowTotal within the rolling window.

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 → windowTotal

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 window check is last because it’s 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 }

Session expiry (ttlSeconds / expiresAt) and the rolling window (windowTotal + windowSeconds) need a clock. To keep evaluatePolicy pure, the client injects time through a private context — there is no public way to pass it. Calling evaluatePolicy directly (as in a unit test) skips both time-based checks; behaviour is byte-identical to a time-free policy, so SESSION_EXPIRED and WINDOW_TOTAL only fire through the live client.

To test the time leash itself, drive it through the client’s session surfaces — see the time envelope page.

evaluatePolicy returns the fine-grained PolicyDenyCode (all eight codes 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 eight policy codes down to five:

PolicyDenyCode (read-only)DeclineReasonCode (catch)
SESSION_EXPIREDSESSION_EXPIRED (terminal — restart/extend the TTL, don’t retry)
WINDOW_TOTALOUTSIDE_WINDOW
MAX_TOTALBUDGET
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.