An x402 agentic-payments facilitator & gateway — self-hostable, written in Rust.
⚠️ Portfolio & learning project. Tollgate is a Rust-learning and portfolio project. It runs on testnet only (Base Sepolia), handles no real funds, and is not audited — do not point it at mainnet. It is also honest about its market: the headline x402 transaction numbers sit on thin real commercial volume, so Tollgate is positioned as infrastructure engineering — a correct, observable facilitator — not a bet on the x402 narrative.
M3 (gateway middleware + reverse proxy) complete. On top of the M2
offline verifier, the new tollgate-middleware crate is a tower Layer/Service
that gates an axum app via the x402 flow — 402 + Challenge on a missing/invalid
X-PAYMENT, and verify_payment + an in-memory nonce replay-check on a valid one —
and tollgate-gateway is now an axum server that mounts the middleware and
reverse-proxies accepted requests to a configured upstream (SSRF-guarded, with
hop-by-hop/X-PAYMENT/Host hygiene). An end-to-end integration test drives the
full 402 → sign EIP-3009 → 200-relayed → replay-402 path over real TCP. The
M4 nonce-store slice is now in: a NonceStore trait with a Redis backend
(atomic SET NX PX claim, per-claim TTL from each authorization's validBefore,
store-error → fail-closed non-leaking 503), operator-selected via
TOLLGATE_REDIS_URL and proven under concurrency by testcontainers. The
M5a claims-ledger slice follows the same shape: set TOLLGATE_DATABASE_URL to
a Postgres URL and every accepted payment is recorded durably (schema migrated at
startup, ledger-error → the same fail-closed 503) so a settlement worker can redeem
it later; leave it unset and claims are not recorded at all — the gate still
gates, but accepted payments leave nothing to settle from. The M5b settlement
slice is that later: tollgate-settler is a standalone worker that sweeps the
ledger every minute and redeems each claim's EIP-3009 authorization on Base Sepolia
via transferWithAuthorization, marking the row settled only once the receipt says
success. The M5c solvency slice closes the last hole in that chain: until now
verify_payment was entirely offline, so an address holding nothing could sign
unlimited well-formed authorizations and be served every time. Set TOLLGATE_RPC_URL
and the gate now reads the payer's on-chain balanceOf at accept time and subtracts
the claims that payer already owes and has not yet settled; when the two together
cannot cover the authorization it returns 402 with the offer still attached, and an
RPC fault fails closed (503) rather than waving the payment through. Check and
record are serialized by a Postgres advisory lock held across the nonce claim, so a
burst of concurrent requests cannot outspend one balance.
Leave TOLLGATE_RPC_URL unset and the check is simply absent — the pre-M5c
behaviour, warned about at boot. Debt only counts while a claim is still redeemable,
so the slice also puts a floor under the authorization validity window beside the
existing ceiling — a payer signing a 2-second window would otherwise watch their own
debt expire before the settler's next sweep — which raises the gateway's advertised
maxTimeoutSeconds from 60 to 600.
A floor alone is not enough, because it is a per-claim answer to an aggregate
problem: one payer's debt is counted only while it is still redeemable, so a queue
deep enough to outlive the claims in it lets debt expire out of the sum and re-opens
credit that was never repaid. The gate therefore admits a claim only if its remaining
validity covers the settler's bounded worst case of reaching it —
180s + 30s × <claims already queued ahead>. The payer's own signed window buys
their place in the queue: sign a longer one and you are admitted deeper into a
backlog. When the queue is too deep for the window offered, the gate returns a 402
carrying settlement queue is full for this validity window with the offer still
attached, and the client's remedy is to re-sign with a longer window rather than to
retry the same one. Two operator-visible consequences follow. The admission lock is
now a single global lock rather than one per payer — the attack it closes uses
many distinct payers by construction — so under contention its 2-second
lock_timeout surfaces as fleet-wide 503s, not per-payer ones. And the settler's
budget is only honest if it is enforced: every RPC await is now bounded at
REQUEST_TIMEOUT = 3s, a receipt wait at RECEIPT_TIMEOUT = 12s (was 120s), and a
sweep at SWEEP_DEADLINE = 60s, with claims beyond the deadline deferred to the next
sweep and counted. One consequence is worth knowing before a deploy: an RPC that
cannot answer eth_chainId within 3 seconds now fails the settler at startup
instead of parking on the call.
The M4 policy engine now ships too: two opt-in per-payer caps, both measured over
one shared window. A velocity cap counts requests in the nonce store; a spend
cap sums authorized value from the claims ledger. Either one over its limit answers
429 before the request reaches upstream, before the metered eth_call, and
without burning the payer's nonce.
That ordering is what closes the deferred finding F-A, where a replayed header from a
zero-balance address cost the operator RPC compute units and a pooled connection at
zero cost to the attacker. It is the velocity cap that closes it — and this is
worth reading twice before configuring: a spend-only configuration does not. The
spend read runs for every verified header, replays included, and it sits ahead of
the replay check, so under a spend-only config a replayed header still costs a pooled
connection and a SELECT SUM — on top of what F-A already named. Operators who care
about F-A must arm TOLLGATE_PAYER_MAX_REQUESTS. Arming only
TOLLGATE_PAYER_MAX_SPEND stays legal and is useful for budget control, but it is not
a defence against replay-driven resource burn. One residual remains either way:
unsigned or garbage headers are still decoded and offline-verified before any cap is
consulted — CPU only, no RPC and no pooled connection.
M6 ships the demo kit: one command, make demo, walks the whole loop — a paying
agent, a gated MCP-style tool server and the real gateway — and prints what happened
at every leg. See Demo. M7 ships the numbers: criterion micro-benches of
the x402 hot path (make bench), a deep property-based robustness run over the
untrusted-input surface (make proptest) and a hermetic in-process load test
(make loadtest), all written up with real measurements and honest caveats in
docs/performance.md.
What M0 actually ships:
- Cargo workspace — three crates at M0 (
tollgate-corelib,tollgate-middlewarelib,tollgate-gatewaybin), room for the planned crates. Six as of M6 — see the crate table below. #![forbid(unsafe_code)]across the workspace.- CI gate — a single
make cistep runs fmt-check → build → clippy (pedantic,-D warnings) → test on every push and pull request. - Pinned toolchain —
rust-toolchain.tomlfixes the exact Rust version so local and CI builds match;rustupauto-installs it.
One Cargo workspace. Six crates exist as of M6 (tollgate-core,
tollgate-middleware, tollgate-ledger, tollgate-gateway, tollgate-settler,
tollgate-demo); more are planned as the milestones land. Postgres owns the claims ledger, Redis
holds nonces and velocity windows, and settlement targets Base Sepolia (per PRD §7).
| Crate | Kind | Status | Purpose |
|---|---|---|---|
tollgate-core |
lib | present | x402 types, spec constants, payload parsing & verification |
tollgate-gateway |
bin | present | axum reverse proxy that gates upstreams and issues 402 challenges |
tollgate-middleware |
lib | present | tower Layer/Service for embedding the gate in a host app |
tollgate-ledger |
lib | present | Postgres claims ledger: records accepted payments, hands out what is still owed |
tollgate-settler |
bin | present | settlement worker: sweeps owed claims and redeems them on Base Sepolia |
tollgate-demo |
lib + bin | present | hermetic demo: paying agent + MCP-style tool server + fake chain, driven through the whole loop |
tollgate-admin |
bin | planned (later) | admin API: claims, batches, per-agent spend, Prometheus metrics |
xtask |
bin | planned | in-workspace dev automation |
See docs/architecture.md for the C4 context and
container diagrams.
Prerequisites: make and a Git checkout. Rust is pinned via
rust-toolchain.toml, so rustup auto-installs the correct toolchain (plus
rustfmt and clippy) on the first cargo call — no manual version juggling.
# 1. Build the whole workspace against the committed lockfile.
make build
# 2. Run the test suite.
make test
# 3. Run the full CI gate locally (fmt-check → build → clippy → test).
make ci
# 4. Watch the whole x402 loop happen (see Demo below).
make demomake demo runs the whole x402 loop end to end and prints a transcript of every
leg. One process boots three things on ephemeral loopback ports — an MCP-shaped
tool server (one word_count tool, which knows nothing about payments), the
real gateway in front of it, and a fake chain that narrates every
balanceOf it is asked for — and then walks a paying agent through five legs:
| Leg | Answer | Why it is in the demo |
|---|---|---|
| 1. an unpaid call | 402 | the gate answers with its own price list, and the tool behind it is never reached |
| 2. the same call, paid for | 200 | an EIP-3009 authorization signed against that challenge buys the tool catalogue |
| 3. that same header, again | 402 | replay: the gate remembers (payer, nonce), however honestly the reuse was meant |
| 4. a freshly signed authorization | 200 | a fresh nonce is a fresh payment — and the body carries the word count, so the tool really ran rather than the gate merely allowing |
| 5. a valid signature from a payer who cannot cover it | 402 | solvency is read from the chain at accept time: a well-formed payment is not a funded one |
The agent is a real client, not a test harness with privileged knowledge: it signs over the offer read out of the gate's own 402 challenge, so a gate advertising something else would break the loop rather than pass it.
It is hermetic by construction, not by convention: no live RPC, no containers,
no database, and no network beyond loopback. The gateway is configured from a struct
literal rather than from_env(), so the demo structurally cannot read
TOLLGATE_RPC_URL or TOLLGATE_SIGNER_KEY and cannot be pointed at a live endpoint
by an environment it happens to inherit; the asset and payee are addresses no
contract lives at, so an authorization minted during a run is unredeemable anywhere.
The same loop runs in CI as an ordinary test (crates/tollgate-demo/tests/demo_loop.rs)
that pins all five legs by name and status, so make demo cannot rot quietly.
About the two WARN lines the gateway prints before the transcript: both are
correct and both are left in on purpose. The demo runs with no Postgres, so there is
no claims ledger — which means accepted payments are not recorded and cannot be
settled later, and the solvency check therefore weighs each authorization against
the payer's balance alone with nothing to debit. Those are exactly the two caveats
Configuration documents below for a gateway run without
TOLLGATE_DATABASE_URL, and a demo that silenced them would be advertising a
guarantee it is not providing. Leg 5 still refuses the broke payer for the reason it
claims to; legs 2 and 4 leave nothing behind to settle.
Logging is otherwise quiet by default so the gateway's info! lines do not
interleave with the transcript — RUST_LOG=info make demo puts them back for anyone
debugging the demo itself.
Everything is read from the environment at startup; nothing is read from a config file. The gateway defaults to a local Base Sepolia testnet setup, while the settler defaults nothing — it moves money, so a missing knob is a startup error rather than an assumed testnet.
| Variable | Binary | Required | Purpose |
|---|---|---|---|
TOLLGATE_LISTEN |
gateway | no (127.0.0.1:8080) |
address the gateway binds to |
TOLLGATE_UPSTREAM |
gateway | no (http://127.0.0.1:8081) |
absolute http:// base the gateway proxies accepted requests to |
TOLLGATE_PAY_TO |
gateway | no (placeholder) | EVM address the 402 challenge asks payers to pay |
TOLLGATE_REDIS_URL |
gateway | no | Redis URL for the nonce store; unset falls back to the in-memory store |
TOLLGATE_DATABASE_URL |
gateway, settler | gateway no / settler yes | Postgres URL of the claims ledger; unset in the gateway means accepted payments are not recorded |
TOLLGATE_RPC_URL |
gateway, settler | gateway no / settler yes | JSON-RPC endpoint of the chain, pinned by chain id at startup (settler: 8453 or 84532; gateway: the id its own network resolves to). The settler settles on it; the gateway reads payer balances on it for the accept-time solvency check. Unset in the gateway means payer solvency is not checked at all — any well-formed authorization is accepted, funded or not — and it warns at boot. Set in the gateway without TOLLGATE_DATABASE_URL the check is only half armed: with no ledger there is nothing to debit, so each authorization is weighed against the balance in isolation and a payer holding the price once can be served for it repeatedly. That also warns at boot, and the boot line reports solvency=<chain> (no debit) rather than a bare chain id |
TOLLGATE_PAYER_WINDOW_SECONDS |
gateway | no | width, in seconds, of the window both per-payer caps below are measured over. Unset with either cap set is a startup error — a cap is a value per window, and the window has no safe default; 0 is refused for the same reason, since a window of no width can measure nothing |
TOLLGATE_PAYER_MAX_REQUESTS |
gateway | no | how many requests one payer may make per window before the gate answers 429. Counted in the nonce store (fixed window), so it needs no ledger |
TOLLGATE_PAYER_MAX_SPEND |
gateway | no | how much value, in the asset's base units, one payer may authorize per window before the gate answers 429. Summed from the claims ledger over a rolling window, so it is a startup error without TOLLGATE_DATABASE_URL — with no rows to sum the dial would silently enforce nothing. 0 is a startup error too (and so is 00): a zero cap refuses every payment forever, which is a permanent outage rather than a policy, and TOLLGATE_PAYER_MAX_REQUESTS already rejects 0 for exactly that reason |
TOLLGATE_SIGNER_KEY |
settler | yes | hex secp256k1 key of the account that signs and pays gas for settlements — a secret; parsed once at startup and never logged |
TOLLGATE_TENDERLY_RPC_URL |
tests only | no | forked Base Sepolia endpoint that enables the settler's on-chain end-to-end test; unset and that test skips |
The three TOLLGATE_PAYER_* keys are one opt-in feature rather than three
independent knobs: leave all three unset and there is no policy engine at all — the
accept path is exactly what it was before one existed. Set any of them and the whole
set is validated at startup, because unlike the URL knobs there is no later
connect for a mistake to surface at: a cap with no window, a window with no cap in
it, and a spend cap with no ledger to sum are each refused at boot rather than
accepted and quietly ignored. Nothing partial can therefore reach the request path,
which is why the boot line reports a plain quota=on / quota=off and never the
caps themselves.
Values are credentials as often as not (TOLLGATE_SIGNER_KEY is a private key, and
both URL knobs can carry a password or an API key), so no configuration type in the
workspace derives Debug and no error message repeats one back.
Cargo.toml workspace manifest
Cargo.lock committed lockfile
rust-toolchain.toml pinned Rust version
Makefile build / test / lint / ci / demo targets
crates/
tollgate-core/ library crate (x402 types, spec, verification)
tollgate-middleware/ tower Layer/Service that gates an axum app via x402
tollgate-ledger/ Postgres claims ledger (schema + queries)
tollgate-gateway/ axum reverse-proxy binary crate
tollgate-settler/ settlement worker binary crate (on-chain redemption)
tollgate-demo/ hermetic end-to-end demo of the whole loop (`make demo`)
docs/
architecture.md C4 context + container diagrams (Mermaid)
performance.md M7 benchmark, proptest and load-test writeup
.github/
workflows/ci.yml single-job CI (make ci)
Milestone titles from PRD §8. Each milestone is one /ship cycle.
| Milestone | Scope |
|---|---|
| M0 ✅ | Workspace, CI, clippy/fmt, C4 diagram, first 3 ADRs |
| M1 ✅ | x402 types + 402 challenge + payload parsing with full test suite |
| M2 ✅ | Signature verification (EIP-712/EIP-3009 via alloy) |
| M3 ✅ | Tower middleware + axum gateway (happy path, in-memory nonce) |
| M4 ✅ | Redis nonce store (atomic SET NX PX, per-claim TTL, fail-closed 503) + policy engine: per-payer velocity and spend caps refused at the gate with no upstream hit, no metered eth_call and no burnt nonce |
| M5 ✅ | Postgres claims ledger (every accepted payment recorded, TOLLGATE_DATABASE_URL) + settlement worker (EIP-3009 redemption on Base Sepolia) |
| M5c ✅ | Accept-time payer solvency check (on-chain balanceOf less the payer's unsettled claims, TOLLGATE_RPC_URL) |
| M6 ✅ | Demo kit: paying agent + gated MCP-style tool server, the whole loop under make demo |
| M7 ✅ | Benchmarks, property-based robustness testing, load test, performance writeup |
MIT.