Skip to content

The exact scheme low-level codec

PipRail’s gates default to onchain-proof (the payer pays first, proves with a tx ref, the server verifies locally). The standard x402 exact scheme is a different shape — an EIP-3009 signed authorization the server settles — and PipRail interops with it in both directions.

You almost never call this module. The high-level paths cover the common cases: pay any standard x402 server with schemes: ['exact'], and get paid over exact with createPaymentGate({ exact }). This page is the low-level codec tier underneath them — for hand-rolled clients, v1 servers, or custom flows. It is EVM-only, and it covers only EIP-3009 tokens (canonical USDC and EURC); USDT, native coin, and plain ERC-20s are not exact-payable.

Parse a 402 challenge — parseExactRequirements

Section titled “Parse a 402 challenge — parseExactRequirements”

Given a raw x402 challenge body, pull out the exact rails it offers. It tolerates x402Version 1 or 2 and both the maxAmountRequired and amount field names. It returns [] when the body has no exact entries, and null when the body isn’t a recognisable x402 challenge — so null-guard the result before you iterate it.

import { parseExactRequirements } from '@piprail/sdk'
const res = await fetch('https://api.example.com/report')
const rails = parseExactRequirements(await res.json())
// → ExactAccept[] | null
if (!rails) {
// not a recognisable x402 challenge — nothing to pay
} else {
for (const rail of rails) {
console.log(rail.network, rail.maxAmountRequired, rail.asset)
}
}

Each entry is an ExactAccept — the fields the codec consumes:

FieldMeaning
schemeAlways 'exact'.
networkx402 network slug (e.g. 'base').
maxAmountRequiredAmount in base units.
assetThe EIP-3009 token contract address.
payToRecipient address.
maxTimeoutSecondsHow long the authorization stays valid (defaults to 600).
extraThe server’s claimed EIP-712 domain { name, version } — do not trust it (see below).
description / resourceOptional descriptive fields.

Resolve the chain id — chainIdForExactNetwork

Section titled “Resolve the chain id — chainIdForExactNetwork”

EIP-712 signing needs the chain id, but a 402 carries only a network slug. EXACT_NETWORK_SLUGS maps the slugs PipRail ships with EIP-3009 USDC to their chain ids; chainIdForExactNetwork resolves one, returning null for an unknown slug.

import { chainIdForExactNetwork } from '@piprail/sdk'
chainIdForExactNetwork('base') // → 8453
chainIdForExactNetwork('arbitrum') // → 42161
chainIdForExactNetwork('zksync') // → null — not in the map

The shipped set is ethereum, base, base-sepolia, arbitrum, optimism, polygon, and avalanche.

Read the token’s true domain — readExactDomain

Section titled “Read the token’s true domain — readExactDomain”

This is the one call you should never skip. An EIP-3009 signature is over the token’s EIP-712 domain, and the domain name is not the symbol — canonical USDC’s name is "USD Coin", and EURC is "Euro Coin" on Ethereum/Avalanche but "EURC" on Base. Only the on-chain read is authoritative, so readExactDomain reads name() and version() directly from the contract.

import { readExactDomain } from '@piprail/sdk'
import { createPublicClient, http } from 'viem'
import { base } from 'viem/chains'
const pub = createPublicClient({ chain: base, transport: http() })
const domain = await readExactDomain(pub, '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913')
// → { name: 'USD Coin', version: '2' } — or null

It returns null when the asset is not an EIP-3009 token: it probes authorizationState, which exists only on EIP-3009 tokens and reverts on a plain ERC-20 or USDT. A null domain is your signal that the token isn’t exact-payable — pay it via onchain-proof instead.

Once you hold a signed authorization, encode it into an X-PAYMENT header value. This emits the v1 flat shape{ x402Version, scheme, network, payload } base64-encoded — which is what Coinbase’s original reference emits and what every x402 gate and facilitator still accepts. x402Version defaults to 1 to stay consistent with that flat shape.

import { encodeXPaymentHeader } from '@piprail/sdk'
// `authorization` + `signature` come from the signing step below.
const header = encodeXPaymentHeader({
network: 'base',
authorization, // ExactAuthorization
signature, // Hex (0x…)
})
// → a base64 string for the `X-PAYMENT` header
const url = 'https://api.example.com/report'
await fetch(url, { headers: { 'X-PAYMENT': header } })

The ExactAuthorization it frames is the EIP-3009 message the payer signs: from, to, value, validAfter, validBefore (the three numeric fields are decimal strings on the wire), and a 32-byte hex nonce.

If you sign the authorization yourself, EIP3009_TYPES is the exact TransferWithAuthorization type set the token expects — pass it straight to viem’s signTypedData. The value, validAfter, and validBefore fields are uint256 in the type set but decimal strings on ExactAuthorization, so cast them to bigint before signing — viem rejects a string for a uint256:

import { EIP3009_TYPES, readExactDomain } from '@piprail/sdk'
import type { ExactAccept, ExactAuthorization } from '@piprail/sdk'
import type { Account, PublicClient, WalletClient } from 'viem'
declare const publicClient: PublicClient // for the on-chain domain read
declare const walletClient: WalletClient // your viem wallet client (signs)
declare const account: Account // the EOA paying
declare const accept: ExactAccept // one entry from parseExactRequirements()
declare const chainId: number // from chainIdForExactNetwork(accept.network)
declare const authorization: ExactAuthorization
const domain = await readExactDomain(publicClient, accept.asset)
if (!domain) throw new Error('not exact-payable')
const signature = await walletClient.signTypedData({
account,
domain: { ...domain, chainId, verifyingContract: accept.asset },
types: EIP3009_TYPES,
primaryType: 'TransferWithAuthorization',
message: {
...authorization,
value: BigInt(authorization.value),
validAfter: BigInt(authorization.validAfter),
validBefore: BigInt(authorization.validBefore),
},
})
// → a Hex (0x…) signature to pass to encodeXPaymentHeader / buildExactSignatureHeader

Pair this with BuildExactParams — the parameter shape (account, accept, chainId, now, nonce) the deterministic test/codec primitive accepts.

eip3009Abi is the minimal EIP-3009 ABI a seller needs: both transferWithAuthorization overloads (the 65-byte (v, r, s) form and the (bytes signature) ERC-1271 form), the authorizationState replay check, and name() / version() for the domain. It mirrors Circle’s deployed FiatToken, so you can read or settle against any canonical USDC contract.

import { eip3009Abi } from '@piprail/sdk'
import { createPublicClient, http, type Hex } from 'viem'
import { base } from 'viem/chains'
const pub = createPublicClient({ chain: base, transport: http() })
const token = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' // Base USDC
const payer = '0xYourWallet' // the authorizer
const nonce = '0x…' as Hex // the 32-byte authorization nonce
const used = await pub.readContract({
address: token,
abi: eip3009Abi,
functionName: 'authorizationState',
args: [payer, nonce],
})
// → boolean — true ⇒ this authorization is spent or canceled

buildExactAuthorization builds and signs an authorization in one call, but it is @deprecated: it trusts the server-supplied accept.extra.{name, version} for the EIP-712 domain (a lying or absent value produces a silently-invalid signature) and calls account.signTypedData directly — which is undefined on a bring-your-own JsonRpcAccount. It survives only as a deterministic codec building block for tests.

For real buyer flows, use schemes: ['exact'] on the client. It re-derives the domain on-chain with readExactDomain, refuses a contract / EIP-1271 / EIP-7702-delegated signer before signing, generates a CSPRNG nonce, and signs via the wallet client:

import { PipRailClient } from '@piprail/sdk'
const client = new PipRailClient({
chain: 'base',
wallet: { privateKey: process.env.AGENT_KEY! },
schemes: ['exact'],
})
const res = await client.fetch('https://api.example.com/report') // pays a standard exact server
const data = await res.json()
// → the gated JSON, paid for via exact transparently

See Pay the exact rail (buyer) and Sell the exact rail (seller) for the high-level paths, and the wire codecs for the envelope types these slot into.