Skip to content

dz3ka/tollgate

Repository files navigation

Tollgate

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.

Status

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.

Highlights

What M0 actually ships:

  • Cargo workspace — three crates at M0 (tollgate-core lib, tollgate-middleware lib, tollgate-gateway bin), 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 ci step runs fmt-check → build → clippy (pedantic, -D warnings) → test on every push and pull request.
  • Pinned toolchainrust-toolchain.toml fixes the exact Rust version so local and CI builds match; rustup auto-installs it.

Architecture at a glance

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.

Quickstart

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 demo

Demo

make 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.

Configuration

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.

Repository layout

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)

Roadmap

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

License

MIT.

About

x402 payments facilitator & gateway (Rust) — 402 challenge, offline EIP-3009 verification, Redis-backed replay protection

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages