evaluatePolicy()
Introduction
Section titled “Introduction”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 PolicyDecision — allowed 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 PaymentIntent
Section titled “The PaymentIntent”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?}The PolicyDecision
Section titled “The PolicyDecision”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.
Deny codes
Section titled “Deny codes”Each guard refuses with one typed PolicyDenyCode, matching the field it enforces.
| Code | Fired when |
|---|---|
SESSION_EXPIRED | The session’s TTL or deadline has elapsed — refuses every payment, amount-blind. |
CHAIN | intent.chain / network is not in policy.chains. |
HOST | intent.host is not in policy.hosts (exact or *.suffix wildcard). |
UNKNOWN_TOKEN | The asset isn’t one the SDK can price and allowUnknownTokens is off. |
TOKEN | The true symbol (or 'native') is not in policy.tokens. |
MAX_AMOUNT | The payment exceeds policy.maxAmount (per-payment ceiling). |
MAX_TOTAL | This payment would push per-asset lifetime spend past policy.maxTotal. |
WINDOW_TOTAL | This payment would exceed policy.windowTotal within the rolling window. |
Evaluation order
Section titled “Evaluation order”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 → windowTotalExpiry 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 spentForAssetBase argument
Section titled “The spentForAssetBase argument”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.10evaluatePolicy({ ...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.
Unknown tokens
Section titled “Unknown tokens”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 }No policy is allow-all
Section titled “No policy is allow-all”Omit the policy (or pass undefined) and every payment is allowed — the leash is opt-in.
evaluatePolicy(intent, undefined, 0n)// → { allowed: true }Time checks and the context
Section titled “Time checks and the context”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.
Two enums, not one
Section titled “Two enums, not one”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_EXPIRED | SESSION_EXPIRED (terminal — restart/extend the TTL, don’t retry) |
WINDOW_TOTAL | OUTSIDE_WINDOW |
MAX_TOTAL | BUDGET |
CHAIN / HOST / UNKNOWN_TOKEN / TOKEN / MAX_AMOUNT | POLICY |
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.