Hard caps. Scoped sub-agent budgets. Signed receipts. The payment rail is x402 — Tollgate is the control plane on top of it.
The delegation tree, draining live. One worker is capped, one is revoked, one is still spending — while the supervisor's own pool empties above them.
| Why this is not just x402 | The one-paragraph answer |
| Build status | What is done and what is not |
| Architecture | The component map and the two rules |
| The dashboard | Three views, with screenshots |
| Quick start | Zero to a paid response in 12 seconds |
| Walkthrough: paying | 402 → sign → 200 |
| Walkthrough: delegation | The headline feature |
| HTTP API | Every endpoint |
| Correctness | The atomic claim, TTLs, the crash bridge |
| Security | Replay, SSRF, front-running |
| Measured | Numbers, not assertions |
| Known boundaries | What is mocked, stated plainly |
The first question anyone asks, so it is answered here rather than further down.
x402 solved the payment half of agent API access: an agent pays per request in USDC with no signup, no card, and no legal person behind the account. It deliberately does not solve the control half, and that is the whole of what Tollgate adds:
| x402 gives you | x402 leaves out | Tollgate |
|---|---|---|
| A 402 with machine-readable payment requirements | Nothing stops a runaway loop. x402 settles what it is told to settle | Per-request and total caps enforced atomically before the upstream call, in one Lua script |
| A payment header the server verifies and settles | A parent agent cannot give a sub-agent a scoped budget without sharing its key or running a wallet fleet | EIP-712 capability chains: attenuation-only, verified at the gateway, no round-trip to the parent |
| A transfer on a block explorer | No artifact proving what was bought — which endpoint, which sub-agent, whether the upstream even returned 200 | A signed receipt per charge and a signed expense report rolling up the delegation tree |
Everything on the wire is x402. An off-the-shelf x402 client pays a Tollgate 402 with no
Tollgate-specific code, because inventing a protocol was an explicit non-goal (prd.md §13
decision 1).
All 46 tasks of plan.md Phases 0–9 are complete and committed, and Phase 10 is in progress.
One item is outstanding and it is load-bearing: Task 6.6, the live Base Sepolia deployment and
settlement, is deferred. Every settlement to date has run against a local anvil fork of Base
Sepolia — with real native USDC, so the EIP-712 verification, the authorizationState replay
guard and the balance accounting are the real contract's — but no transaction has landed on the
public testnet. See NOTES.md.
| Phase | Scope | State |
|---|---|---|
| 0 | Workspace, Docker Compose, config, pools, /health |
✅ complete |
| 1 | Postgres schema, provider registry, mock upstream | ✅ complete |
| 2 | x402 402 responder, header decode, field validation, EIP-3009 recovery, metrics | ✅ complete |
| 3 | Atomic Lua claim, policy module, status codes, reservation-stream drain | ✅ complete |
| 4 | SSRF resolve/validate/pin, streaming proxy, promote-or-void | ✅ complete |
| 5 | Capability EIP-712 type, chain decode + verification, multi-level decrement, revocation, boot rebuild | ✅ complete |
| 6 | BatchSettler contract and settler worker |
🟡 6.1–6.5 complete, 6.6 (live Base Sepolia run) deferred |
| 7 | Signed receipts and expense report | ✅ complete |
| 8 | Python client library | ✅ complete |
| 9 | Dashboard: provider, delegation tree, public feed | ✅ complete |
| 10 | Demo agents, load test, docs, hardening | 🟡 10.1–10.3 complete, 10.4 (demo rehearsal) outstanding |
RPC_URL, USDC_ADDRESS and BATCH_SETTLER_ADDRESS are consumed by the settler and must point at
a real chain and a deployed contract for it to do anything.
Requirements status: prd.md F1, F2, F3, F4, F6, F7, F8 and F9 are implemented. F5
(settlement) is built and proven against a Base Sepolia fork but not yet demonstrated on the
live testnet.
┌────────────────────────────┐
Supervisor agent │ TOLLGATE GATEWAY │
│ signs EIP-712 │ Rust / Axum / Tower │
│ capability │ │──► Upstream API
▼ (no server call) │ · x402 402 responder │ (provider's service)
Worker agents ── HTTP ───►│ · EIP-3009 verifier │
(+ tollgate-client) │ · capability chain walker │
▲ │ · atomic policy claim │
│ 402 + requirements │ · SSRF-pinned proxy │
└─────────────────────│ · receipt signer │
└──────┬─────────────────────┘
│
┌──────────────┼───────────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌──────────────┐
│ Redis │ │ Postgres │ │ SETTLER │
│ budgets │ │ ledger │ │ worker │
│ nonces │ │ providers │ │ (Rust) │
│ caps │ │ capabs │ └──────┬───────┘
│ reserve │ │ receipts │ │ one tx per batch
│ stream │ └───────────┘ ▼
└───────────┘ ▲ ┌─────────────────┐
│ │ BASE SEPOLIA │
┌───────┴────┐ │ USDC (EIP-3009)│
│ DASHBOARD │ │ BatchSettler │
│ Next.js 14 │ │ (holds nothing)│
└────────────┘ └─────────────────┘
Two rules the whole design follows (architecture.md §1):
Nothing touches a chain inside the request path. The gateway verifies a signature locally, claims budget in Redis, and proxies. Settlement runs on a separate timer, in batches. Break this and the p99 becomes block time.
Do not invent a protocol. x402 already defines the 402 shape, the payment header and the
exact scheme. The build days go into the layer x402 does not have: delegation, atomic caps, and
signed receipts.
A worker agent with its own keypair calls a paid endpoint through the gateway and gets data back, having paid for it, with every cap in its delegation chain enforced atomically before the upstream was ever contacted:
1. GET /v1/proxy/mock-ok/ok -> 402 + x402 payment requirements
2. same request
X-PAYMENT: <base64 EIP-3009 authorisation>
X-Tollgate-Capability: <base64 capability chain> (optional)
-> 200 + real upstream body
Between those two lines the gateway does, in this order — cheap checks before crypto, crypto before the Redis round-trip, and never an upstream call before the claim succeeds:
- decode the
X-PAYMENTheader (base64 → JSON → typed) - seven cheap field checks (expiry, validity window, network, scheme,
payTo, amount) - EIP-3009 signature recovery with
alloy, high-srejected, recovered address must equalauthorization.from - capability chain walk: depth, signatures, parent links, attenuation, subject binding, revocation, expiry, allowlist
- one Lua script: nonce claim + per-level cap checks + per-level decrement + reservation
XADD, all atomic - proxy to the upstream over a pinned-IP connector, response streamed back
- 2xx promotes the reservation to
queued; anything else voids it and credits every level back
Next.js 14, polling the gateway. Three views, because architecture.md §7.4 is blunt about why:
"The delegation tree is the product surface. Without it this reads as invisible infrastructure."
cd dashboard && npm run dev # http://localhost:3000The screen that matters most, caught at the moment prd.md §9 step 3 describes: one worker has
hit its cap and stopped, its two siblings are still going, and the supervisor's pool above them is
draining from all three. A capped node turns red and reads capped; a revoked one is struck
through; siblings keep filling.
Every charge decrements every capability in the chain, which is what makes the supervisor's bar move at all — and what makes all three workers stop at once when it empties, even though none of them individually hit its own cap.
Register an upstream, set a price per request, get paid to your own wallet. No billing code, no
signup for the agent. Submit an upstream that resolves into private address space and the form
refuses it inline with unsafe_upstream.
Latency is shown as two separate numbers — Tollgate's added time and the upstream's — because
prd.md §8 warns they will be conflated otherwise. The settlement panel reads — rather than a
fabricated zero until a batch actually settles.
- Rust stable (built against 1.93.1;
rust-toolchain.tomlpins the channel) - Docker and Docker Compose
sqlx-cli(cargo install sqlx-cli --no-default-features --features postgres)- Python 3 with
eth-account, for the signing harness (.venvin this repo has it)
./scripts/dev.shThis runs docker compose up -d, waits for both healthchecks, and applies the migrations with
sqlx migrate run. Redis runs with --appendonly yes --appendfsync everysec, which
architecture.md §9.3 requires — the reservation stream is a crash bridge and an unflushed AOF
would defeat it.
There is no dotenv loader in the binary — Config::from_env reads the process environment only, and
fails loudly at startup on any missing required variable rather than defaulting a secret. Copy the
template and source it:
cp .env.example .env
set -a; source .env; set +a.env is gitignored. Every variable is documented in .env.example; the full reference is
below.
cargo run -p tollgate-gateway
curl -s localhost:8080/health # {"status":"ok","postgres":true,"redis":true}The gateway self-migrates on boot (sqlx::migrate!), SCRIPT LOADs the claim script so the first
paid request pays for an EVALSHA and not a NOSCRIPT round-trip, rebuilds every capability spend
counter from Postgres before binding the listener, then starts the reservation drain.
python3 examples/mock_upstream.py &
TOLLGATE_ALLOW_LOOPBACK_UPSTREAM=true cargo run -p tollgate-gateway # see the SSRF note
./scripts/seed_providers.shexamples/mock_upstream.py is a stdlib-only server on http://127.0.0.1:9000:
| Route | Behaviour |
|---|---|
GET /ok |
200 {"result":"ok"} |
GET /slow |
sleeps 5s, then 200 — exercises the timeout path |
GET /fail |
500 — exercises the void path |
GET /echo |
200, echoing received headers and query |
scripts/seed_providers.sh registers mock-ok, mock-slow (timeout_ms: 1000) and mock-fail
against it, all priced at 0.001 USDC.
pip install -e client/
curl -s -X POST localhost:8080/v1/providers -H 'content-type: application/json' \
-d '{"slug":"quickstart","upstream_url":"http://127.0.0.1:9000",
"price":"0.001","payout_address":"0x0000000000000000000000000000000000000001"}'
python3 client/examples/quickstart.pyThat curl is the whole provider path — no signup, no billing code — and quickstart.py is the
agent path: it seeds a budget for a fresh keypair, pays a 402 transparently, and prints the
upstream body with a receipt signature.
client/README.md is the client's own documentation: install, the one-curl provider path, the
delegation sample, the exception table, and what is mocked.
Time from zero to a paid response: 12 seconds, measured. prd.md §8 budgets 3 minutes for
provider integration. The measurement starts from a torn-down stack — docker compose down -v, so
no containers and no database, .env deleted, and tollgate-client uninstalled — and covers
pip install -e client/, ./scripts/dev.sh, sourcing .env, starting the mock upstream, starting
the gateway with cargo run -p tollgate-gateway, waiting for /health, registering a provider with
one curl, and running quickstart.py through to a 200 and a printed receipt signature.
The one thing it assumes is a warm cargo cache: the Rust workspace is already built, so
cargo run re-links rather than compiling from scratch. A first-ever build of the dependency tree
takes several minutes and is a one-time toolchain cost, not part of integrating a provider, which is
what the prd.md metric measures.
curl -si localhost:8080/v1/proxy/mock-ok/ok{
"x402Version": 1,
"accepts": [{
"scheme": "exact",
"network": "eip155:84532",
"maxAmountRequired": "1000",
"asset": "0x…USDC",
"payTo": "0x…provider payout address",
"resource": "http://localhost:8080/v1/proxy/mock-ok/ok",
"description": "",
"mimeType": "",
"maxTimeoutSeconds": 300,
"extra": { "name": "USDC", "version": "2" }
}],
"error": "payment required"
}Spec-shaped deliberately: network is CAIP-2 and comes from CHAIN_ID, amounts are base-unit
decimal strings, extra carries the EIP-712 domain a client needs to sign against USDC, and
maxTimeoutSeconds is QUOTE_VALIDITY_SECONDS. The exact key casing is pinned by a fixture at
crates/tollgate-gateway/tests/fixtures/x402_402_response.json, so a field rename breaks a test.
Interop check outstanding. The Phase 2 Gate asks that an off-the-shelf x402 client — one nobody wrote for Tollgate — parse this 402 and produce a header the gateway accepts. That has not been recorded as run. The shape is asserted against the fixture and against the published schema, which the gate allows as a substitution, but the live third-party-client demonstration is still owed.
examples/sign.py is a throwaway signing harness (stdlib + eth-account) that exists so the
verification steps in plan.md can actually be run. It is not the Phase 8 client library.
export PAYER_KEY=0x… # a funded test key
export PAYOUT=0x0000000000000000000000000000000000000001 # the provider's payout address
P=$(python3 examples/sign.py payment \
--key "$PAYER_KEY" --to "$PAYOUT" --value 0.001 \
--usdc "$USDC_ADDRESS" --chain-id 84532)
curl -si localhost:8080/v1/proxy/mock-ok/ok -H "X-PAYMENT: $P"Without a capability header this charges a single payer-level budget, seeded with
POST /v1/budgets. Replay the same header and you get 409; exhaust the budget and you get 402 insufficient_funds; exceed the per-request cap and you get 429 cap_exceeded.
sign.py subcommands: address, payment, chain, revoke, verify-fixture. The last one
re-derives tests/fixtures/capability_vectors.json in Python and asserts it matches the Rust
implementation byte for byte — a cross-language check on the EIP-712 encoding, a phase before the
Python client needs it.
This is the headline feature (prd.md decision #4, F4). A supervisor signs a capability for a
worker offline, with no server round-trip; the worker presents the chain on every request; every
charge draws down every ancestor.
EIP-712 typed data, signed by the issuer, presented by the subject:
struct Capability {
address issuer; // who is granting
address subject; // who may spend
bytes32 parent; // hash of the parent capability, 0x0 for a root
uint256 maxTotal; // cumulative ceiling, in token base units
uint256 maxPerRequest;
bytes32 allowlistHash; // keccak of the sorted provider-slug list, 0x0 = inherit
uint64 notBefore;
uint64 expiry;
uint32 maxDepth;
bytes32 salt;
}Domain: name: "Tollgate", version: "1", chainId from config, no verifyingContract. The
chainId in the domain is the cross-chain replay guard. allowlistHash is
keccak256(slugs sorted lexicographically, joined with "\n") — a plain sorted-list hash, not a
merkle root (plan.md A9).
Write a spec describing intent; the harness computes each parent from the previous link, so it
cannot accidentally emit a chain that fails the parent-link rule:
[
{"issuer_key": "0xSUPERVISOR", "subject": "0xSupervisorAddr", "max_total": "1.00",
"max_per_request": "0.05", "allowlist": ["mock-ok", "mock-fail"],
"expiry_seconds": 3600, "max_depth": 3},
{"issuer_key": "0xSUPERVISOR", "subject": "0xWorkerAddr", "max_total": "0.10",
"max_per_request": "0.05", "allowlist": ["mock-ok"],
"expiry_seconds": 1800, "max_depth": 2}
]CHAIN=$(python3 examples/sign.py chain --spec worker_a.json --chain-id 84532 --hashes)
curl -si localhost:8080/v1/proxy/mock-ok/ok \
-H "X-PAYMENT: $P" -H "X-Tollgate-Capability: $CHAIN"Walked root-first, rejecting on the first failure (architecture.md §5.2):
| # | Rule | Failure |
|---|---|---|
| 1 | depth ≤ 4, checked before any signature work | 400 chain_too_deep |
| 2 | every link's signature recovers to its declared issuer |
400 bad_signature |
| 3 | issuer(i) == subject(i-1) and parent(i) == hash(link i-1); root's parent == 0x0 |
400 broken_chain |
| 4 | attenuation only: maxTotal, maxPerRequest, expiry non-increasing; notBefore non-decreasing; maxDepth strictly decreasing; allowlist a subset |
400 attenuation |
| 5 | subject(last) == the EIP-3009 payment signer |
403 subject_mismatch |
| 6 | no link's hash in tg:revoked |
403 capability_revoked |
| 7 | now within every link's [notBefore, expiry] |
403 capability_expired / capability_not_yet_valid |
| 8 | the requested slug is in the effective allowlist | 403 provider_not_allowed |
Rule 1 comes first because it is a denial-of-service guard: a 500-link chain must be refused without 500 signature recoveries. An attenuation violation is a 400, not a 403 — a child claiming more than its parent granted is a malformed chain, not a policy decision.
One charge increments the spend counter of every link in the chain, inside the same Lua execution. That is what makes a supervisor's pool visibly drain as three workers spend in parallel, and what makes all three stop at once when the supervisor empties even though none of them hit its own cap.
Both refusals name the level that said no, and carry the same diagnostic body:
{
"reason": "cap_exceeded",
"tripped_level": 1,
"capability_hash": "0x…",
"limit": "50000",
"chain": [
{"hash": "0x…", "subject": "0x…", "max_total": "1000000", "spent": "300000", "remaining": "700000"},
{"hash": "0x…", "subject": "0x…", "max_total": "100000", "spent": "100000", "remaining": "0"}
]
}The 402 carries reason, tripped_level, capability_hash and the same per-level chain
alongside the full payment-requirements body, so either refusal is equally diagnosable — a
supervisor whose pool emptied needs to know it was the root that stopped its workers and not their
own caps. One exception: a payer who never seeded a budget at all gets a plain 402 insufficient_funds with no chain, since there are no levels to report.
Which code you get depends on which cap tripped, and the distinction is deliberate
(architecture.md §4.3, and NOTES.md decision D11):
- exhausting a total cap →
402 insufficient_funds, plus the requirements body - violating a per-request cap →
429 cap_exceeded
Without X-Tollgate-Capability a request charges exactly one level — a payer-level budget keyed by
keccak(payer) and seeded by POST /v1/budgets. prd.md F3 (caps) is a standalone requirement
that does not mention delegation, so both paths build the same Vec<CapLevel> through one function
and nothing downstream branches on whether delegation was used. See NOTES.md decision D1.
Off-chain, in a Redis set, and that is sufficient because the gateway is the enforcement point — nothing needs to reach a chain for a revocation to take effect.
BODY=$(python3 examples/sign.py revoke --key 0xISSUER --hash 0x<capability hash> --chain-id 84532)
curl -X POST localhost:8080/v1/capabilities/0x<hash>/revoke \
-H 'content-type: application/json' -d "$BODY"Where <capability hash> comes from, since every endpoint that takes one also needs one to
begin with. examples/sign.py chain --hashes does not print it — despite the name it prints
each link's subject address. The working sources are the Python client, which is the intended
one:
chain = supervisor.delegate(subject=worker, max_total="0.10", max_per_request="0.05")
chain.root_hash(), chain.leaf_hash() # the two you actually wantor, without the client: examples/agents/supervisor.py setup prints the root hash, any 402
or 429 refusal body carries capability_hash plus a per-level chain of hashes, and the
dashboard's delegation tree shows one per node.
Signed by the capability's issuer or any ancestor issuer — an ancestor can revoke a descendant,
and revoking a parent blocks every child whose chain includes it. A revocation from an unrelated key
is 403 not_authorised_to_revoke. Revocations may not be future-dated.
Every endpoint — click to expand
| Method | Path | Purpose |
|---|---|---|
GET |
/health |
Liveness of Postgres and Redis. 200 both up, 503 with the failing component false |
GET |
/metrics |
Prometheus text format |
POST |
/v1/providers |
Register an upstream. 201; 409 duplicate_slug; 400 invalid_price / invalid_address / unsafe_upstream |
GET |
/v1/providers |
List providers |
GET |
/v1/providers/{slug} |
One provider; 404 provider_not_found |
POST |
/v1/budgets |
Dev only. Seed a payer-level budget: {payer, max_total, max_per_request} |
GET |
/v1/capabilities/{hash} |
Stored capability with revoked_at and live spend from Redis |
POST |
/v1/capabilities/{hash}/revoke |
Signed EIP-712 revocation |
GET |
/v1/receipts/{nonce} |
Signed receipt for a charge, plus tx_hash once settled; 404 receipt_not_issued if the charge was voided or is still in flight |
GET |
/v1/expense-report |
?capability=0x… for a delegation subtree, or ?payer=0x… for a payer-level budget. Signed; see below |
GET |
/v1/gateway-key |
The address that signs receipts and expense reports, and the EIP-712 domain to verify them under |
GET |
/v1/feed |
?limit=50&since=<ISO timestamp> — recent charges, newest first, payer address in full |
GET |
/v1/stats |
Live panel: gateway and upstream latency percentiles, batch size, gas per batch and per charge, queue depth, mean queued→settled |
ANY |
/v1/proxy/{slug}/{*path} |
The paid path |
Every error uses one shared body, defined once in tollgate-core/src/error.rs:
{"error": {"code": "invalid_price", "message": "price must be greater than zero"}}Prices are submitted as decimal USDC strings ("0.001") and converted server-side to base units
(6 decimals) — that request registers a provider charging 1000 wei of USDC per call.
A report is a variable-depth tree, which EIP-712 cannot hash directly, so the signature covers
ExpenseReport { bytes32 bodyHash; uint64 timestamp; } under the same Tollgate domain receipts
use. bodyHash is keccak256 of the response body with generated_at and signature removed —
neither can be inside its own digest — serialised as compact JSON with sorted keys:
body = {k: v for k, v in report.items() if k not in ("generated_at", "signature")}
body_hash = keccak(json.dumps(body, sort_keys=True, separators=(",", ":")).encode())
# then recover ExpenseReport{bodyHash, timestamp=report["generated_at"]} and compare
# against the address from GET /v1/gateway-keyAmounts in the report are decimal USDC strings ("0.42"), not base units, and figures come from
the Postgres ledger rather than the Redis counters. voided charges are excluded from every
total; failed ones are included and also counted in failed_count. See NOTES.md D15 and D16.
| Situation | Response |
|---|---|
| No payment header | 402 + payment requirements |
Signature invalid, expired, wrong network/scheme/payTo/amount |
402 + requirements + an error naming the reason |
| Nonce already claimed | 409 nonce_replayed, no budget change |
| Total cap exhausted | 402 insufficient_funds + tripped level + per-level chain |
| Per-request cap exceeded | 429 cap_exceeded + tripped level + per-level chain |
| Capability expired or revoked | 403 |
| Capability chain malformed or attenuation violated | 400 |
| Provider not in allowlist | 403 provider_not_allowed |
| Upstream 2xx | passed through as-is, reservation promoted to queued — the only path that becomes settleable |
| Upstream 5xx | 502 upstream_error, reservation voided, every level credited back. The upstream's own error detail is not leaked verbatim |
| Upstream timeout or connector failure | 504 / 502, reservation voided, every level credited back |
| Upstream 3xx/4xx | forwarded to the caller as-is, but the reservation is voided — the agent is not charged for a redirect or a 404 |
A voided charge does not release the nonce. The EIP-3009 signature stays replayable until its
validBefore, so freeing the nonce would reopen the free-shopping window; re-presenting a voided
payment gets 409, not a retry.
Postgres tables and Redis keys — click to expand
Three tables from architecture.md §8, in migrations/0001_init.sql:
providers—slugPK,upstream_url,price_wei,max_price_wei,scheme,payout_address,timeout_ms.max_price_wei/schemeexist so the P1uptoscheme needs no migration; the gateway implementsexactonly and rejects anything else by name.capabilities—hashPK (the EIP-712 struct hash), issuer, subject,parent_hash(self-FK, NULL at a root — never 32 zero bytes, because0x0is not a row), depth, limits, allowlist, signature,revoked_at.authorisations—noncePK (the EIP-3009 nonce, the idempotency key end to end), payer,capability_hash,provider_slug,amount_wei, fullpayloadJSONB, signature,status,upstream_status,batch_id,tx_hash,receipt_sig.
status is CHECK-constrained to reserved | voided | queued | settled | failed, and transitions
are one-way — the promotion UPDATE carries WHERE status = 'reserved'.
migrations/0002_chain_hashes.sql adds chain_hashes BYTEA[]: the full root-first chain that was
charged. capability_hash keeps its meaning — the leaf, i.e. which agent spent — and stays the
indexed column; ancestors live only in chain_hashes, which carries no FK and so can never poison
the drain. Written for every charge including the single-level payer case, so the boot rebuild has
exactly one code path (NOTES.md D4, D5).
| Key | Contents |
|---|---|
tg:nonce:{nonce} |
replay guard, TTL = validBefore - now + clock_skew |
tg:cap:{hash}:spent |
cumulative spend against one capability level |
tg:cap:{hash}:meta |
max_total, max_per_request, expiry |
tg:revoked |
set of revoked capability hashes |
tg:reservations |
stream, XADDed inside the claim script |
tg:outcome:{nonce} |
promote-or-void marker the drain applies |
There is no tg:window:* key — time-window budgets are P1 and out of scope (plan.md A4). There is
no tg:cb:* circuit breaker either (A10).
The nonce claim, every level's cap check, every level's decrement, and the reservation XADD happen
in a single Lua script (crates/tollgate-gateway/src/lua/claim.lua). All reads and all checks
complete before any write, so the script can never leave Redis half-updated.
The naive version — SETNX, then check, then DECRBY — is three round-trips and, more importantly,
not atomic: two concurrent requests both pass the cap check before either decrements. Under
concurrency that fires. The regression test drives 100 concurrent claims against a budget that
permits exactly 50 and asserts exactly 50 succeed.
A flat TTL that expires while the EIP-3009 signature is still valid lets the same payload replay for
a free upstream call, and you find out when settlement reverts. The TTL is computed as
validBefore - now + clock_skew and asserted > 0; quotes are refused if their validity window
exceeds QUOTE_VALIDITY_SECONDS + clock_skew, because the TTL has to be able to cover them.
The claim script XADDs the reservation in the same atomic execution as the decrement, so balance
and reservation live or die together. A Tokio task drains tg:reservations through the tg-writer
consumer group, inserts with ON CONFLICT (nonce) DO NOTHING, and XACKs only after the
Postgres commit. Kill Postgres mid-run and the entries stay pending; bring it back and they land.
At boot, before the listener binds, every capability counter is rebuilt from Postgres by summing
amount_wei over unnest(chain_hashes). The sum includes reserved, queued, settled and
failed — voided is the only exclusion, because it is the only status where the caller got
nothing. Counting failed as spent is a deliberate divergence from architecture.md §9.3, which
would otherwise hand an agent its budget back at every restart (NOTES.md D8).
The startup replay pages forward with XREADGROUP ... <last_id> rather than re-reading from 0.
Re-reading from 0 wedged the drain permanently on any entry that could never be applied — see the
"Fixed" section of NOTES.md for the full post-mortem; it is the reason
ledger::replay::a_poison_entry_does_not_wedge_the_startup_replay exists.
| Concern | Handling |
|---|---|
| Replay | Redis nonce with a TTL tied to validBefore; USDC's authorizationState is the on-chain finality guard |
| Cross-chain replay | chainId inside the EIP-712 domain of both the payment payload and the capability |
| Signature malleability | High-s signatures rejected before recovery |
| Gateway inflating the price | Impossible — the amount is inside the payer's signature |
| Free shopping | No upstream call is reachable before the claim returns Ok; the proxy call lives inside the Ok match arm, not after the match |
| Sub-agent escalating its budget | Attenuation-only verification; a child claiming more than its parent is rejected as malformed |
| Stolen capability | Bound to the subject — the last link's subject must equal the EIP-3009 signer, so the capability is useless without the subject's key |
| Provider looping to drain an agent | Per-request and total caps at every level, all evaluated before the upstream call |
| SSRF via provider registration | Resolve → validate every IP → pin the connection. See below |
String-based URL validation does not defend this, so the gateway does three things:
- Resolve and validate at registration.
POST /v1/providersrejects with400 unsafe_upstreamif any resolved address falls in RFC 1918, loopback, link-local,169.254.169.254, IPv4-mapped IPv6 (checked after unmapping), unique-local IPv6,0.0.0.0/8, multicast, or CGNAT. A hostname resolving to a mix of public and private addresses is rejected outright. - Pin the connection at request time. Registration-time validation is necessary but not
sufficient — DNS can change between registration and request. The custom
towerconnector resolves once, validates, then dials the validatedSocketAddrdirectly, never handing the hostname back to the resolver. TLS SNI and theHostheader are set from the original hostname. A rebinding test drives a stub resolver that returns a public IP first and127.0.0.1second and asserts the connector dials the validated address both times. - Do not follow redirects. 3xx responses are returned to the caller as-is.
architecture.md§10 asks for re-validation on every hop; not following at all is the simpler correct choice here.
The proxy strips X-PAYMENT, X-Tollgate-*, Host and hop-by-hop headers on the way upstream, and
adds X-Forwarded-For. A provider never sees the payment header.
⚠️ TOLLGATE_ALLOW_LOOPBACK_UPSTREAM=truepermits127.0.0.1so the local mock upstream works. It defaults to false and must never be true in a deployed configuration. It is the one switch that turns the SSRF defence off.
architecture.md §10 also asks for a deny-by-default egress network policy. That is a deployment
control rather than application code, and is not implemented here — it is an operational
requirement on whoever runs this (plan.md A12).
Front-running, stated rather than hidden
Settlement will use transferWithAuthorization, not receiveWithAuthorization, because the latter
requires msg.sender == to and that breaks batching (plan.md A6). The consequence is bounded: an
observer who replays the payload from the mempool executes exactly the transfer the payer signed, to
the recipient the payer signed, and pays the gas themselves. It affects ordering, not custody.
Per-stage Prometheus histograms named tollgate_stage_seconds, with a stage label:
| Stage | Covers |
|---|---|
decode |
header decode and the cheap field checks |
recover |
EIP-3009 signature recovery |
capability |
the chain walk, including the revocation lookup |
claim |
the Lua round-trip |
upstream |
the proxy hop |
gateway |
the whole request minus the upstream hop — the added latency prd.md §8 budgets |
total |
the whole request, upstream included |
curl -s localhost:8080/metrics | grep tollgate_stage_seconds
curl -s localhost:8080/v1/stats # the same numbers, as percentilesupstream is recorded separately and never folded into the others — prd.md §8 targets p50 under
15 ms of added gateway latency, and conflating the two would make the number meaningless. That
is why gateway exists alongside total: total spans the whole handler and necessarily includes
the upstream, so it is not the number to quote. gateway is recorded only on requests that
actually reached an upstream, since a 402 or 409 refusal never pays for one. Per plan.md A8 no
phase gate is conditioned on hitting the target; missing it is a tuning problem.
GET /v1/stats serves these as percentiles by parsing the same exported histograms, so it and
/metrics agree by construction. A stage with no observations yet reads as null, not zero.
prd.md §8 sets the numbers this project claims. scripts/loadtest.py measures them rather than
asserting them: 100 paid requests across 5 parallel workers, all drawing on one shared root
capability sized to exactly 100 charges.
python3 scripts/loadtest.pyLast run, against a release gateway and the local mock upstream:
prd.md §8 target |
Target | Measured | |
|---|---|---|---|
| Added gateway latency, p50 | < 15 ms | 3 ms (mean 2.8 ms) | ✅ |
| Added gateway latency, p99 | < 50 ms | 10 ms | ✅ |
| End-to-end p50, upstream included | < 150 ms | 32 ms | ✅ |
| Requests handled live | 100+ in < 60 s | 100 in 0.7 s (151/s) | ✅ |
| Delegation depth demonstrated | 2 levels, 3 workers | examples/agents/ |
✅ |
| Provider integration, measured live | < 3 min | 12 s (see Quick start) | ✅ |
| Gas per charge, batch of 50 | — | 44,514 (40,846 execution) | |
| Settlement cost per charge | < $0.001 | $0.00053–$0.00107 at ETH $2k–$4k |
Every figure above is measured. The two marked forge test and a Base Sepolia fork
rather than the public testnet, so they exclude Base's L1 data-availability fee; Task 6.6 replaces
them with the all-in number. Derivation is in Settlement cost.
Four caveats, because each of these numbers is smaller than it looks:
- Release build only. The same run against a
cargo rundebug binary gives a gateway mean of 17.0 ms and misses the p50 target. Signature recovery is the hot path and it is the part debug builds punish hardest. Quote the release figure, and know the development default is not it. Recorded rather than omitted, perplan.mdA8: missing a target is a tuning problem and gates nothing. - The upstream is the local mock, answering in ~1 ms. A real provider over the network is the
dominant term in the end-to-end figure, which is why
prd.md§8 asks for the two separately and whygatewayexists alongsidetotal. - The p50 and p99 are interpolated from coarse buckets. The histogram jumps from 0.01 to 0.05 with nothing in between, so a debug run — where every sample lands in that gap — reports a p50 of exactly 0.030, the bucket's midpoint, and a p99 of exactly 0.050, its ceiling. Neither is a measurement. The load test prints the mean from the histogram's own sum and count alongside them for this reason. Release samples fall in resolved buckets, so the release percentiles are real.
- Nothing settled.
settledis 0 and stays 0 until Task 6.6 deploys to Base Sepolia; the charges reachqueued. The settlement cost row is fork-measured execution gas excluding Base's L1 data-availability fee.
The one hard assertion in the load test is that spend never exceeds the root cap. It sums the
ledger, not the Redis counters — excluding voided and including failed (NOTES.md D8, D16) —
and exits non-zero if the total ever passes the cap. Last run: 0.10 USDC spent against a 0.100
USDC root cap, 100 charges, zero errors.
cargo test --workspace # 111 unit tests, no external dependencies
cargo test --workspace -- --ignored # 19 integration tests, needs Postgres + Redis up
cargo clippy --workspace -- -D warningsLast run on this tree: 111 passed / 0 failed unit, 19 passed / 0 failed integration, clippy clean.
The integration tests read DATABASE_URL and REDIS_URL from the environment and each build a
throwaway Redis stream, so they neither touch tg:reservations nor race one another. That is
deliberate: two test modules once shared the real stream and made the suite flaky about one run in
three — see NOTES.md.
Notable tests, if you want to read the ones that carry the weight:
policy::claim::one_hundred_concurrent_claims_stop_exactly_at_max_total— the concurrency proofcapability::verify— one test per attenuation and binding rule, each asserting the exact variantssrf::denylist— table test over every denied range plus mixed-resolution hostnamesproxy::rebinding— proves the connector pins rather than re-resolvesledger::replay::a_poison_entry_does_not_wedge_the_startup_replay— the drain-wedge regression
Settlement needs a chain. For everything short of Task 6.6's live run, that chain is a local
anvil fork of Base Sepolia, which gives real USDC semantics — EIP-712 verification, the
authorizationState replay guard, real balance accounting — with no faucet and no waiting.
./scripts/anvil.sh & # forks Base Sepolia, funds a test EOA with ETH and USDCIt prints the RPC, the funded test EOA and its key. anvil's own output goes to
/tmp/tollgate-anvil.log. kill %1 stops it. Overridable by environment:
| Variable | Default | |
|---|---|---|
BASE_SEPOLIA_RPC |
https://sepolia.base.org |
what to fork; the public endpoint needs no key |
ANVIL_PORT |
8545 |
|
USDC_ADDRESS |
0x036CbD…dCF7e |
native USDC on Base Sepolia |
USDC_FUND_UNITS |
1000000000 |
1,000 USDC at 6 decimals |
TEST_EOA_KEY |
committed test key | throwaway, fork-only, holds nothing real |
Then deploy BatchSettler into it and point .env at the result:
cd contracts
export ANVIL_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 # anvil account 0
forge script script/Deploy.s.sol --rpc-url http://localhost:8545 --broadcast --private-key $ANVIL_KEY
# BatchSettler deployed at: 0x…
cast code <address> --rpc-url http://localhost:8545 # non-empty confirms itPut that address in .env as BATCH_SETTLER_ADDRESS, and set RPC_URL=http://localhost:8545
so the settler talks to the fork rather than a public endpoint.
Two things about this fork that will bite if you do not know them.
The fork must run with --chain-id 84532, which anvil.sh passes. anvil defaults to 31337, and
every EIP-3009 payment and every capability in this project commits to chain 84532 inside its
EIP-712 domain — on a 31337 fork all of them fail to verify, for a reason the error messages do
not make obvious.
The funded test EOA is not one of anvil's ten default accounts. All ten have EIP-7702
delegation code deployed at their addresses on real Base Sepolia, and USDC's SignatureChecker
routes any from that has code to ERC-1271 rather than ecrecover. An anvil default therefore
cannot make a working EIP-3009 payment against forked USDC — it fails with
FiatTokenV2: invalid signature however correctly the payload is signed. anvil.sh refuses to
start if its test EOA ever has code. The same constraint applies to the real payer wallet in a
live run: it must be a plain EOA.
A separate binary and a separate process. Nothing it does is on the request path — the caller already has the upstream's response by the time a row becomes visible to it.
cargo run -p tollgate-settlerIt flushes at SETTLER_BATCH_SIZE authorisations or SETTLER_FLUSH_SECONDS, whichever comes
first, submits one BatchSettler.settle transaction, and writes back status='settled',
tx_hash and settled_at for every nonce the chain reported as Settled.
Three properties worth knowing:
- It reads only
status = 'queued'. Areservedrow — one whose upstream call has not returned 2xx yet — is invisible to it by construction. That is what closes the v1 race where a flush could settle a charge the request handler was about to void. batch_idiskeccak256over the sorted nonce set, stamped inside the same transaction that selects the rows, withFOR UPDATE SKIP LOCKED. The same set of charges always produces the same id, so a duplicate submission is recognisable rather than looking like new work.- Gas is EIP-1559 with a fixed multiplier — twice the latest base fee, plus a constant priority fee. There is no gas oracle and no dependency on one.
Throughput ceiling, stated rather than hidden. One in-flight transaction at a time, awaited
to completion before the next batch, so the settler EOA's nonces stay sequential. That is roughly
one batch per block: at 50 per batch and Base's 2-second blocks, ~25 settlements per second
sustained. That is comfortably above prd.md §8's target of 100 requests in 60 seconds, but it
is a real ceiling. The production fix is several settler EOAs behind a nonce allocator, which is
not built.
prd.md §8 claims settlement costs under $0.001 per request after batching, "verified with forge
gas snapshots, not estimated". Here is the measurement rather than the estimate.
Run cd contracts && forge test -vv; the 50-item test measures the settle call with gasleft()
and logs it. contracts/.gas-snapshot is committed alongside.
settle(50) execution gas: 2042281
execution gas per charge: 40845
Per charge, for a full batch of 50:
| Component | Gas | Note |
|---|---|---|
| Execution | 40,846 | measured, real USDC on a Base Sepolia fork |
| Intrinsic | 420 | 21,000 amortised across 50 |
| Calldata | 3,248 | one Auth is 320 bytes; 156 zero @ 4, 164 non-zero @ 16 |
| Total | 44,514 |
At Base's gas price of 0.006 gwei (cast gas-price, live at time of writing):
| ETH price | Cost per charge |
|---|---|
| $2,000 | $0.00053 |
| $3,000 | $0.00080 |
| $4,000 | $0.00107 |
The claim holds, but not by an order of magnitude. At this gas price it breaks even with $0.001 at an ETH price around $3,744. Two caveats worth stating rather than burying:
- This excludes Base's L1 data-availability fee, which a calldata-heavy batch does incur. The real all-in figure comes from Task 6.6's live Base Sepolia settlement, and this section will be replaced by that measurement.
- Execution gas came in at ~41k per charge against
architecture.md§6's 60–80k estimate, so the batch lands near 2.0M gas rather than the 3.5M that section predicted.
Every environment variable — click to expand
| Variable | Required | Default | Used by |
|---|---|---|---|
DATABASE_URL |
yes | — | pool, migrations |
REDIS_URL |
yes | — | claim, budgets, drain |
BIND_ADDR |
yes | — | listener |
CHAIN_ID |
no | 84532 |
CAIP-2 network, both EIP-712 domains |
RPC_URL |
yes | — | Phase 6 (unused today) |
USDC_ADDRESS |
yes | — | asset in the 402, verifyingContract in the payment domain |
BATCH_SETTLER_ADDRESS |
yes | — | Phase 6 (unused today) |
SETTLER_PRIVATE_KEY |
yes | — | Phase 6 (unused today) |
GATEWAY_SIGNING_KEY |
yes | — | Phase 7 receipts (unused today) |
QUOTE_VALIDITY_SECONDS |
no | 300 |
maxTimeoutSeconds, validity-window ceiling |
CLOCK_SKEW_SECONDS |
no | 60 |
timestamp tolerance, nonce TTL |
SETTLER_BATCH_SIZE |
no | 50 |
Phase 6 |
SETTLER_FLUSH_SECONDS |
no | 10 |
Phase 6 |
TOLLGATE_ALLOW_LOOPBACK_UPSTREAM |
no | false |
SSRF test-mode escape hatch — see above |
RUST_LOG |
no | — | tracing_subscriber env filter |
USDC_ADDRESS must match what payers sign against: it is the verifyingContract of the
TransferWithAuthorization domain, so a mismatch makes every signature recover to the wrong address
and fail the from check. Native USDC on Base Sepolia (84532) is
0x036CbD53842c5426634e7929541eC2318f3dCF7e — verified on-chain as name "USDC", symbol "USDC",
decimals 6, EIP-712 version "2", with authorizationState present. Native, not bridged
USDC.e, which does not implement EIP-3009 compatibly.
Where everything lives — click to expand
Cargo.toml workspace root
docker-compose.yml postgres 16 + redis 7 (AOF, everysec)
migrations/ 0001_init … 0005_gas_used.sql
crates/
tollgate-core/ config, types, db + redis pools, error body, stage timer
tollgate-gateway/ the axum binary
src/x402.rs 402 construction, header decode, field validation
src/verify.rs EIP-3009 recovery via alloy
src/capability.rs capability type, hashing, chain decode, chain verification
src/policy.rs typed driver for the claim script
src/lua/claim.lua the atomic claim
src/proxy.rs pinned-IP connector, streaming passthrough
src/ssrf.rs resolve + IP denylist
src/ledger.rs stream drain, promote/void, boot rebuild, receipts
src/routes/ providers, budgets, capabilities, proxy, receipts,
expense, feed, health, metrics
tollgate-settler/ batch selection, submission, retries
contracts/ BatchSettler.sol, tests, deploy script
client/tollgate/ the Python client: session, capability, signing, errors
dashboard/app/ next.js: /provider, /agent, /feed
examples/
mock_upstream.py deterministic paid upstream
sign.py throwaway EIP-712/EIP-3009 signing harness
agents/ supervisor + 3 workers, the prd.md §9 demo
scripts/dev.sh compose up + wait + migrate
scripts/seed_providers.sh register the three mock providers
scripts/anvil.sh base sepolia fork, funded test EOA
scripts/loadtest.py 100 paid requests, measured against prd.md §8
scripts/check.sh every check across rust, solidity, python, typescript
Stated up front, because a stated boundary reads better than a discovered one
(architecture.md §12).
- Testnet only. Base Sepolia, testnet USDC, no mainnet, no real value.
- One chain. Multichain is described in the design, not deployed. The design is per-chain isolated with no bridge, so it is an afternoon of work later rather than an architectural change.
- Settlement has never run against the live testnet. The
BatchSettlercontract and the settler worker are built and exercised end to end, but only against a local anvil fork of Base Sepolia. Task 6.6 — the real deployment and the first public transaction — is outstanding. - Provider onboarding is a form. No email, no verification, no reputation.
- The settler key is a hot key in an env var. Production wants a KMS or a threshold signer.
This matters less than it looks: the settler cannot invent, inflate or redirect a charge, because
toandvalueare inside the payer's signature and checked by USDC, andBatchSettlerholds no funds and has no owner, no pause and no upgrade path (architecture.md§6). - Delegation ships with Resolution A funding. The parent sends USDC to each child wallet up front and the capability chain is pure policy constraining money the child already holds. The parent cannot claw back unspent funds without the child's cooperation, and each child costs one on-chain transfer. Channel-backed delegation (Resolution B) is designed and described, not built.
- A Redis loss loses only undrained in-flight reservations — a bounded window of seconds
between the atomic claim and the stream drain landing the row in Postgres. Do not read this as
exactly-once across a Redis wipe, because it is not (
architecture.md§9.3). - Settlement is effectively-once, not exactly-once. The EIP-3009 nonce is the idempotency key
at every layer: gateway dedup, stream dedup, Postgres primary key, and USDC's
authorizationStateon-chain. On-chain that composition is at-most-once and the chain enforces it; off-chain the settler is at-least-once with idempotent retries. Compose the two and you get effectively-once, which is the honest description and the one to use (architecture.md§9.4). - Settler throughput ceiling: ~25 settlements per second. One in-flight transaction at a time
so the settler EOA's nonces stay sequential — roughly one batch per block, at 50 per batch and
Base's 2-second blocks. Comfortably above
prd.md§8's 100-requests-in-60-seconds target, but a real ceiling. The production fix is several settler EOAs behind a nonce allocator, not built. transferWithAuthorizationfront-running is bounded, not absent. Batching requires it —receiveWithAuthorizationdemandsmsg.sender == to, which breaks batching. An observer who replays the payload from the mempool executes exactly the transfer the payer signed, to the recipient the payer signed, and pays the gas themselves. It affects ordering, not custody.- Deny-by-default egress is a deployment requirement this application does not enforce.
architecture.md§10 asks for the proxy's egress to run under a deny-by-default network policy so that an SSRF bypass reaches nothing. That is a deployment control, not application code (plan.mdA12). The gateway implements resolve, validate and pin; the network policy is on whoever runs it. - Out of scope by decision: the
uptoscheme, time-window budgets, circuit breakers, on-chain refunds of settled charges, and thetollgate wrapCLI.
- The off-the-shelf x402 client interop demonstration (Phase 2 Gate) is not recorded as run.
cargo build --workspaceproduces a gateway that panics on its first HTTPS request. The gateway askshyper-rustlsforringand the settler pullsaws-lc-rstransitively throughalloy; building both in one invocation unifies the features and leaves rustls with two crypto providers and no default.cargo build -p tollgate-gatewayalone is fine, and traffic to the local mock upstream is plain HTTP, which is why this has gone unnoticed. It bites against a real provider.- The request path does not tolerate a Postgres outage: provider lookup hits Postgres before any
Redis logic, so a blip makes in-flight requests
500rather than degrading. Fail-closed, no data loss, no mis-attribution. /v1/statspercentiles are interpolated from histogram buckets that jump 0.01 → 0.05, so a gateway running slower than 10 ms per request reports bucket edges rather than measurements.
All four are detailed in the "Deferred" section of NOTES.md.
| File | What it is |
|---|---|
prd.md |
Product requirements, target users, success metrics, the honest business case |
architecture.md |
The design: request lifecycle, delegation, concurrency, security, data model |
plan.md |
The 46-task build plan, phase by phase, with a gate per phase |
NOTES.md |
Decisions taken during execution (D1–D22), post-mortems on bugs found and fixed, and deferred items |
EXECUTING.md |
How this repo is built with Claude Code, and the git policy |
CONTRIBUTING.md |
Setup, the checks, house style, and how to report a security issue |
LICENSE |
Apache License 2.0 |
Git policy: the agent never runs git write commands. Every commit in this repository was made by a human after review.
Apache License 2.0 — permissive, with an explicit patent grant, which is worth having in a payments implementation.
Contributions are welcome and are accepted under the same terms; CONTRIBUTING.md covers the
workflow, the checks that must pass, and where to report a security issue. Tollgate handles payment
authorisations, so please report anything security-relevant privately rather than in a public
issue.
Testnet only. No real value moves.
Base Sepolia · native USDC · transferWithAuthorization
Built with x402 on the wire, because a payment protocol that already exists does not need reinventing.


