pontage
by mteen1
README.md
# pontage
<p align="center">
<img src="docs/assets/402-payment-required.png" alt="402 Payment Required" width="400">
</p>
**A self-settling [x402](https://www.x402.org/) payment gateway for MCP tools.**
Gate any [Model Context Protocol](https://modelcontextprotocol.io) tool behind a
per-call USDC micropayment — and verify and settle the payment **directly against
the chain**, with no third-party facilitator in the path.
License: MIT · Python 3.12 · Base + USDC (EIP-3009)
> **pontage** *(n.)* — the medieval toll paid to cross a bridge. The agent
> pays pontage; the gateway takes it straight across to the chain.
---
## Why
AI agents increasingly consume gated tools, APIs, and data with no human in the
loop to click "subscribe." [x402](https://www.x402.org/) revives HTTP 402 as the
protocol for this: the server answers `402 Payment Required`, the agent signs a
stablecoin payment, and the server serves the resource once paid.
The reference x402 deployments (Cloudflare, AWS) delegate payment
**verification and settlement** to a hosted facilitator. `pontage` collapses
that role into the server itself:
```
reference x402: Agent ──▶ Server ──▶ Facilitator ──▶ Blockchain
pontage: Agent ──▶ Gateway ─────────────────▶ Blockchain
```
The buyer signs an **EIP-3009 `transferWithAuthorization`** — a gasless
authorization that moves USDC **directly from the buyer's wallet to the seller's
receiving address**. The gateway only *verifies the signature* and *broadcasts
the authorization*. It never custodies buyer funds and never holds spendable
revenue. The single key it holds pays gas and holds dust — the entire drainable
surface (see [Custody model](#custody-model)).
## Why not just have the agent pay and hand us the txid?
That's the naive design — and it's exactly what the [attack harness](#the-attack-harness)
below exists to break. It looks simple until you actually build it:
- **A txid can be replayed.** Nothing stops an agent from handing you a txid
from a payment it already redeemed against you (or from a totally different
service). Without a server-issued, single-use nonce that you atomically
claim, "here's a txid" is just a receipt you have no way to invalidate.
- **A raw transfer isn't bound to what it paid for.** A token transfer says
"A sent B tokens" — nothing more. It doesn't say *which* tool call, *which*
arguments, or *which* price it was authorizing. Without binding the payment
to the exact request up front, an agent can pay the cheap price once and
swap in a more expensive call before you check.
- **"I sent a txid" isn't "the money moved."** The transaction could still be
pending, get dropped, get replaced, or fall out of the chain in a reorg.
Serving as soon as you see a txid — instead of after on-chain confirmation —
means you're trusting the agent's claim, which is the exact trust problem
HTTP 402 exists to remove.
- **You need to check it against a live chain anyway.** Confirming a payment
really landed means an RPC call (or your own node), decoding the receipt,
confirmation-depth logic that scales with price, and idempotent handling so
a slow confirmation doesn't get double-charged or double-served.
- **The agent still needs to learn the price and address first.** Even in the
"just send a txid" version, there's an implicit handshake before payment —
which is precisely what the 402 challenge formalizes instead of leaving
ad hoc.
So the job doesn't go away — mint a challenge, bind it to the request, verify
the payment against it, atomically claim it exactly once, wait for on-chain
confirmation, *then* serve. `pontage`'s bet isn't that this job is avoidable;
it's that you don't need to hand it to a third-party facilitator to do it
correctly. Verification and settlement happen in-process, directly against
the chain.
## What makes it different
- **No facilitator dependency.** Verify + settle happen in-process over plain
JSON-RPC. No hosted service, no third-party account, no facilitator economics.
- **A reproducible attack-test harness.** The paper
[*Five Attacks on x402*](https://arxiv.org/abs/2605.11781) found every audited
open-source x402 SDK exploitable. `pontage` ships each attack class as a
runnable test that **succeeds against a naive gateway and fails against this
one** — so you verify the defenses yourself instead of trusting a claim. See
[`tests/attacks/`](tests/attacks/).
- **Payment bound to the exact request.** The gateway issues the EIP-3009 nonce
and binds it to a specific tool + argument set, closing the request↔payment
binding gap that underlies most x402 replay attacks.
- **Clean settlement-adapter interface.** The engine depends only on a
`SettlementAdapter` protocol; the Base/USDC EIP-3009 implementation is one
swappable module.
## Quick start
Requires [uv](https://docs.astral.sh/uv/) and Python 3.12.
```bash
uv sync
# Run the gateway with an in-memory fake chain (no wallet, no network needed).
PONTAGE_CHAIN_MODE=fake \
PONTAGE_PAY_TO_ADDRESS=0x2222222222222222222222222222222222222222 \
uv run python -m pontage
```
In another terminal, run the example paying agent end-to-end:
```bash
uv run python examples/client.py --tool lucky_number --args '{"seed": "pontage"}'
```
```
→ calling lucky_number (no payment)
← 402 challenge: $0.001 USDC on base-sepolia to 0x2222…2222
→ paying and re-calling lucky_number
← tool result: { "seed": "pontage", "luckyNumber": 37, ... }
← settlement receipt: { "success": true, "transaction": "0x0f3c…10ed", ... }
```
The client fetches the 402 challenge, signs the EIP-3009 authorization with the
server-issued nonce, and re-calls the tool with the payment in
`_meta["x402/payment"]` — the [x402 MCP transport](https://www.x402.org/)
pattern.
## Defining a paid tool
```python
from pontage.transport.paid_tool import paid_tool
@paid_tool(mcp, get_engine, price_atomic=1_000) # $0.001 (USDC has 6 decimals)
async def market_lookup(symbol: str) -> dict:
"""Premium market data for a symbol."""
return await fetch_quote(symbol)
```
The first call returns a `402`-style challenge; the paid retry runs the tool and
returns a settlement receipt. Payment travels in `_meta`, never in the tool
arguments — the tool's public schema stays clean.
## Payment flow (Mode A: confirm-before-serve)
1. Agent calls the tool with no payment.
2. Gateway records a challenge (server-issued nonce, price, recipient, expiry)
and returns the x402 `402` challenge.
3. Agent signs an EIP-3009 authorization over the challenge's nonce and re-calls.
4. Gateway verifies the payload **against the stored challenge** (signature,
recipient, amount, validity window, resource binding), atomically **claims the
nonce** (DB unique constraint — the replay lock), then broadcasts the
authorization and **waits for on-chain confirmation**.
5. Only after funds have moved does the tool run; the receipt (tx hash) is
returned in `_meta["x402/payment-response"]`.
Serving is gated on *money moved*, not on signature validity — the core defense
against the "unpaid service" attack.
## Custody model
| Key | Held where | Can spend | Worst case if the gateway is fully compromised |
|-----|-----------|-----------|-----------------------------------------------|
| **Receiving address** | Cold wallet; gateway knows the *public address only* | Seller revenue | Cannot be drained via the gateway. At most, future challenges point at an attacker address — detectable (receipts stop matching) and reversible. |
| **Submitter key** | Gateway host (env / secret store) | Gas only — holds dust | Gas dust lost. Bounded by construction. |
Because EIP-3009 moves funds buyer → receiving address directly, the total
drainable surface is gas dust plus whatever revenue cap you configure
(`PONTAGE_FLOAT_CAP_ATOMIC`).
## Configuration
Environment variables, prefix `PONTAGE_`:
| Variable | Default | Meaning |
|----------|---------|---------|
| `PAY_TO_ADDRESS` | *(required)* | Receiving address (public only) |
| `CHAIN_MODE` | `rpc` | `rpc` (real chain) or `fake` (in-memory demo chain) |
| `CHAIN_ID` | `84532` | `8453` Base mainnet · `84532` Base Sepolia |
| `RPC_URL` | `https://sepolia.base.org` | JSON-RPC endpoint |
| `SUBMITTER_PRIVATE_KEY` | — | Gas-paying key (dust); required in `rpc` mode |
| `DATABASE_URL` | `sqlite+aiosqlite:///./pontage.db` | Postgres in production |
| `CONFIRMATION_THRESHOLD_ATOMIC` | `100000` | Prices ≥ this wait for more confirmations |
| `FLOAT_CAP_ATOMIC` | `50000000` | Refuse new challenges past this cumulative revenue ($50) |
See [`.env.example`](.env.example) and [`docs/`](docs/) for the full list.
## The attack harness
```bash
uv run pytest tests/attacks/ -v
```
Each test attacks both a deliberately-naive gateway and the real engine under
identical on-chain conditions (a real in-memory chain model, not a mock), and
asserts the exploit **succeeds against naive, fails against pontage**:
| Attack | Class | Defense |
|--------|-------|---------|
| 1 | Replay across HTTP/chain boundary | Atomic nonce claim (DB unique constraint) |
| 2 | Request/resource binding flaw | Server-issued nonce bound to tool + arguments |
| 3 | Authorization weaknesses | Every field checked against the challenge; signer recovered |
| 4 | Stale/expired authorization | Expiry + settlement-headroom checks |
| 5 | Settlement-path inconsistency | Confirm-before-serve + idempotent retry |
## Status & scope
`pontage` is **v0.1** — a working, demonstrable slice: one FastAPI service, an
MCP server with paid tools, EIP-3009 verify + settle on Base, and the attack
harness. It is a reference implementation, not a hardened financial system; run
the public demo on a capped mainnet float, and read the
[custody model](#custody-model) before pointing real money at it.
Deferred to later milestones: a Go settlement core, serve-on-verify (Mode B),
prepaid tabs, a plain-HTTP middleware surface, and additional chain adapters
(the interface stays generic).
## Development
```bash
uv sync
uv run ruff check src tests # lint
uv run ruff format src tests # format
uv run mypy src/pontage # strict type check
uv run pytest # tests (no network needed)
```
## License
MIT — see [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues