# PAYING.md — an agent's from-zero recipe for paying with Thunderpolt

You are an autonomous agent with a shell. **The minimal path is four
steps** — §1 discover, §2 one key, §3 get funded, §4 pay with `exact` — and
needs nothing but `python3` (or `curl` + `jq` + `cast`). Channels (§5–§6) are
the upgrade you take when you'll pay the same merchant more than a handful
of times: one deposit, then every payment is a free signature.

You were given ONE thing: a merchant URL (`SITE_URL`, e.g. the reference
site) or a facilitator URL (`FACILITATOR_URL`). Either resolves the other
(§1). Everything about the **platform** — chain id, token/router addresses,
hub addresses, dispenser/explorer URLs — is discovered from `/meta` at read
time. A specific **merchant** tells you its own `resource`/`price`/`payTo`
via its 402 — that's merchant-owned truth (MERCHANTS.md §2), never something
the facilitator knows or serves. Never hardcode an address from this doc
into your own workflow: every command below reads addresses out of `/meta`
or your own wallet into a shell variable — any `0x...` you see below is a
placeholder shape, not a real address, and every JSON value shown in a
comment is `# example` output from one real run, not a value to reuse.
**Addresses compare case-insensitively** (EIP-55 checksums vary the case of
the same address) — lowercase both sides before any string comparison
(`ascii_downcase` in `jq`, `.lower()` in Python).

**Testnet only.** MockUSDC has no real-world value. POL on Polygon Amoy is
testnet gas, not real money. This is an MVP proof of concept: channel funds
trust the hub operator within the challenge window (Level-1, operator-trusted
settlement — see the repo README's "Epoch-root settlement (M8)" section for
the full trust-model disclosure). Do not model custody on this system.

**Never print private keys, and never send one anywhere.** Store them under
a `chmod 700` directory with `chmod 600` files, and reference them via
`$(cat …)` — never paste a key literally into a command, log, or message.
Every step below signs LOCALLY: the only thing that ever leaves your machine
is a 65-byte signature inside the `X-PAYMENT` header. No facilitator,
merchant, or dispenser ever needs your key — one that asks is not this
platform.

**Run this in one continuous shell session** (or a single script). Every
section below sets shell variables (`PAYER`, `HUB`, `NEW`, ...) that later
sections and later payments read — if your tool runs each snippet in its own
fresh subprocess, variables won't survive between them; combine dependent
steps into one shell invocation, or persist values to files instead.

Operating a channel over time (what a signed ceiling commits you to, resync,
recovering from total state loss, when a receipt is final) lives in
[`OPERATIONS.md`](OPERATIONS.md) — read it before your first channel payment
loop, not before your first `exact` purchase.

---

## 0. Prerequisites

Two paths. Take **A** if your sandbox has no package manager, no `sudo`, or
no way to install Foundry — it needs only `python3` (3.8+) and `curl`, and it
covers the whole minimal path (§1–§4). Take **B** for the channel path
(§5–§6, which needs `cast` for the on-chain deposit) or if you simply prefer
`cast`/`jq`. Both are idempotent and safe to re-run.

### 0-A. Zero-dependency path (python3 only)

```bash
# The appendix signer: stdlib-only keccak-256 + secp256k1 + RFC 6979 ECDSA +
# the EIP-712 TransferWithAuthorization digest, plus the JSON-RPC calls that
# needed ABI encoding. Served by every reference site (and in the repo at
# docs/agents/x402_exact.py). Nothing to pip-install; python3 -m unittest
# docs/agents/test_x402_exact.py cross-checks it against `cast wallet sign`.
curl -sfO "$SITE_URL/x402_exact.py" && python3 x402_exact.py --help | head -3
python3 -c 'import json,hashlib,hmac,urllib.request; print("python OK")'
```

That is the whole install. If `python3 --version` is 3.8 or newer you are
done with §0 — skip to §1 and use the `python3 x402_exact.py …` variant of
each block. (Appendix A lists every subcommand.)

### 0-B. Full toolchain (cast + jq)

```bash
# 0a. See what you already have.
for t in curl jq openssl python3 cast; do
  command -v "$t" >/dev/null 2>&1 && echo "OK      $t" || echo "MISSING $t"
done
```

```bash
# 0b. Foundry (cast/forge) - the official installer, then make `cast`
#     usable IN THIS SAME SHELL SESSION. foundryup installs to
#     ~/.foundry/bin and the installer edits your shell's startup file
#     (~/.bashrc, ~/.zshenv, ...) for FUTURE shells - but this session
#     already started, so it will NOT pick that up on its own. Export PATH
#     yourself and re-verify before moving on.
if ! command -v cast >/dev/null 2>&1 && [ ! -x "$HOME/.foundry/bin/cast" ]; then
  curl -L https://foundry.paradigm.xyz | bash
  export PATH="$HOME/.foundry/bin:$PATH"
  [ -f "$HOME/.foundry/env" ] && . "$HOME/.foundry/env"   # newer installers write this
  foundryup
fi
export PATH="$HOME/.foundry/bin:$PATH"    # idempotent - harmless if cast was already on PATH
cast --version                            # MUST succeed before you continue to §1
```

```bash
# 0c. jq - apt-get first (Debian/Ubuntu, the common agent-sandbox base),
#     brew as a macOS/fallback, else a pointer to your own package manager.
if ! command -v jq >/dev/null 2>&1; then
  if command -v apt-get >/dev/null 2>&1; then
    sudo apt-get update -y && sudo apt-get install -y jq     # sudo may prompt for a password
  elif command -v brew >/dev/null 2>&1; then
    brew install jq
  else
    echo "No apt-get or brew found - install jq with your package manager (yum/dnf/apk/pacman/...) and re-run this block." >&2
  fi
fi
jq --version
```

```bash
# 0d. curl / python3 / openssl are near-universal on both Linux and macOS -
#     detect and tell you what's missing rather than forcing an install.
for t in curl python3 openssl; do
  command -v "$t" >/dev/null 2>&1 || \
    echo "MISSING $t - install it with your distro's package manager (e.g. apt-get install $t) before continuing." >&2
done
```

Re-run 0a; every line should read `OK`. **If a line still reads `MISSING`
and you cannot install (no `sudo`, no egress to the installers, a
read-only image), do NOT loop here — that is exactly what path 0-A is for:
`cast` is only ever needed for the on-chain deposit in §5 and the
`cast`-flavoured blocks, every one of which has a python3 twin.** `jq` is
convenience: any block below that uses it can be re-expressed with
`python3 -c 'import json,sys; …'`.

---

## 1. Discover

Resolve the platform from a `/meta` endpoint — a frozen shape; safe to rely
on the field names below. **If you only have a merchant URL**, its own
`/meta` carries the same platform fields and additionally `urls.facilitator`
— read the facilitator from there first:

```bash
# Given only SITE_URL: the merchant's /meta names its facilitator.
[ -n "${FACILITATOR_URL:-}" ] || FACILITATOR_URL=$(curl -sf "$SITE_URL/meta" | jq -r .urls.facilitator)
```

The facilitator's `/meta` is the canonical discovery root:

```bash
META=$(curl -sf "$FACILITATOR_URL/meta")
CHAIN_ID=$(jq -r .chainId <<<"$META")           # 80002 = Polygon Amoy, 31337 = the hosted devnet / local drill stack
NETWORK=$(jq -r .network <<<"$META")            # "eip155:<chainId>" — the wire label every payload uses
USDC=$(jq -r .usdc <<<"$META")
ROUTER=$(jq -r .router <<<"$META")
HUB=$(jq -r '.hubs[0].hub' <<<"$META")
ENGINE_URL=$(jq -r '.hubs[0].engineUrl' <<<"$META")   # may be "" (private) — see §5 fallback; when set it is a GET-only read proxy (/payments, /epoch/proof, /epoch/<id>/leaves, /stats/*) — AUDIT.md §1
DISPENSER_URL=$(jq -r .urls.dispenser <<<"$META")
EXPLORER_URL=$(jq -r .urls.explorer <<<"$META")
jq .assignment <<<"$META"    # {"rule":"declared-hub-else-rendezvous","quotePin":"hub","paymentField":"payload.hub"}
```

**`RPC_URL` — do you even need one?** The `exact` path (§4) does **not**:
you sign an authorization offline and the facilitator broadcasts the
transfer with its own gas; the only chain reads you might want (`balanceOf`)
are optional. The channel path (§5) does: `approve` + `deposit` are
transactions YOU send. `/meta` never carries an RPC URL; resolve it from the
chain id:

| chainId | RPC_URL |
|---|---|
| 80002 (Polygon Amoy) | `https://rpc-amoy.polygon.technology` |
| 31337 (the hosted Paychannels devnet) | the same origin as the facilitator, path `/rpc` — e.g. `RPC_URL="${FACILITATOR_URL%/facilitator}/rpc"` (nginx proxies it to the devnet chain; the operator's handout names it too) |
| anything else | your operator's handout supplies `RPC_URL` |

`/meta.deployments` (`/meta/deployments`) serves the full address book
verbatim — router, every hub, deploy block — if you need more than `/meta`
exposes directly. `/meta` can list more than one hub in `hubs[]`; this doc
uses `hubs[0]` throughout, but the same recipe applies to any hub in the
array. `assignment` above is what decides which hub a BRAND-NEW payer with
no channel lands on if it never declares one — §6 covers the hub-pin
mandate that keeps a payer WITH a channel off this path entirely; read
`assignment` here as context, not something you compute yourself.

Note what `/meta` deliberately does NOT know: per-resource `price`/`payTo`.
Those are merchant-owned truth — you get them from whichever merchant's 402
you're reading (`$SITE_URL` below, or any other participating site). A
merchant site's OWN `/meta` (the same field set the facilitator's carries,
plus site-specific extras like `prices` and `urls.facilitator`) is the
documented, supported convention that let you bootstrap above; the
facilitator's `/meta` is the one handout that always resolves the platform,
with or without knowing any particular site.

---

## 2. Wallet

**The minimal path needs ONE key** — the payer key that holds the funds and
signs `exact` authorizations:

```bash
# 2-A. python3 only: writes a 0600 key file, prints the address.
mkdir -p .wallets && chmod 700 .wallets
PAYER=$(python3 x402_exact.py keygen --out .wallets/payer.key)
```

```bash
# 2-B. cast: the same one key.
mkdir -p .wallets && chmod 700 .wallets
W="$(cast wallet new)"
PAYER=$(grep -oE '0x[0-9a-fA-F]{40}' <<<"$W" | head -1)
grep -oE '0x[0-9a-fA-F]{64}' <<<"$W" | head -1 > .wallets/payer.key && chmod 600 .wallets/payer.key
unset W
```

**If you already know you'll open a channel (§5), make both keys now** —
the channel binds a second, hot *session* key at deposit time:

```bash
mkdir -p .wallets && chmod 700 .wallets
W="$(cast wallet new)"
PAYER=$(grep -oE '0x[0-9a-fA-F]{40}' <<<"$W" | head -1)
grep -oE '0x[0-9a-fA-F]{64}' <<<"$W" | head -1 > .wallets/payer.key && chmod 600 .wallets/payer.key
S="$(cast wallet new)"
SESSION_ADDR=$(grep -oE '0x[0-9a-fA-F]{40}' <<<"$S" | head -1)
grep -oE '0x[0-9a-fA-F]{64}' <<<"$S" | head -1 > .wallets/session.key && chmod 600 .wallets/session.key
unset W S
```

Why two keys for a channel: the **root** key (`PAYER`) holds and controls
funds — it signs the on-chain `deposit`/exit transactions and its address is
the identity your channel accrues against. The **session** key
(`SESSION_ADDR`) is the hot key that signs every voucher; on-chain `deposit`
binds `signer[payer] = sessionKey`, and the hub only ever accepts vouchers
signed by that binding. Keep the root key offline once the deposit lands — a
leaked session key can, at worst, sign more vouchers against the deposit you
already made; it can never withdraw funds or move them anywhere else. Only
the session key needs to stay hot on whatever host makes payments.
(`python3 x402_exact.py keygen --out .wallets/session.key` makes the second
key without `cast` if you prefer.)

---

## 3. Get funded

The dispenser gives you POL (gas) and MockUSDC (spend) in one call:

```bash
curl -s -X POST "$DISPENSER_URL/fund" -H 'content-type: application/json' \
     -d "{\"address\":\"$PAYER\"}"
# 200 -> {"address":"0x...","pol_wei":"100000000000000000","usdc":"1000000000","pol_tx":"0x...","usdc_tx":"0x..."}
cast balance "$PAYER" --rpc-url "$RPC_URL"                       # >= 0.1 POL
cast call "$USDC" 'balanceOf(address)(uint256)' "$PAYER" --rpc-url "$RPC_URL"   # 1000000000 = 1000 USDC
```

Zero-dependency twin (the funding call, then both balances over plain
JSON-RPC — the script ABI-encodes `balanceOf` for you):

```bash
python3 x402_exact.py fund --dispenser "$DISPENSER_URL" --address "$PAYER"
python3 x402_exact.py balance --rpc "$RPC_URL" --usdc "$USDC" --address "$PAYER"
# {"address":"0x...","pol_wei":"100000000000000000","usdc_base_units":"1000000000"}
```

No-cast alternative for the POL check only — `eth_getBalance` needs no ABI
encoding, so a raw JSON-RPC `curl` does the same job as `cast balance`:

```bash
curl -s "$RPC_URL" -H 'content-type: application/json' \
     -d "$(jq -nc --arg a "$PAYER" '{jsonrpc:"2.0",id:1,method:"eth_getBalance",params:[$a,"latest"]}')" \
  | jq -r .result   # hex wei, same value cast balance prints in decimal POL
```

The balance checks are optional on the exact path — the 200 from the
dispenser (with both tx hashes) is enough to proceed to §4. Some edges sit
behind Cloudflare, which answers a bare default `User-Agent` with 403 `error
code: 1010`: send any explicit `User-Agent` (`curl -A x402-agent …`; the
python script already does).

Retry etiquette (the error body is a tri-state, not a plain error string):
a non-200 body looks like `{"error":"...","polSent":true|false|null,"usdcMinted":true|false|null}`
(note: camelCase here, unlike the success body's snake_case). `false` on both
means nothing was sent — a bounded retry (≤3 attempts) is safe. `null` on
either means "unknown, don't blindly retry" — wait ~30 s, re-check the
on-chain balance yourself, and only retry the leg that's still actually
short. `429` means you hit the rate limit (defaults: 5 funds per address, or
1000 total, per 24 h window) — back off; you don't need more than one fund
for this entire walkthrough.

---

## 4. Pay with exact

The `exact` scheme needs no setup — it's a direct EIP-3009
`transferWithAuthorization` on every payment, verified by the facilitator and
broadcast on-chain by its submitter key. **You need no RPC and no gas**: you
sign offline, the facilitator pays the gas. Two steps, deliberately split:
**sign** (offline, produces the base64 `X-PAYMENT` string) and **send** (one
HTTP request with one header).

### 4.1 Read the merchant's terms

Fetch the 402 from the merchant you're buying from (`$SITE_URL` here — the
reference site; any participating merchant works identically) and pull the
`exact` entry out of `accepts` — every input you need, including the
EIP-712 domain, lives in this one entry, and it is safe to reuse across many
payments to the SAME resource (MERCHANTS.md §2 — merchants are expected to
cache/version this body, not mint it fresh per request):

```bash
curl -s -o /tmp/q402.json -w '%{http_code}' "$SITE_URL/api/paid/mirror"    # -> 402
EX=$(jq -c '.accepts[] | select(.scheme=="exact")' /tmp/q402.json)
```

**Skip the 402 round trip entirely (optional, saves one RTT per new
resource).** A merchant may publish every paid resource's `accepts` up front
at `GET $SITE_URL/.well-known/x402` — the same entries its 402 would serve,
plus `extra.paymentFlow: "upfront"` meaning *the payment may ride your FIRST
request*. The reference site does; a merchant that doesn't answers 404 there
and you fall back to the 402 above. **Match on the path suffix, not the full
URL**: the `resource` a merchant advertises is ITS public base URL + path,
which can legitimately differ from the URL you dialed (an IP-addressed
origin behind a hostname, an `http://` origin behind an `https://` proxy)
— an exact-string match would then select nothing:

```bash
if curl -sf -o /tmp/manifest.json "$SITE_URL/.well-known/x402"; then
  EX=$(jq -c --arg r "$SITE_URL/api/paid/mirror" --arg p "/api/paid/mirror" \
        '.resources[] | select(.resource==$r or (.resource|endswith($p))) | .accepts[] | select(.scheme=="exact")' /tmp/manifest.json)
fi
# The manifest is cacheable (ETag + Cache-Control) — re-validate with
# If-None-Match rather than re-fetching; a 304 means nothing changed.
```

Every entry also discloses `extra.settlementModel` (channel entries:
`"hub-epoch"` — settled through operator-posted epoch roots, AUDIT.md;
exact entries carry none — on-chain per payment). Then continue:

```bash
PAY_TO=$(jq -r .payTo <<<"$EX"); VALUE=$(jq -r .maxAmountRequired <<<"$EX")
ASSET=$(jq -r .asset <<<"$EX");  NET=$(jq -r .network <<<"$EX")
TOK_NAME=$(jq -r .extra.name <<<"$EX"); TOK_VER=$(jq -r .extra.version <<<"$EX")
```

`TOK_NAME` contains a space (e.g. `Mock USD Coin`) — if you persist these variables to a
file you later `source`, quote the values or the shell will split them and you'll sign
with an empty domain name.

### 4.2 Sign (offline)

**Zero-dependency:** the 402 body (or the manifest) + your key → the
`X-PAYMENT` string. No network access; the key never leaves the process.

```bash
python3 x402_exact.py sign --key-file .wallets/payer.key \
        --requirements /tmp/q402.json --resource "$SITE_URL/api/paid/mirror" > /tmp/xp.b64
XP=$(cat /tmp/xp.b64)
```

**cast:** sign the EIP-3009 authorization with `cast wallet sign`'s
typed-data mode:

```bash
NONCE=0x$(openssl rand -hex 32); VALID_BEFORE=$(( $(date +%s) + 3600 ))
cat > /tmp/3009.json <<EOF
{"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],
"TransferWithAuthorization":[{"name":"from","type":"address"},{"name":"to","type":"address"},{"name":"value","type":"uint256"},{"name":"validAfter","type":"uint256"},{"name":"validBefore","type":"uint256"},{"name":"nonce","type":"bytes32"}]},
"primaryType":"TransferWithAuthorization",
"domain":{"name":"$TOK_NAME","version":"$TOK_VER","chainId":$CHAIN_ID,"verifyingContract":"$ASSET"},
"message":{"from":"$PAYER","to":"$PAY_TO","value":"$VALUE","validAfter":"0","validBefore":"$VALID_BEFORE","nonce":"$NONCE"}}
EOF
SIG=$(cast wallet sign --private-key "$(cat .wallets/payer.key)" --data --from-file /tmp/3009.json)
```

`value` must be **at least** `maxAmountRequired` (the facilitator declines
anything less) — sign exactly `maxAmountRequired`. Use a fresh random 32-byte
`nonce` per payment; reusing a nonce that already landed on-chain fails.

**The validity window is checked strictly, with NO clock-skew tolerance.**
The facilitator (`crates/facilitator/src/exact.rs`) requires
`now > validAfter` and `now < validBefore` in whole seconds of ITS clock, at
`/verify` and again at `/settle` before broadcasting; the token contract
re-checks the same inequalities against `block.timestamp` when the transfer
mines. `validAfter: "0"` is therefore always fine; `validBefore` must still
be in the future when the transaction MINES, not just when you send it — if
the window closes while the intent is still un-mined the facilitator marks
it terminally failed (`authorization-window-expired-unmined`, §8) and you
need a fresh nonce AND a fresh payment-id. `date +%s + 3600` (what both
signers above use) is a comfortable default; anything shorter than
`maxTimeoutSeconds` (30 s) plus your clock error is asking for trouble, and
a `validBefore` in the past is `authorization-expired`.

Then build the `X-PAYMENT` header (macOS `base64` wraps its output — always
pipe through `tr -d '\n'`, per crib #23):

```bash
XP=$(jq -nc --arg net "$NET" --arg sig "$SIG" --arg from "$PAYER" --arg to "$PAY_TO" \
     --arg v "$VALUE" --arg vb "$VALID_BEFORE" --arg n "$NONCE" \
 '{x402Version:2,scheme:"exact",network:$net,payload:{signature:$sig,
   authorization:{from:$from,to:$to,value:$v,validAfter:"0",validBefore:$vb,nonce:$n}}}' \
 | base64 | tr -d '\n')
```

### 4.3 Send (one header)

```bash
curl -s -D /tmp/exact.hdr -o /tmp/exact.json -w '%{http_code}' \
     "$SITE_URL/api/paid/mirror" -H "X-PAYMENT: $XP"              # -> 200
grep -i '^x-payment-response:' /tmp/exact.hdr | sed 's/^[^:]*: *//' | tr -d '\r' \
  | base64 -d | jq .                                              # success:true, transaction:0x...
```

Zero-dependency twin (prints the status, the decoded receipt and the body):

```bash
python3 x402_exact.py pay --url "$SITE_URL/api/paid/mirror" --x-payment "$XP"
# or all of 4.1–4.3 in one shot against any paid URL:
python3 x402_exact.py buy --key-file .wallets/payer.key --url "$SITE_URL/api/paid/time"
```

Each exact payment is a real on-chain transfer the facilitator broadcasts for
you — expect well under a second on a local drill stack (1 s block time), up
to `maxTimeoutSeconds` (30 s) on Amoy under congestion. `/api/paid/mirror`'s
body echoes your payment plus per-stage timings; `/api/paid/mirror`'s
`X-PAYMENT-RESPONSE` header (base64) always carries
`{success,transaction,network,payer,scheme,replay}`. You can confirm the
transfer independently with `cast receipt <transaction> --rpc-url "$RPC_URL"`
or on the explorer (§7).

**Header names (x402 v2 aliases).** The reference site accepts
`PAYMENT-SIGNATURE` as an exact alias of `X-PAYMENT` (send either, not both),
emits the receipt in BOTH `X-PAYMENT-RESPONSE` and `PAYMENT-RESPONSE`
(identical values — read whichever your client library expects), and every
402 additionally carries its body base64-encoded in a `PAYMENT-REQUIRED`
header. Other merchants may speak only the `X-` names; everything in this
doc uses those, which work everywhere.

**Stale prices (cached 402s / manifest).** Prices can change under a cached
body. The rule is one-directional and enforced by the hub, not the
merchant: sign against an old, LOWER price and you're declined
(`unauthorized-cumulative` on the channel scheme, `insufficient-payment` on
exact) with a fresh 402 carrying the new price — re-read it and retry; sign
against an old, HIGHER price and you are charged the CURRENT price only
(channel: the ceiling model consumes exactly the price; exact: the
authorization's `value` is what moves — so on exact, re-read the 402 when
in doubt rather than over-paying). You are never charged more than the
current price for a channel payment, and content is never served below it.

**That is the whole minimal path.** Stop here if you needed one purchase.

---

## Upgrade to a channel when you'll make more than a handful of payments

Every `exact` payment is one on-chain transaction: it waits for a block
(up to 30 s), it spends the facilitator's gas (which is why the exact path
is rate-limited per payer and per IP, §8), and it is the slowest thing in
this system by four orders of magnitude. A **channel** costs two
transactions once (`approve` + `deposit`, §5) and then every payment to
every merchant on that hub is a free, sub-millisecond signature (§6). Rule
of thumb: more than ~10 payments, anything latency-sensitive, or any loop —
open a channel. The channel path needs `cast` and `RPC_URL` (§1) for the
deposit; `x402_exact.py` covers the exact scheme only.

---

## 5. Open a channel

Channels cost real gas exactly once (the deposit) and then every payment is
free. You need the session key from §2 (`SESSION_ADDR`). Approve the hub,
then deposit — binding your session key in the same call:

```bash
DEPOSIT=500000000    # 500 USDC of your 1000 - leaves the rest for exact + headroom
cast send "$USDC" 'approve(address,uint256)' "$HUB" "$DEPOSIT" \
     --private-key "$(cat .wallets/payer.key)" --rpc-url "$RPC_URL"
cast send "$HUB" 'deposit(address,address,uint256)' "$PAYER" "$SESSION_ADDR" "$DEPOSIT" \
     --private-key "$(cat .wallets/payer.key)" --rpc-url "$RPC_URL"
```

Now **wait** for the hub's on-chain watcher to see and confirm the deposit
(`CONFIRMATION_DEPTH` blocks: ~30 s on Amoy at the current depth 15 — the
operator's runbook proposes depth 5 ≈ 10 s now that Heimdall v2 finalizes in
~5 s; a few seconds on a local drill stack, which runs depth 3). Check
readiness in this order:

```bash
# (a) ENGINE_URL is known (non-"") - poll the hub directly, BOUNDED to ~30s.
#     A published URL is a hint, not a guarantee it is routable FROM WHERE
#     YOU STAND - never poll it forever; on timeout fall through to (b):
SEEN=""
if [ -n "$ENGINE_URL" ]; then
  DEADLINE=$(( $(date +%s) + 30 ))
  while [ -z "$SEEN" ] && [ "$(date +%s)" -lt "$DEADLINE" ]; do
    curl -sf -m 5 "$ENGINE_URL/stats/hub" | jq -e --arg p "$PAYER" \
      '.payers[]?|select((.address|ascii_downcase)==($p|ascii_downcase))' >/dev/null && SEEN=1 || sleep 2
  done
fi
# (b) ENGINE_URL is "" (private), or (a) timed out unconfirmed: poll the
#     free public explorer for your finalized deposit instead:
[ -n "$SEEN" ] || until curl -s "$EXPLORER_URL/api/address/$PAYER" \
      | jq -e '[.chainEvents[]?|select(.kind=="deposit" and .status=="final")]|length>0' >/dev/null; do sleep 2; done
# (c) else: neither is available - just proceed. A too-early channel payment
#     declines politely (see §8); wait ~30s and retry.
```

**From here on, your hub is pinned for the lifetime of this channel (§8 of
the spec — read as: until you exit).** Every quote/bootstrap request from
now on MUST include this `HUB` — see §6's hub-pin mandate.

---

## 6. Pay through the channel

**Decision rule:** if you have a funded, open channel, always prefer the
channel scheme — it's free. Fall back to `exact` only when you have no
channel (or it's exhausted).

**Local state is normative — read this before your first payment.** You, the
agent, own `(epoch, cumulative)` for every (payer, merchant, hub) triple you
pay through. A merchant's 402 is a cached, generic, non-personalized body
(MERCHANTS.md §2) — its channel entry deliberately OMITS `epoch`, because
epoch is YOUR local truth, never something served to you. You already have
to track `cumulative` to sign monotone vouchers; `epoch` joins it as required
local state, tracked the same way (e.g. one file per hub, or a small local
store keyed by hub+merchant).

**Bootstrap — ONCE per channel, right after your deposit confirms (§5), not
per payment.** Learn your starting `(epoch, cumulative)` baseline with ONE
personalized quote call **directly against the facilitator** (you already
have everything it needs: `resource`/`price`/`payTo` from the merchant's
cached 402, `payer`/`hub` from your own state):

```bash
RESOURCE="https://your.merchant/api/paid/time"   # from the merchant's 402 you already fetched
PRICE="1000"                                     # ditto — this resource's maxAmountRequired
MERCHANT="0x..."                                 # ditto — this resource's payTo
Q=$(curl -sf -X POST "$FACILITATOR_URL/quote" -H 'content-type: application/json' \
    -d "$(jq -nc --arg r "$RESOURCE" --arg pr "$PRICE" --arg me "$MERCHANT" --arg p "$PAYER" --arg h "$HUB" \
         '{resource:$r, price:$pr, payTo:$me, payer:$p, hub:$h}')")
CH=$(jq -c --arg hub "$HUB" \
    '.accepts[] | select(.scheme=="batch-settlement") | select((.extra.contract|ascii_downcase)==($hub|ascii_downcase))' <<<"$Q")
EPOCH=$(jq -r '.extra["thunderpolt-hub/v1"].epoch' <<<"$CH")
echo "$EPOCH" > .wallets/epoch.txt
OLD=0   # a brand-new channel always starts at cumulative 0; on a resync, read your last receipt instead (below)
```

A merchant site that supports personalized 402s the older way (a
`PAYMENT-PAYER` header on a normal request to its paid route, which it
forwards into its OWN `/quote` call) is an equally valid, unaffected
alternative to calling the facilitator directly — use whichever your
merchant offers; both return the same shape. Either way, **always include
your `hub` once you have one** — this is the hub-pin mandate (spec §8): a
`/quote`/personalization request that omits your hub, once you have a
channel, risks getting a stale or wrong-hub answer if the operator has since
added hubs. Your `payload.hub` at PAYMENT time is already mandatory and
unaffected by any of this — that binding alone is what makes stickiness safe
even if you forget the pin somewhere upstream (worst case: a fragmented
extra deposit, never lost funds).

**Steady state — every payment after bootstrap.** No re-quote. Read
`price`/`payTo`/hub `extra.contract` from your merchant's cached, generic 402
(MERCHANTS.md §2's `$CACHED_402`); read `epoch`/`cumulative` from your OWN
local state:

```bash
CH=$(jq -c --arg hub "$HUB" \
    '.accepts[] | select(.scheme=="batch-settlement") | select((.extra.contract|ascii_downcase)==($hub|ascii_downcase))' <<<"$CACHED_402")
PRICE=$(jq -r .maxAmountRequired <<<"$CH"); MERCHANT=$(jq -r .payTo <<<"$CH")
EPOCH=$(cat .wallets/epoch.txt)                              # YOUR local state, not the entry
OLD=$(cat .wallets/cum.txt 2>/dev/null || echo 0); NEW=$((OLD + PRICE))
PAYID="pay_$(openssl rand -hex 14)"
```

`epoch` is **your** channel's current epoch as tracked by the hub (starts at
`0`, only changes if you exit and re-deposit) — it is NOT the hub's
epoch-root id used for settlement. `cumulative` is an **authorization
ceiling**: by signing it you authorize the hub to serve you up to that total
(sum of base-unit prices) for THIS merchant through THIS hub, starting from
0 — never a per-payment delta. Each payment *consumes* exactly the
resource's price from your ceiling; the hub refuses any payment not backed
by remaining signed headroom (`unauthorized-cumulative`, §8). For the
sequential recipe above this changes nothing: signing `NEW = OLD + PRICE`
per payment behaves exactly as it always did. **A signed ceiling is
spendable to its full value — sign only what you're willing to spend**
(OPERATIONS.md §1).

**Concurrent payments — one voucher, K payment-ids.** One-in-flight is NO
LONGER required for the channel scheme. To fire `K` payments to one
merchant concurrently, sign **ONE** voucher with
`cumulative = KNOWN_CUM + K*PRICE` (where `KNOWN_CUM` is your tracked
cumulative) and send it in `K` requests that differ ONLY in their
`payment-identifier` — one signature for the whole burst; the hub dedupes
by payment-id and consumes `PRICE` per request:

```bash
K=16
CEIL=$((OLD + K * PRICE))
# ...sign ONE voucher at cumulative=$CEIL exactly as above (VSIG)...
for k in $(seq 1 "$K"); do
  PAYID="pay_$(openssl rand -hex 14)"
  # build XP from the SAME voucher+VSIG, this $PAYID, and curl in the
  # background — all K may be in flight at once, arrival order is free
done
wait; echo "$CEIL" > .wallets/cum.txt   # persist once the burst settles
```

Any payments the burst does NOT consume (failed requests) stay as unspent
headroom — your next ceiling starts from what the hub actually served, so
after a partial burst resync `cumulative`/`servedSum` from a personalized
quote (below) rather than assuming all K landed. Ordering no longer
matters: the facilitator pipelines same-payer payments and the hub's
accounting is arrival-order independent.

Sign the voucher with your **session** key (not the root key):

```bash
cat > /tmp/voucher.json <<EOF
{"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],
"Voucher":[{"name":"payer","type":"address"},{"name":"merchant","type":"address"},{"name":"epoch","type":"uint64"},{"name":"cumulative","type":"uint256"}]},
"primaryType":"Voucher",
"domain":{"name":"ThunderpoltHub","version":"1","chainId":$CHAIN_ID,"verifyingContract":"$HUB"},
"message":{"payer":"$PAYER","merchant":"$MERCHANT","epoch":"$EPOCH","cumulative":"$NEW"}}
EOF
VSIG=$(cast wallet sign --private-key "$(cat .wallets/session.key)" --data --from-file /tmp/voucher.json)
```

Build the `X-PAYMENT` header — note `payload.hub` is **required** and does
NOT come from anywhere else in this recipe; you set it explicitly to the hub
address you're targeting — then pay, and only persist your local counter on
an actual 200:

```bash
XP=$(jq -nc --arg net "eip155:$CHAIN_ID" --arg p "$PAYER" --arg m "$MERCHANT" --arg e "$EPOCH" \
     --arg c "$NEW" --arg sig "$VSIG" --arg hub "$HUB" --arg id "$PAYID" \
 '{x402Version:2,scheme:"batch-settlement",network:$net,
   payload:{binding:"thunderpolt-hub/v1",voucher:{payer:$p,merchant:$m,epoch:$e,cumulative:$c},signature:$sig,hub:$hub},
   extensions:{"payment-identifier":{id:$id}}}' | base64 | tr -d '\n')
CODE=$(curl -s -D /tmp/ch.hdr -o /tmp/ch.json -w '%{http_code}' "$SITE_URL/api/paid/time" -H "X-PAYMENT: $XP")
[ "$CODE" = 200 ] && echo "$NEW" > .wallets/cum.txt
```

Repeat for as many different paid endpoints as you like — `cumulative` keeps
accumulating across ALL of them for the (payer, merchant, hub) triple, it is
not per-endpoint. Only the `resource` (endpoint URL) and `price` change; keep
tracking `NEW` as one running counter in `.wallets/cum.txt`.

**`stale-epoch` and `unauthorized-cumulative` are resync triggers, not
failures to work around.** If a payment declines `stale-epoch` (§8), your
LOCAL `epoch` has drifted from the hub's (most commonly: you exited and
re-deposited since you last bootstrapped this hub). If it declines
`unauthorized-cumulative`, your local counter has drifted BELOW the hub's
recorded ceiling (or you reused a fully-consumed ceiling). Both are
expected, occasional behavior — never a steady-state cost: make ONE
personalized quote exactly like the bootstrap block above. A personalized
quote's channel entry now carries the pair's live state in
`extra["thunderpolt-hub/v1"]`: `cumulative` (the highest ceiling the hub
has recorded for you and this merchant) and `servedSum` (how much of it is
consumed). Overwrite `.wallets/epoch.txt` from `epoch` and
`.wallets/cum.txt` from `cumulative`, then resume — signing
`C = cumulative + PRICE` is ALWAYS serviceable. One decline + one quote,
done.

Lost your hub pin along with everything else? Receipts and durability?
Both are operational topics — OPERATIONS.md §3 (total state loss:
`GET $FACILITATOR_URL/payer/$PAYER/channels`) and §4 (a receipt is
provisional until visible in `GET /payments`).

**Shortcut for repo holders** (optional — the recipe above needs no repo
checkout at all): if you have this repo built, `engine gen-payment` prints a
ready X-PAYMENT header to stdout (diagnostics go to stderr, safe to capture
with `$(...)`), but it does not know about `payload.hub` — inject it with jq:

```bash
HDR=$(./target/release/engine gen-payment --payer-address "$PAYER" --session-key "$(cat .wallets/session.key)" \
      --cumulative "$NEW" --payment-id "$PAYID" --chain-id "$CHAIN_ID" --hub "$HUB" --merchant "$MERCHANT")
XP=$(base64 -d <<<"$HDR" | jq -c --arg hub "$HUB" '.payload.hub=$hub' | base64 | tr -d '\n')
```

---

## 7. Verify yourself

Three independent ways to confirm a payment actually happened — don't just
trust the 200:

```bash
# 1. the receipt you already hold, decoded from X-PAYMENT-RESPONSE:
#    success, replay, and (channel only) receipt.{seq,cumulative,delta,paymentId}
# 2. (mirror-specific) /api/paid/mirror's body echoes the payment that
#    bought it (scheme/seq/delta/paymentId/timings) - that echo is that ONE
#    endpoint's feature, not a general rule; other paid endpoints' bodies
#    (/time, /report, other merchants) don't reflect your payment at all
# 3. the free, public explorer API - independent of anything you hold locally:
curl -s "$EXPLORER_URL/api/payments?address=$PAYER&limit=10" \
  | jq '.payments[] | {scheme, paymentId: .clientPaymentId, amount, finality}'
curl -s "$EXPLORER_URL/api/address/$PAYER" | jq .
# UI (human-readable, same data): open $EXPLORER_URL/web/explorer.html#/address/$PAYER
```

Note: the indexer's JSON field for your payment-id is `clientPaymentId`
(camelCase), not `paymentId` — the alias above (`paymentId: .clientPaymentId`)
just relabels it for readability. `/api/summary`'s amount fields
(`totals.channel.amount`, etc.) are all decimal **strings**, not numbers —
don't feed them straight into arithmetic without parsing. The explorer
lowercases addresses in its output — compare case-insensitively (top of this
doc).

Finality lifecycle: exact payments start `pending` and promote to `final` at
chain confirmation depth; channel payments are `final` as soon as the hub's
journal records them (they don't wait on a chain event — the chain only
enters the picture later, at claim time; see OPERATIONS.md §4 for the narrow
async-mode exception). The indexer polls on an interval (~1 s by default),
so a payment you just made may take a moment to appear via the explorer even
though your own receipt already proves it happened.

---

## 8. Errors and etiquette

Decline reasons you may see in a 402 body's `error` field (the accompanying
`accepts` array is always a fresh quote — re-read it, don't reuse a stale
one):

| `error` (prefix) | Meaning | What to do |
|---|---|---|
| `invalid-signature: voucher signature verification failed` | Either the hub has no deposit/session-key registered for this payer yet, OR you signed with the wrong key — the two are indistinguishable from this message alone (the hub deliberately does not reveal which, to avoid leaking registry state to an attacker) | If you just deposited, wait for watcher confirmation and retry (§5); otherwise double-check you signed the voucher with the SESSION key, not the root key |
| `insufficient-channel-balance: ...` | This payment's delta exceeds what's left in your deposit | Deposit more, or fall back to `exact` |
| `unauthorized-cumulative: ...` | This payment isn't backed by remaining signed authorization: your ceiling is fully consumed, or your local counter drifted below the hub's recorded ceiling | The §6 resync: ONE personalized quote, read the channel entry's `cumulative`/`servedSum`, sign `C = cumulative + PRICE`, resume |
| `duplicate-payment: ...` | Zero-value payment (older hubs' stale-counter decline; on current hubs you'll see `unauthorized-cumulative` instead) | Same resync as `unauthorized-cumulative` |
| `stale-epoch: ...` | Your LOCAL `epoch` (§6) doesn't match your CURRENT channel epoch | This is the resync trigger, not a failure — make ONE personalized quote (§6's bootstrap block) to relearn your baseline, then resume. Never a steady-state cost. |
| `channel-frozen: ...` | You have a pending exit in progress | Channel payments are blocked until the exit resolves; use `exact` meanwhile |
| `unsupported-requirements: ...` | Malformed/mismatched selection (e.g. `payload.hub` doesn't match any live hub, or scheme unrecognized) | Re-check `payload.hub` against a LIVE `/meta` hub address |
| `payment-identifier-conflict: ...` | Same payment-id reused against a different resource/price (§6) | Never happens if you mint a fresh id per payment; if it does, mint a new one |
| `merchant-cap-exceeded` (`errorReason`) | This payer has already paid the hub's configured cap of distinct merchants (default 256) within its current channel epoch — an abuse guard, not something a normal payer paying real merchants should ever hit | Fall back to `exact` for this merchant; the cap resets on your next exit/re-deposit |

Exact-scheme declines (`errorReason`), all from the facilitator's offline
gates in §4.2's order:

| `errorReason` | Meaning | What to do |
|---|---|---|
| `invalid-signature` | The recovered signer isn't `authorization.from` — wrong key, wrong EIP-712 domain (`extra.name`/`version`/`asset`/chainId), or a value that differs from what you signed | Re-read the `exact` entry (§4.1), sign again |
| `insufficient-payment` | `value` < `maxAmountRequired` (a stale, lower cached price) | Re-read the 402, sign the current price |
| `authorization-not-yet-valid` / `authorization-expired` | `now <= validAfter` or `now >= validBefore` on the facilitator's clock (strict, no tolerance — §4.2) | Sign again with `validAfter: "0"` and a `validBefore` an hour out |
| `authorization-nonce-already-used` | That 32-byte `nonce` already moved funds on-chain | Fresh random nonce |
| `authorization-window-expired-unmined` | TERMINAL: the window closed before the intent mined | Fresh nonce AND fresh payment-id (see below) |
| `insufficient-payer-balance` | `balanceOf(from)` < `value` | §3 (the dispenser), or a smaller purchase |
| `settlement-pending` | Your identical retry arrived while the first broadcast is still un-mined | Wait, retry the SAME `X-PAYMENT` |

Other statuses: `409` with `payment-identifier-conflict` is the same
condition as above, hit via a narrower race — treat it identically (new id,
retry). `503` is fail-closed (facilitator or hub unreachable, or the
exact-scheme's daily gas budget is momentarily exhausted —
`errorReason:"gas-budget-exhausted"`, channel payments unaffected) — retry
the SAME `X-PAYMENT` later, never a fresh payment-id. `429` with
`errorReason:"rate-limited"` means you're over the exact scheme's per-payer
or per-IP rate limit — back off; the channel path is unaffected. `429` with
`errorReason:"overloaded"` (`Retry-After: 1`) means the hub's settle queue
was momentarily full — nothing was applied or enqueued, so the identical
retry is safe immediately after the backoff. `429` from the dispenser is its
own, unrelated rate limit — back off, you don't need repeated funding. `403`
with a Cloudflare `error code: 1010` body is the edge rejecting a bare
default `User-Agent` — send an explicit one (§3).

**One exception to "retry the SAME payment-id": after a TERMINAL failure,
retry with a NEW payment-id.** A payment-id is first-claim-wins — once the
facilitator resolves it to a terminal `failed` state (exact scheme only;
the only trigger is the authorization window expiring before it ever
mined), that outcome is bound to this payment-id FOREVER: retrying the same
`X-PAYMENT` (same payment-id) replays the SAME failure, it does not get a
fresh chance. Everything else in this doc's retry contract — `503`,
`429`, a merely-`pending` exact intent, any channel-scheme decline — is
safe to retry with the SAME payment-id; only a genuine terminal failure
needs a fresh one.

**Don't hammer.** This is a shared testnet stack. Pace discovery/polling
calls at roughly 1/s or slower, and keep payment loops to a few per second
unless you've specifically read `STRESS-TESTING.md` and are following its
etiquette rules for a deliberate load test.

---

## Appendix A — `x402_exact.py`, the zero-dependency exact signer

Stdlib-only Python (3.8+): Keccak-f[1600], secp256k1 in Jacobian
coordinates, RFC 6979 deterministic ECDSA with EIP-2 low-`s` and `v` ∈
{27, 28} (byte-identical to `cast wallet sign --data`), the EIP-712
`TransferWithAuthorization` digest exactly as the facilitator recomputes it
(`extra.name`/`extra.version` from the `exact` entry, defaulting to
MockUSDC's `Mock USD Coin`/`2`), and `urllib` for the three HTTP calls.
Fetch it from any reference site (`$SITE_URL/x402_exact.py`) or the repo
(`docs/agents/x402_exact.py`; `docs/agents/test_x402_exact.py` is its
unittest — the cast cross-check skips when `cast` is absent).

| subcommand | network? | what it does |
|---|---|---|
| `keygen --out FILE` | no | random key → `FILE` (0600, refuses to overwrite), prints the checksummed address |
| `address --key-file FILE` | no | address of a key |
| `fund --dispenser URL --address A` | yes | `POST URL/fund` (§3) |
| `balance --rpc URL --usdc ADDR --address A` | yes | `eth_getBalance` + ABI-encoded `balanceOf` over JSON-RPC (§3) |
| `sign --key-file F --requirements 402.json --resource URL [--valid-secs 3600] [--payment-id ID]` | **no** | the base64 `X-PAYMENT` on stdout; picks the `exact` entry by exact `resource`, then by path suffix (§4.1); works on a 402 body or the `/.well-known/x402` manifest |
| `pay --url URL --x-payment B64` | yes | `GET URL` with the header; prints `{status, receipt, body}` with the receipt base64-decoded |
| `buy --key-file F --url URL` | yes | 402 → `sign` → `pay` in one shot (prints the signing time on stderr) |

Runtime: signing is ~10–20 ms of pure Python on a laptop (a whole `sign`
invocation, interpreter start-up included, ≈ 50–80 ms); `keccak256` is the
slow primitive, fine for one payment at a time, not a load generator (use
`loadgen`, STRESS-TESTING.md). It does not do the channel scheme: the
voucher digest would be a ten-line addition, but the deposit is a raw
transaction (RLP + EIP-1559 signing) that `cast send` already does — §5.
