agent402-tollbooth 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -19
- package/edge-mpp.js +139 -0
- package/edge.js +40 -4
- package/index.js +53 -7
- package/mpp-codec.js +139 -0
- package/mpp.js +21 -70
- package/package.json +11 -2
package/README.md
CHANGED
|
@@ -5,22 +5,17 @@ any site or API: humans browse free, AI crawlers and agents pay per request** -
|
|
|
5
5
|
in USDC over the [x402 protocol](https://x402.org), over
|
|
6
6
|
[MPP](https://mpp.dev) (the Machine Payments Protocol `Payment` HTTP auth
|
|
7
7
|
scheme, settled through the same stack), or for free by solving a
|
|
8
|
-
proof-of-work. No
|
|
8
|
+
proof-of-work. No platform lock-in, no card processor required, no Merchant-of-Record, no signup.
|
|
9
9
|
The first self-hostable pay-per-crawl gate that speaks both wires on one 402 -
|
|
10
10
|
the sell side of [Agentic Finance](https://agent402.tools/agentic-finance),
|
|
11
11
|
where agents pay and sites get paid, per request, with no account in between.
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
idea, but MIT-licensed and live today, running in
|
|
20
|
-
front of *any* origin (even a Cloudflare Worker), non-custodial (you hold the wallet,
|
|
21
|
-
no signup or Merchant-of-Record), and - the wedge the platform gateways don't offer -
|
|
22
|
-
with a **proof-of-work free tier** so a walletless agent still has a path through.
|
|
23
|
-
Built on the same hardened 402 + proof-of-work machinery as
|
|
13
|
+
Tollbooth is an **open-source monetization gateway**: MIT-licensed, running in
|
|
14
|
+
front of *any* origin (Express, a reverse proxy, Next.js, a Cloudflare Worker),
|
|
15
|
+
non-custodial (you hold the wallet, no signup or Merchant-of-Record), settling
|
|
16
|
+
over x402 and MPP on one 402 - including native MPP on Tempo with split
|
|
17
|
+
payments - and with a **proof-of-work free tier** so a walletless agent still
|
|
18
|
+
has a path through. Built on the same hardened 402 + proof-of-work machinery as
|
|
24
19
|
[Agent402](https://github.com/MikeyPetrillo/Agent402).
|
|
25
20
|
|
|
26
21
|
## See it work (one command)
|
|
@@ -123,8 +118,7 @@ must pay, or `null` to let it through.
|
|
|
123
118
|
**Ready-to-deploy templates** (copy a folder, don't assemble from docs):
|
|
124
119
|
|
|
125
120
|
- **Cloudflare Workers** → [`deploy/cloudflare/`](deploy/cloudflare/) - a ready
|
|
126
|
-
`wrangler.toml` + a 3-step deploy guide
|
|
127
|
-
incumbent's own platform).
|
|
121
|
+
`wrangler.toml` + a 3-step deploy guide.
|
|
128
122
|
- **Next.js / Vercel** → [`deploy/nextjs/`](deploy/nextjs/) - a drop-in
|
|
129
123
|
`middleware.js` + a 3-step deploy guide.
|
|
130
124
|
- **Docker** → [`deploy/docker/`](deploy/docker/) - a `Dockerfile` +
|
|
@@ -162,8 +156,16 @@ export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)
|
|
|
162
156
|
> On the edge, pass a stable `secret` (PoW tokens are HMAC-signed). For
|
|
163
157
|
> single-use replay protection across stateless invocations, supply a `store`
|
|
164
158
|
> (e.g. a Cloudflare KV wrapper - the Worker entry wires this for you).
|
|
165
|
-
> The `x402:` middleware mode and
|
|
166
|
-
> edge gate offers proof-of-work plus the `verifyX402` callback
|
|
159
|
+
> The `x402:` middleware mode and the native Tempo rail are Node/Express
|
|
160
|
+
> features; the edge gate offers proof-of-work plus the `verifyX402` callback,
|
|
161
|
+
> and since 0.10.0 **MPP on that same verifier**: with `secret`, `payTo` and
|
|
162
|
+
> `verifyX402` set, every 402 also carries a `WWW-Authenticate: Payment`
|
|
163
|
+
> evm/charge challenge for the quote, and an `Authorization: Payment`
|
|
164
|
+
> credential (HMAC-bound to our challenge, unexpired, minted for that exact
|
|
165
|
+
> resource) is translated to `PAYMENT-SIGNATURE` and handed to `verifyX402` as
|
|
166
|
+
> if an x402 client had sent it. `mpp:false` turns it off; `mppAssetAddress` +
|
|
167
|
+
> `mppAssetName` name a token outside the built-in USDC table (Base, Celo,
|
|
168
|
+
> Polygon, Arbitrum, Optimism, Avalanche, Sei).
|
|
167
169
|
|
|
168
170
|
## Accepting USDC: x402 and MPP on the same 402
|
|
169
171
|
|
|
@@ -240,9 +242,21 @@ app.use(createTollbooth({
|
|
|
240
242
|
}));
|
|
241
243
|
```
|
|
242
244
|
|
|
245
|
+
The `tempo` object takes `apiKey` (required), `recipient` (required, your
|
|
246
|
+
Tempo payTo), `currency` or `currencies` (TIP-20 token addresses; default USDC.e
|
|
247
|
+
`0x20C000000000000000000000b9537d11c60E8b50`, one challenge minted per currency,
|
|
248
|
+
first = preferred), `splits` (up to 10, total below the price, same transaction),
|
|
249
|
+
`chainId` (default 4217, Tempo mainnet), `apiBaseUrl` (relay, default
|
|
250
|
+
`https://api.tempo.xyz`), `decimals` (6), `timeoutSeconds` (300), plus the
|
|
251
|
+
confirm knobs below. A bad config throws at construction - the gate never boots
|
|
252
|
+
minting unpayable challenges.
|
|
253
|
+
|
|
243
254
|
Or from env on the CLI: `TOLLBOOTH_TEMPO_API_KEY`, `TOLLBOOTH_TEMPO_RECIPIENT`
|
|
244
|
-
(defaults to `TOLLBOOTH_PAYTO`), optional `TOLLBOOTH_TEMPO_CURRENCY
|
|
245
|
-
`TOLLBOOTH_TEMPO_SPLITS="0xabc:0.0002,0xdef:0.0001"
|
|
255
|
+
(defaults to `TOLLBOOTH_PAYTO`), optional `TOLLBOOTH_TEMPO_CURRENCY` (CSV of
|
|
256
|
+
token addresses), `TOLLBOOTH_TEMPO_SPLITS="0xabc:0.0002,0xdef:0.0001"`,
|
|
257
|
+
`TOLLBOOTH_TEMPO_API_BASE` (relay override), `TOLLBOOTH_TEMPO_RPC_URL` (confirm
|
|
258
|
+
RPC override). Tempo works with NO x402 middleware at all (a Tempo-only
|
|
259
|
+
tollbooth) and next to one.
|
|
246
260
|
|
|
247
261
|
Every 402 then carries a `WWW-Authenticate: Payment` tempo/charge challenge
|
|
248
262
|
(next to any evm challenges from the x402 rail, if you run both); a stock
|
|
@@ -293,6 +307,9 @@ live incident transaction in the tests).
|
|
|
293
307
|
| `mpp` | `true` when `x402` set | Accept MPP (`WWW-Authenticate: Payment` / `Authorization: Payment`) through the `x402` middleware |
|
|
294
308
|
| `mppSecret` | `powSecret` / `TOLLBOOTH_SECRET` | HMAC secret binding MPP challenge ids (stable across workers) |
|
|
295
309
|
| `mppNetworks` | `[8453, 42220]` | EVM chain ids to offer as MPP challenges (`"all"` = every EVM chain the middleware advertises) |
|
|
310
|
+
| `tempo` | env (`TOLLBOOTH_TEMPO_*`) or off | Native MPP on Tempo (0.9.0): `{ apiKey, recipient, currency \| currencies, splits, chainId, apiBaseUrl, decimals, timeoutSeconds, confirm, confirmRpcUrl }` - mints `tempo/charge` challenges, validates with Tempo's relay before the handler, broadcasts after a <400 response. Works with or without `x402` |
|
|
311
|
+
| `tempo.confirm` | `true` | Chain-truth confirm (0.9.2): on a relay broadcast failure, check the chain for the credential's own txid before answering 402; `false` disables |
|
|
312
|
+
| `tempo.confirmRpcUrl` | `https://rpc.tempo.xyz` (`TOLLBOOTH_TEMPO_RPC_URL`) | Tempo JSON-RPC used by the confirm step |
|
|
296
313
|
| `verifyX402(req, opts)` | – | Legacy verify-only USDC check (deprecated for settle-after-handler middlewares; see above) |
|
|
297
314
|
| `resourceBaseUrl` | `""` | Absolute base used for the `resource` field / PoW binding |
|
|
298
315
|
| `observe` | `false` | Observe-only: classify and count, but never 402. For pre-launch traffic measurement. |
|
|
@@ -309,11 +326,18 @@ Read by the bundled proxy / Express entry point (`index.js`):
|
|
|
309
326
|
| `TOLLBOOTH_PAYTO` | – | Wallet address; advertises a USDC x402 quote (and, with `TOLLBOOTH_FACILITATOR_URL`, settles it) |
|
|
310
327
|
| `TOLLBOOTH_FACILITATOR_URL` | – | x402 facilitator that settles your network. With `TOLLBOOTH_PAYTO` the CLI builds a real `@x402/express` middleware and takes payment over x402 **and** MPP (0.8.0). Needs `@x402/express @x402/core @x402/evm` installed; refuses to start without them |
|
|
311
328
|
| `TOLLBOOTH_FACILITATOR_HEADERS` | – | Optional JSON object of auth headers sent on the facilitator's `/verify`, `/settle`, `/supported` (e.g. `{"X-API-Key":"…"}`) |
|
|
329
|
+
| `TOLLBOOTH_CDP_API_KEY_ID` + `TOLLBOOTH_CDP_API_KEY_SECRET` | – | Settle through Coinbase's facilitator (CDP) instead of a URL (no fee is taken from the payment itself; Coinbase's facilitator is free for the first 1,000 settlements a month and $0.001 each after). Needs `npm i @coinbase/x402`. This is how a Coinbase Business account gets paid by agents: `TOLLBOOTH_PAYTO` = the account's USDC (Base) receive address. |
|
|
312
330
|
| `TOLLBOOTH_PRICE` | `"$0.001"` | Advertised price per request |
|
|
313
331
|
| `TOLLBOOTH_NETWORK` | `"base"` | x402 network. CLI settlement mode accepts `base`, `base-sepolia`, `polygon`, `arbitrum`, `optimism`, `avalanche`, `celo`, `sei`, `monad`, or a raw `eip155:<id>` |
|
|
314
332
|
| `TOLLBOOTH_MPP` | on when `x402` set | `false` to switch MPP off |
|
|
315
333
|
| `TOLLBOOTH_MPP_SECRET` | `TOLLBOOTH_SECRET` | HMAC secret for MPP challenge ids |
|
|
316
334
|
| `TOLLBOOTH_MPP_NETWORKS` | `8453,42220` | CSV of EVM chain ids to offer as MPP challenges, or `all` |
|
|
335
|
+
| `TOLLBOOTH_TEMPO_API_KEY` | – | Tempo API key (`mpp:write` scope). Presence switches on the native Tempo rail (0.9.0) |
|
|
336
|
+
| `TOLLBOOTH_TEMPO_RECIPIENT` | `TOLLBOOTH_PAYTO` | Tempo payTo (0x address) |
|
|
337
|
+
| `TOLLBOOTH_TEMPO_CURRENCY` | USDC.e on Tempo | CSV of TIP-20 token addresses to offer (one challenge each, first = preferred) |
|
|
338
|
+
| `TOLLBOOTH_TEMPO_SPLITS` | – | `0xabc:0.0002,0xdef:0.0001` - split recipients + USD amounts paid in the same transaction (≤10, total below the price) |
|
|
339
|
+
| `TOLLBOOTH_TEMPO_API_BASE` | `https://api.tempo.xyz` | Tempo relay base URL override |
|
|
340
|
+
| `TOLLBOOTH_TEMPO_RPC_URL` | `https://rpc.tempo.xyz` | Tempo JSON-RPC for the chain-truth confirm step (0.9.2) |
|
|
317
341
|
| `TOLLBOOTH_ASSET` | `"USDC"` | Asset symbol in the quote (`USDG` charges in USDG on Robinhood Chain) |
|
|
318
342
|
| `TOLLBOOTH_POW_BITS` | `18` | Proof-of-work difficulty in leading zero bits |
|
|
319
343
|
| `TOLLBOOTH_MODE` | `"bots"` | Who pays: `bots` · `all` · `strict` |
|
|
@@ -381,7 +405,7 @@ changes. Bots see a `X-Tollbooth-Observed: would-charge` header in observe mode
|
|
|
381
405
|
## Analytics
|
|
382
406
|
|
|
383
407
|
The middleware keeps aggregate counters (no per-request data):
|
|
384
|
-
- `gate.stats()` → sync, in-process mirror: `{ requests, freeAllowed, wouldCharge, charged, powSolved, x402Paid, mppPaid, difficultyNow, observe }`.
|
|
408
|
+
- `gate.stats()` → sync, in-process mirror: `{ requests, freeAllowed, wouldCharge, charged, powSolved, x402Paid, mppPaid, tempoPaid, difficultyNow, observe }`.
|
|
385
409
|
- `gate.snapshot()` → async, reads from the configured durable sink (defaults to memory).
|
|
386
410
|
- `gate.flush()` → flush any buffered deltas to the durable sink (call inside `ctx.waitUntil` on edge runtimes).
|
|
387
411
|
|
|
@@ -585,3 +609,27 @@ Celo by default - what a stock mppx client can sign); add `4663` there or pass
|
|
|
585
609
|
## Legal
|
|
586
610
|
|
|
587
611
|
Use of the hosted instance at agent402.tools is subject to its [Terms of Service](https://agent402.tools/terms) (acceptable-use policy included) and [Privacy Policy](https://agent402.tools/privacy). This package is MIT-licensed; the hosted server is AGPL-3.0. Both are provided as-is without warranty, and self-hosted deployments are their operator's responsibility.
|
|
612
|
+
|
|
613
|
+
## Get paid into a Coinbase Business account
|
|
614
|
+
|
|
615
|
+
Coinbase Business accounts receive x402 payments from AI agents directly. The
|
|
616
|
+
tollbooth is the "integrate x402 into your API" step: point `TOLLBOOTH_PAYTO`
|
|
617
|
+
at the account's USDC receive address on Base and settle through Coinbase's
|
|
618
|
+
facilitator with a CDP API key.
|
|
619
|
+
|
|
620
|
+
```bash
|
|
621
|
+
npm i agent402-tollbooth @x402/express @x402/core @x402/evm @coinbase/x402
|
|
622
|
+
TOLLBOOTH_PAYTO=0xYourCoinbaseBusinessBaseAddress \
|
|
623
|
+
TOLLBOOTH_CDP_API_KEY_ID=... TOLLBOOTH_CDP_API_KEY_SECRET=... \
|
|
624
|
+
TOLLBOOTH_PRICE='$0.005' TOLLBOOTH_UPSTREAM=http://localhost:8080 \
|
|
625
|
+
npx agent402-tollbooth
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
Load the three values from a `.env` or a secret store rather than typing the
|
|
629
|
+
key on the command line. Live-proven 2026-08-27 with real USDC through
|
|
630
|
+
Coinbase's facilitator ([tx](https://basescan.org/tx/0x8175178ac4e2229dfd9385a3c78c491ffe554b08fdf52cf92f99425c983ec5d1));
|
|
631
|
+
note the facilitator refuses a payer paying itself (`self_send_not_allowed`),
|
|
632
|
+
so test from a second wallet. Every paid request settles as USDC into the Coinbase Business account; humans
|
|
633
|
+
browse your site normally. Full walkthrough with an Express example:
|
|
634
|
+
https://agent402.tools/guides/coinbase-business-get-paid-by-agents
|
|
635
|
+
|
package/edge-mpp.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// MPP (Machine Payments Protocol) for the EDGE gate - Web Crypto HMAC, no
|
|
2
|
+
// node:* imports, so it runs where edge.js runs (Cloudflare Workers, Next.js
|
|
3
|
+
// edge middleware, Deno, Bun, Node 20+).
|
|
4
|
+
//
|
|
5
|
+
// Same design as the Node build's mpp.js: settlement authority stays with the
|
|
6
|
+
// operator's x402 verifier, and MPP is pure header translation around it.
|
|
7
|
+
//
|
|
8
|
+
// OUTBOUND the gate's USDC quote (price / network / asset / payTo) becomes
|
|
9
|
+
// ONE HMAC-bound `WWW-Authenticate: Payment` evm/charge challenge
|
|
10
|
+
// on the 402, next to the JSON `accepts` block the x402 path reads.
|
|
11
|
+
// The x402-v2-shaped accept it stands for, plus the resource it
|
|
12
|
+
// was minted for, ride in the challenge's opaque slot so inbound
|
|
13
|
+
// is stateless and byte-exact.
|
|
14
|
+
// INBOUND an `Authorization: Payment` credential whose challenge id
|
|
15
|
+
// HMAC-verifies, is unexpired and was minted for THIS resource is
|
|
16
|
+
// re-encoded as an x402 v2 PAYMENT-SIGNATURE and handed to the
|
|
17
|
+
// SAME verifyX402 callback the x402 path uses - so an operator who
|
|
18
|
+
// already settles x402 on the edge takes MPP with no new code.
|
|
19
|
+
//
|
|
20
|
+
// Receipts: the edge gate answers null (allow) and never sees the operator's
|
|
21
|
+
// response, so it cannot mirror a Payment-Receipt; `receiptFor(tx)` is
|
|
22
|
+
// exported for an operator whose verifier learns the settlement hash.
|
|
23
|
+
import {
|
|
24
|
+
META_ACCEPTS_KEY, STABLECOIN_DECIMALS, encodeRequest, challengeIdInput, serializeChallenge, bytesToB64url,
|
|
25
|
+
decodeCredentialHeader, challengeShapeOk, metaFromChallenge, authorizationFromPayload, paymentSignatureFor, receiptFor,
|
|
26
|
+
} from "./mpp-codec.js";
|
|
27
|
+
export { receiptFor, isMppCredential } from "./mpp-codec.js";
|
|
28
|
+
|
|
29
|
+
const META_RESOURCE_KEY = "resource";
|
|
30
|
+
const te = new TextEncoder();
|
|
31
|
+
|
|
32
|
+
/** USDC on the chains the CLI names (same table as CLI_NETWORKS, plus the
|
|
33
|
+
* token address and its EIP-712 domain name, which a facilitator needs to
|
|
34
|
+
* verify the EIP-3009 signature). Robinhood Chain (USDG) and non-EVM rails
|
|
35
|
+
* are not MPP evm/charge targets here. */
|
|
36
|
+
export const EDGE_USDC = {
|
|
37
|
+
8453: { asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", name: "USD Coin" },
|
|
38
|
+
42220: { asset: "0xcebA9300f2b948710d2653dD7B07f33A8B32118C", name: "USDC" },
|
|
39
|
+
137: { asset: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", name: "USD Coin" },
|
|
40
|
+
42161: { asset: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", name: "USD Coin" },
|
|
41
|
+
10: { asset: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", name: "USD Coin" },
|
|
42
|
+
43114: { asset: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", name: "USD Coin" },
|
|
43
|
+
1329: { asset: "0xe15fC38F6D8c56aF07bbCBe3BAf5708A2Bf42392", name: "USDC" },
|
|
44
|
+
};
|
|
45
|
+
const CHAIN_BY_NAME = { base: 8453, celo: 42220, polygon: 137, arbitrum: 42161, optimism: 10, avalanche: 43114, sei: 1329 };
|
|
46
|
+
|
|
47
|
+
/** "base" | "eip155:8453" -> chain id, or null. */
|
|
48
|
+
export function edgeChainId(network) {
|
|
49
|
+
const n = String(network || "").trim().toLowerCase();
|
|
50
|
+
if (CHAIN_BY_NAME[n]) return CHAIN_BY_NAME[n];
|
|
51
|
+
const m = n.match(/^eip155:(\d+)$/);
|
|
52
|
+
return m ? Number(m[1]) : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** "$0.001" | "0.001" | 0.001 -> base units at 6 decimals ("1000"), or null. */
|
|
56
|
+
export function toBaseUnits(price, decimals = STABLECOIN_DECIMALS) {
|
|
57
|
+
const s = String(price ?? "").trim().replace(/^\$/, "");
|
|
58
|
+
if (!/^\d+(\.\d+)?$/.test(s)) return null;
|
|
59
|
+
const [whole, frac = ""] = s.split(".");
|
|
60
|
+
if (frac.length > decimals) return null; // sub-unit prices cannot be settled
|
|
61
|
+
const units = BigInt(whole) * 10n ** BigInt(decimals) + BigInt((frac + "0".repeat(decimals)).slice(0, decimals));
|
|
62
|
+
return units > 0n ? units.toString() : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function hmacId(secret, c) {
|
|
66
|
+
const key = await crypto.subtle.importKey("raw", te.encode(String(secret)), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
67
|
+
return bytesToB64url(await crypto.subtle.sign("HMAC", key, te.encode(challengeIdInput(c))));
|
|
68
|
+
}
|
|
69
|
+
function constEq(a, b) {
|
|
70
|
+
if (a.length !== b.length) return false;
|
|
71
|
+
let r = 0;
|
|
72
|
+
for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
73
|
+
return r === 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Mint the gate's one MPP challenge. Returns { header, accepted } or null when
|
|
78
|
+
* the quote cannot be expressed as evm/charge (unknown chain, non-USDC asset
|
|
79
|
+
* with no `assetAddress`, unparseable price, no secret).
|
|
80
|
+
*/
|
|
81
|
+
export async function mintEdgeChallenge({ secret, realm, price, network, asset = "USDC", assetAddress, assetName, payTo, resource, ttlSeconds = 300 } = {}) {
|
|
82
|
+
if (!secret || !realm || !payTo || !resource) return null;
|
|
83
|
+
const chainId = edgeChainId(network);
|
|
84
|
+
if (!chainId) return null;
|
|
85
|
+
const known = EDGE_USDC[chainId];
|
|
86
|
+
const token = assetAddress || (String(asset).toUpperCase() === "USDC" && known ? known.asset : null);
|
|
87
|
+
if (!token) return null;
|
|
88
|
+
const amount = toBaseUnits(price);
|
|
89
|
+
if (!amount) return null;
|
|
90
|
+
// Any finite non-zero ttl is honoured (a negative one mints an already-expired
|
|
91
|
+
// challenge - the refusal path tests use it); 0/unset = 300 s.
|
|
92
|
+
const ttl = Number.isFinite(Number(ttlSeconds)) && Number(ttlSeconds) !== 0 ? Number(ttlSeconds) : 300;
|
|
93
|
+
const accepted = {
|
|
94
|
+
scheme: "exact",
|
|
95
|
+
network: `eip155:${chainId}`,
|
|
96
|
+
amount,
|
|
97
|
+
asset: token,
|
|
98
|
+
payTo,
|
|
99
|
+
maxTimeoutSeconds: ttl,
|
|
100
|
+
extra: { name: assetName || (assetAddress ? String(asset) : known.name), version: "2" },
|
|
101
|
+
};
|
|
102
|
+
const c = {
|
|
103
|
+
realm,
|
|
104
|
+
method: "evm",
|
|
105
|
+
intent: "charge",
|
|
106
|
+
request: encodeRequest({
|
|
107
|
+
amount,
|
|
108
|
+
currency: token,
|
|
109
|
+
recipient: payTo,
|
|
110
|
+
methodDetails: { chainId, credentialTypes: ["authorization"], decimals: STABLECOIN_DECIMALS },
|
|
111
|
+
}),
|
|
112
|
+
expires: new Date(Date.now() + ttl * 1000).toISOString(),
|
|
113
|
+
opaque: encodeRequest({ [META_ACCEPTS_KEY]: JSON.stringify(accepted), [META_RESOURCE_KEY]: resource }),
|
|
114
|
+
};
|
|
115
|
+
c.id = await hmacId(secret, c);
|
|
116
|
+
return { header: serializeChallenge(c), accepted };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Validate an MPP credential against our HMAC binding and re-encode it as an
|
|
121
|
+
* x402 v2 PAYMENT-SIGNATURE value. Returns { paymentSignature, accepted,
|
|
122
|
+
* authorization, signature, resource } or null for anything that is not a
|
|
123
|
+
* valid, unexpired, HMAC-bound evm/charge credential of ours.
|
|
124
|
+
*/
|
|
125
|
+
export async function translateEdgeCredential(authorizationHeader, { secret } = {}) {
|
|
126
|
+
if (!secret) return null;
|
|
127
|
+
const wire = decodeCredentialHeader(authorizationHeader);
|
|
128
|
+
const ch = wire?.challenge;
|
|
129
|
+
if (!challengeShapeOk(ch)) return null;
|
|
130
|
+
if (!constEq(String(ch.id), await hmacId(secret, ch))) return null;
|
|
131
|
+
if (ch.expires && !(Date.parse(ch.expires) > Date.now())) return null;
|
|
132
|
+
const meta = metaFromChallenge(ch);
|
|
133
|
+
let accepted;
|
|
134
|
+
try { accepted = JSON.parse(meta[META_ACCEPTS_KEY]); } catch { return null; }
|
|
135
|
+
if (!accepted || typeof accepted !== "object") return null;
|
|
136
|
+
const auth = authorizationFromPayload(wire.payload);
|
|
137
|
+
if (!auth) return null;
|
|
138
|
+
return { paymentSignature: paymentSignatureFor(accepted, auth), accepted, ...auth, resource: typeof meta[META_RESOURCE_KEY] === "string" ? meta[META_RESOURCE_KEY] : null };
|
|
139
|
+
}
|
package/edge.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// See worker.js for a Cloudflare Worker entry, and the README for Next.js.
|
|
9
9
|
import { makeBotMatcher, AI_BOTS } from "./bots.js";
|
|
10
10
|
import { memorySink } from "./sinks.js";
|
|
11
|
+
import { mintEdgeChallenge, translateEdgeCredential, isMppCredential } from "./edge-mpp.js";
|
|
11
12
|
|
|
12
13
|
export { memorySink, kvStatsSink, httpStatsSink } from "./sinks.js";
|
|
13
14
|
|
|
@@ -135,6 +136,17 @@ export function createEdgeTollbooth(config = {}) {
|
|
|
135
136
|
// payment, which is why the accepts block below is withheld when it is
|
|
136
137
|
// absent - see the note there.
|
|
137
138
|
verifyX402 = null,
|
|
139
|
+
// MPP (the "Payment" HTTP auth scheme) on the same verifier: default ON
|
|
140
|
+
// whenever the USDC quote is live (payTo + verifyX402 + secret). The 402
|
|
141
|
+
// gains a WWW-Authenticate: Payment challenge and an Authorization: Payment
|
|
142
|
+
// credential is translated to PAYMENT-SIGNATURE and handed to verifyX402.
|
|
143
|
+
// See edge-mpp.js. `mppAssetAddress`/`mppAssetName` name a token the
|
|
144
|
+
// built-in USDC table does not know (the domain name is what a facilitator
|
|
145
|
+
// verifies the EIP-3009 signature under).
|
|
146
|
+
mpp = true,
|
|
147
|
+
mppAssetAddress,
|
|
148
|
+
mppAssetName,
|
|
149
|
+
mppRealm,
|
|
138
150
|
botUserAgents = AI_BOTS,
|
|
139
151
|
// "bots" (default, charge AI crawler UAs) | "all" (charge all but free()) |
|
|
140
152
|
// "strict" (charge anything that isn't a real-browser request). An explicit
|
|
@@ -163,6 +175,7 @@ export function createEdgeTollbooth(config = {}) {
|
|
|
163
175
|
);
|
|
164
176
|
}
|
|
165
177
|
|
|
178
|
+
const mppOn = Boolean(mpp && secret && payTo && verifyX402);
|
|
166
179
|
const isBot = makeBotMatcher(botUserAgents);
|
|
167
180
|
const powEngine = pow ? createEdgePow({ secret, difficulty: powDifficulty, store }) : null;
|
|
168
181
|
const mem = memorySink();
|
|
@@ -222,6 +235,23 @@ export function createEdgeTollbooth(config = {}) {
|
|
|
222
235
|
paid = false;
|
|
223
236
|
}
|
|
224
237
|
if (paid) { incr("paid"); return null; }
|
|
238
|
+
} else if (mppOn && isMppCredential(request.headers.get("authorization"))) {
|
|
239
|
+
// MPP inbound: only a credential whose challenge id HMAC-verifies as
|
|
240
|
+
// OURS, unexpired, and minted for THIS resource (a credential for /a
|
|
241
|
+
// must not pay for /b - the challenge carries the resource it was minted
|
|
242
|
+
// for). The verifier sees the same request with PAYMENT-SIGNATURE in
|
|
243
|
+
// place of Authorization, exactly as an x402 client would have sent it.
|
|
244
|
+
let paid = false;
|
|
245
|
+
try {
|
|
246
|
+
const t = await translateEdgeCredential(request.headers.get("authorization"), { secret });
|
|
247
|
+
if (t && t.resource === resource) {
|
|
248
|
+
const headers = new Headers(request.headers);
|
|
249
|
+
headers.set("payment-signature", t.paymentSignature);
|
|
250
|
+
headers.delete("authorization");
|
|
251
|
+
paid = await verifyX402(new Request(request, { headers }), { price, network, asset, payTo, resource, accepted: t.accepted });
|
|
252
|
+
}
|
|
253
|
+
} catch { paid = false; }
|
|
254
|
+
if (paid) { incr("paid"); incr("mppPaid"); return null; }
|
|
225
255
|
}
|
|
226
256
|
|
|
227
257
|
const sol = request.headers.get("x-pow-solution");
|
|
@@ -242,10 +272,16 @@ export function createEdgeTollbooth(config = {}) {
|
|
|
242
272
|
accepts: (payTo && verifyX402) ? [{ scheme: "exact", network, maxAmountRequired: String(price), asset, payTo, resource }] : [],
|
|
243
273
|
};
|
|
244
274
|
if (powEngine) body.proofOfWork = await powEngine.challenge(powResource);
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
275
|
+
const headers = { "content-type": "application/json", "x-tollbooth": "pay-per-crawl" };
|
|
276
|
+
if (mppOn) {
|
|
277
|
+
// One evm/charge challenge for the gate's quote (never a price the
|
|
278
|
+
// x402 path would not honour - both are built from the same fields).
|
|
279
|
+
try {
|
|
280
|
+
const minted = await mintEdgeChallenge({ secret, realm: mppRealm || u.host, price, network, asset, assetAddress: mppAssetAddress, assetName: mppAssetName, payTo, resource });
|
|
281
|
+
if (minted) headers["www-authenticate"] = minted.header;
|
|
282
|
+
} catch { /* a quote MPP cannot express is still a valid x402 402 */ }
|
|
283
|
+
}
|
|
284
|
+
return new Response(JSON.stringify(body), { status: 402, headers });
|
|
249
285
|
}
|
|
250
286
|
|
|
251
287
|
// Operator surface: in-process mirror (sync), durable snapshot (async),
|
package/index.js
CHANGED
|
@@ -98,6 +98,8 @@ const STRIP_INBOUND = new Set([
|
|
|
98
98
|
"host", "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
|
99
99
|
"te", "trailer", "transfer-encoding", "upgrade", "content-length",
|
|
100
100
|
"x-tollbooth-paid", "x-tollbooth-error", "x-pow-error", "x-forwarded-host", "forwarded",
|
|
101
|
+
// client-IP / scheme trust headers an upstream might honour (spoofable by the bot)
|
|
102
|
+
"x-real-ip", "cf-connecting-ip", "true-client-ip", "x-client-ip", "fastly-client-ip", "x-forwarded-proto", "x-forwarded-port", "via",
|
|
101
103
|
]);
|
|
102
104
|
|
|
103
105
|
/**
|
|
@@ -686,9 +688,17 @@ export const CLI_NETWORKS = {
|
|
|
686
688
|
export async function buildCliX402Middleware(env = process.env) {
|
|
687
689
|
const payTo = env.TOLLBOOTH_PAYTO || "";
|
|
688
690
|
const facilitatorUrl = env.TOLLBOOTH_FACILITATOR_URL || "";
|
|
691
|
+
// Coinbase's facilitator (CDP) authenticates every verify/settle call with
|
|
692
|
+
// a short-lived JWT signed by a CDP API key, so a static header cannot
|
|
693
|
+
// reach it; @coinbase/x402's createFacilitatorConfig mints them. With the
|
|
694
|
+
// two keys set the CLI settles through CDP (no fee is taken from the
|
|
695
|
+
// payment; CDP's own free tier is 1,000 settlements a month, then $0.001
|
|
696
|
+
// each), no URL needed. This is
|
|
697
|
+
// the path a Coinbase Business account uses to get paid by agents.
|
|
698
|
+
const cdpKeys = env.TOLLBOOTH_CDP_API_KEY_ID && env.TOLLBOOTH_CDP_API_KEY_SECRET;
|
|
689
699
|
if (!payTo) return null;
|
|
690
|
-
if (!facilitatorUrl) {
|
|
691
|
-
console.warn("⚠ TOLLBOOTH_PAYTO is set without TOLLBOOTH_FACILITATOR_URL: the 402 advertises a USDC quote but nothing here can verify or settle a payment, so every paid request is refused. Set TOLLBOOTH_FACILITATOR_URL (a public x402 facilitator that settles your network - keyless free tiers exist; https://x402.org/facilitator for base-sepolia) to actually charge, over x402 and MPP.");
|
|
700
|
+
if (!facilitatorUrl && !cdpKeys) {
|
|
701
|
+
console.warn("⚠ TOLLBOOTH_PAYTO is set without TOLLBOOTH_FACILITATOR_URL (or TOLLBOOTH_CDP_API_KEY_ID/SECRET): the 402 advertises a USDC quote but nothing here can verify or settle a payment, so every paid request is refused. Set TOLLBOOTH_FACILITATOR_URL (a public x402 facilitator that settles your network - keyless free tiers exist; https://x402.org/facilitator for base-sepolia) to actually charge, over x402 and MPP.");
|
|
692
702
|
return null;
|
|
693
703
|
}
|
|
694
704
|
const asset = (env.TOLLBOOTH_ASSET || "USDC").toUpperCase();
|
|
@@ -708,7 +718,7 @@ export async function buildCliX402Middleware(env = process.env) {
|
|
|
708
718
|
({ HTTPFacilitatorClient, x402ResourceServer } = await import("@x402/core/server"));
|
|
709
719
|
({ ExactEvmScheme } = await import("@x402/evm/exact/server"));
|
|
710
720
|
} catch (e) {
|
|
711
|
-
console.error(`✖ TOLLBOOTH_FACILITATOR_URL is set, so this gate must settle x402 payments, but the x402 packages are not installed (${String(e?.message || e).slice(0, 120)}). Install them next to agent402-tollbooth: npm i @x402/express @x402/core @x402/evm . Refusing to start rather than advertise a quote it cannot settle.`);
|
|
721
|
+
console.error(`✖ TOLLBOOTH_FACILITATOR_URL or TOLLBOOTH_CDP_API_KEY_ID/SECRET is set, so this gate must settle x402 payments, but the x402 packages are not installed (${String(e?.message || e).slice(0, 120)}). Install them next to agent402-tollbooth: npm i @x402/express @x402/core @x402/evm . Refusing to start rather than advertise a quote it cannot settle.`);
|
|
712
722
|
process.exit(1);
|
|
713
723
|
}
|
|
714
724
|
// Optional facilitator auth headers, sent on /verify, /settle and /supported
|
|
@@ -717,7 +727,26 @@ export async function buildCliX402Middleware(env = process.env) {
|
|
|
717
727
|
if (env.TOLLBOOTH_FACILITATOR_HEADERS) {
|
|
718
728
|
try { authHeaders = JSON.parse(env.TOLLBOOTH_FACILITATOR_HEADERS); } catch { console.error("✖ TOLLBOOTH_FACILITATOR_HEADERS must be a JSON object of header name -> value. Refusing to start."); process.exit(1); }
|
|
719
729
|
}
|
|
720
|
-
|
|
730
|
+
let cdpConfig = null;
|
|
731
|
+
if (cdpKeys) {
|
|
732
|
+
if (facilitatorUrl || authHeaders) console.warn("⚠ TOLLBOOTH_CDP_API_KEY_ID/SECRET are set, so TOLLBOOTH_FACILITATOR_URL / TOLLBOOTH_FACILITATOR_HEADERS are ignored: settlement goes through Coinbase's facilitator.");
|
|
733
|
+
let createFacilitatorConfig;
|
|
734
|
+
try { ({ createFacilitatorConfig } = await import("@coinbase/x402")); } catch (e) {
|
|
735
|
+
console.error(`✖ TOLLBOOTH_CDP_API_KEY_ID/SECRET are set, so this gate must settle through Coinbase's facilitator, but @coinbase/x402 is not installed (${String(e?.message || e).slice(0, 120)}). Install it: npm install @coinbase/x402`);
|
|
736
|
+
process.exit(1);
|
|
737
|
+
}
|
|
738
|
+
cdpConfig = createFacilitatorConfig(env.TOLLBOOTH_CDP_API_KEY_ID, env.TOLLBOOTH_CDP_API_KEY_SECRET);
|
|
739
|
+
// Prove the key can sign BEFORE claiming to settle: createFacilitatorConfig
|
|
740
|
+
// validates nothing, and a key that cannot mint a JWT would otherwise boot
|
|
741
|
+
// "settling via Coinbase CDP" and answer every paid request 500 (nobody
|
|
742
|
+
// charged, every agent turned away). Minting one JWT is local, no network.
|
|
743
|
+
try { await cdpConfig.createAuthHeaders(); } catch (e) {
|
|
744
|
+
const why = String(e?.message || e).replace(env.TOLLBOOTH_CDP_API_KEY_SECRET, "<secret>").slice(0, 160);
|
|
745
|
+
console.error(`✖ TOLLBOOTH_CDP_API_KEY_SECRET cannot sign a facilitator request (${why}). Check the CDP API key secret; nothing is being charged.`);
|
|
746
|
+
process.exit(1);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
const client = new HTTPFacilitatorClient(cdpConfig || {
|
|
721
750
|
url: facilitatorUrl,
|
|
722
751
|
...(authHeaders ? { createAuthHeaders: async () => ({ verify: authHeaders, settle: authHeaders, supported: authHeaders }) } : {}),
|
|
723
752
|
});
|
|
@@ -856,12 +885,29 @@ async function startCli() {
|
|
|
856
885
|
} else {
|
|
857
886
|
app.use((_req, res) => res.json({ ok: true, note: "Bare tollbooth gate (no TOLLBOOTH_UPSTREAM set). Clients that reach here paid or solved a proof-of-work." }));
|
|
858
887
|
}
|
|
859
|
-
app.listen(port, () => {
|
|
860
|
-
const paidLabel = x402mw ? `x402 + MPP (${process.env.TOLLBOOTH_ASSET || "USDC"}, settling via ${process.env.TOLLBOOTH_FACILITATOR_URL})` : (process.env.TOLLBOOTH_PAYTO ? `x402 quote only (${process.env.TOLLBOOTH_ASSET || "USDC"}, NOT settling - set TOLLBOOTH_FACILITATOR_URL)` : "");
|
|
888
|
+
const server = app.listen(port, () => {
|
|
889
|
+
const paidLabel = x402mw ? `x402 + MPP (${process.env.TOLLBOOTH_ASSET || "USDC"}, settling via ${process.env.TOLLBOOTH_CDP_API_KEY_ID && process.env.TOLLBOOTH_CDP_API_KEY_SECRET ? "Coinbase CDP" : process.env.TOLLBOOTH_FACILITATOR_URL})` : (process.env.TOLLBOOTH_PAYTO ? `x402 quote only (${process.env.TOLLBOOTH_ASSET || "USDC"}, NOT settling - set TOLLBOOTH_FACILITATOR_URL)` : "");
|
|
861
890
|
const rails = [gate.pow ? "proof-of-work" : "", paidLabel].filter(Boolean).join(" + ");
|
|
862
|
-
|
|
891
|
+
// The BOUND port, so PORT=0 (let the OS pick) prints something a caller can use.
|
|
892
|
+
const bound = server.address()?.port ?? port;
|
|
893
|
+
console.log(`agent402-tollbooth listening on :${bound} — charging AI bots via ${rails || "proof-of-work"}`);
|
|
863
894
|
if (upstream) console.log(` proxying → ${upstream}`);
|
|
864
895
|
});
|
|
896
|
+
// Evidence for a process that leaves without being told to. Four times
|
|
897
|
+
// (2026-08-19, 08-22, 08-26, 08-27; CI only, never locally) the CLI printed the
|
|
898
|
+
// banner above and then exited 0 with no signal and no --trace-exit stack: the
|
|
899
|
+
// event loop drained under a LISTENING server, which should be impossible while
|
|
900
|
+
// the handle is ref'd. `beforeExit` is the one hook that fires exactly then;
|
|
901
|
+
// naming the live handles and the server's own state at that instant is the
|
|
902
|
+
// record every earlier occurrence lacked. Costs nothing in a healthy process
|
|
903
|
+
// (it never fires while the server listens).
|
|
904
|
+
server.on("close", () => console.error("[agent402-tollbooth] http server closed"));
|
|
905
|
+
server.on("error", (e) => console.error(`[agent402-tollbooth] http server error: ${e?.code || ""} ${e?.message || e}`));
|
|
906
|
+
process.on("beforeExit", (code) => {
|
|
907
|
+
let active = "?";
|
|
908
|
+
try { active = JSON.stringify(process.getActiveResourcesInfo()); } catch { /* older node */ }
|
|
909
|
+
console.error(`[agent402-tollbooth] event loop drained (exit code ${code}) while listening=${server.listening} address=${JSON.stringify(server.address())} active=${active}`);
|
|
910
|
+
});
|
|
865
911
|
}
|
|
866
912
|
|
|
867
913
|
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) startCli();
|
package/mpp-codec.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// MPP wire codec shared by the Node build (mpp.js, HMAC via node:crypto) and
|
|
2
|
+
// the edge build (edge-mpp.js, HMAC via Web Crypto). Runtime-agnostic on
|
|
3
|
+
// purpose: no node:* imports, no Buffer - only TextEncoder/TextDecoder,
|
|
4
|
+
// btoa/atob, which every Fetch-API runtime (Workers, Next.js edge, Deno, Bun,
|
|
5
|
+
// Node 20+) provides. Nothing in here signs or verifies; the two builds own
|
|
6
|
+
// that, so this file cannot mint a challenge on its own.
|
|
7
|
+
//
|
|
8
|
+
// Wire (tempoxyz/mpp, paymentauth.org):
|
|
9
|
+
// challenge WWW-Authenticate: Payment id="...", realm="...", method="evm",
|
|
10
|
+
// intent="charge", request="<b64url JCS>", expires="...", opaque="<b64url JCS>"
|
|
11
|
+
// credential Authorization: Payment <b64url JSON{challenge, payload, source?}>
|
|
12
|
+
// receipt Payment-Receipt: <b64url JSON{method, status, reference, timestamp}>
|
|
13
|
+
export const SCHEME = "Payment";
|
|
14
|
+
export const META_ACCEPTS_KEY = "x402";
|
|
15
|
+
export const STABLECOIN_DECIMALS = 6; // Circle USDC + Paxos USDG on every EVM rail
|
|
16
|
+
// What a stock mppx client can auto-sign (its built-in asset registry covers
|
|
17
|
+
// Base + Celo mainnets); every extra challenge costs ~800 bytes on every 402.
|
|
18
|
+
export const DEFAULT_MPP_CHAIN_IDS = [8453, 42220];
|
|
19
|
+
|
|
20
|
+
const te = new TextEncoder();
|
|
21
|
+
const td = new TextDecoder();
|
|
22
|
+
function bytesToBinary(bytes) {
|
|
23
|
+
let s = "";
|
|
24
|
+
for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
|
25
|
+
return s;
|
|
26
|
+
}
|
|
27
|
+
const binaryToBytes = (bin) => Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
28
|
+
/** utf8 string -> standard base64 */
|
|
29
|
+
export const b64std = (s) => btoa(bytesToBinary(te.encode(String(s))));
|
|
30
|
+
/** standard base64 -> utf8 string (throws on junk) */
|
|
31
|
+
export const unb64std = (s) => td.decode(binaryToBytes(atob(String(s).replace(/-/g, "+").replace(/_/g, "/").replace(/\s+/g, ""))));
|
|
32
|
+
/** utf8 string -> base64url (no padding) */
|
|
33
|
+
export const b64url = (s) => b64std(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
34
|
+
/** base64url -> utf8 string (padding optional; throws on junk) */
|
|
35
|
+
export function unb64url(s) {
|
|
36
|
+
let v = String(s).replace(/-/g, "+").replace(/_/g, "/");
|
|
37
|
+
while (v.length % 4) v += "=";
|
|
38
|
+
return td.decode(binaryToBytes(atob(v)));
|
|
39
|
+
}
|
|
40
|
+
/** bytes -> base64url, for HMAC digests */
|
|
41
|
+
export const bytesToB64url = (bytes) => btoa(bytesToBinary(new Uint8Array(bytes))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
42
|
+
|
|
43
|
+
/** RFC 8785-style canonical JSON (sorted object keys, no whitespace) - what the
|
|
44
|
+
* reference implementation uses for the `request`/`opaque` slots. */
|
|
45
|
+
export function canonicalJson(value) {
|
|
46
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
47
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
48
|
+
const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
|
|
49
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
|
|
50
|
+
}
|
|
51
|
+
export const encodeRequest = (obj) => b64url(canonicalJson(obj));
|
|
52
|
+
|
|
53
|
+
/** Positional id-binding input - the same slot layout the reference
|
|
54
|
+
* implementation HMACs, so its Challenge.verify() agrees with ours given the
|
|
55
|
+
* same secret. The builds sign this string. */
|
|
56
|
+
export const challengeIdInput = (c) =>
|
|
57
|
+
[c.realm, c.method, c.intent, c.request, c.expires ?? "", c.digest ?? "", c.opaque ?? ""].join("|");
|
|
58
|
+
|
|
59
|
+
export const authParam = (name, value) => {
|
|
60
|
+
const v = String(value);
|
|
61
|
+
if (/[\r\n]/.test(v)) throw new Error("invalid auth-param value");
|
|
62
|
+
return `${name}="${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** One serialized challenge (the caller has already set `c.id`). */
|
|
66
|
+
export function serializeChallenge(c) {
|
|
67
|
+
return `${SCHEME} ` + [
|
|
68
|
+
authParam("id", c.id), authParam("realm", c.realm), authParam("method", c.method), authParam("intent", c.intent),
|
|
69
|
+
authParam("request", c.request), authParam("expires", c.expires), authParam("opaque", c.opaque),
|
|
70
|
+
].join(", ");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The evm/charge request slot for one x402-shaped accept. */
|
|
74
|
+
export function chargeRequestFor({ amount, asset, chainId }) {
|
|
75
|
+
return encodeRequest({
|
|
76
|
+
amount: String(amount),
|
|
77
|
+
currency: asset,
|
|
78
|
+
// `recipient` is filled by the caller's accept; kept explicit there.
|
|
79
|
+
methodDetails: { chainId, credentialTypes: ["authorization"], decimals: STABLECOIN_DECIMALS },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** True when the header looks like an MPP credential (scheme check only). */
|
|
84
|
+
export function isMppCredential(authorizationHeader) {
|
|
85
|
+
return typeof authorizationHeader === "string" && /^Payment\s+\S/i.test(authorizationHeader);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Decode `Authorization: Payment <b64url JSON>` to its wire object, or null. */
|
|
89
|
+
export function decodeCredentialHeader(authorizationHeader) {
|
|
90
|
+
if (typeof authorizationHeader !== "string") return null;
|
|
91
|
+
const m = authorizationHeader.match(/^Payment\s+([A-Za-z0-9_-]+=*)\s*$/i);
|
|
92
|
+
if (!m) return null;
|
|
93
|
+
try { return JSON.parse(unb64url(m[1])); } catch { return null; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Shape-check an evm/charge challenge object from a credential (no HMAC here). */
|
|
97
|
+
export function challengeShapeOk(ch) {
|
|
98
|
+
return !!ch && typeof ch === "object" && ch.method === "evm" && ch.intent === "charge"
|
|
99
|
+
&& typeof ch.request === "string" && typeof ch.opaque === "string" && typeof ch.id === "string";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The meta object bound into the challenge's opaque slot, or null. */
|
|
103
|
+
export function metaFromChallenge(ch) {
|
|
104
|
+
try {
|
|
105
|
+
const meta = JSON.parse(unb64url(ch.opaque));
|
|
106
|
+
return meta && typeof meta === "object" ? meta : null;
|
|
107
|
+
} catch { return null; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const HEX_ADDR = /^0x[0-9a-fA-F]{40}$/;
|
|
111
|
+
const HEX_32 = /^0x[0-9a-fA-F]{64}$/;
|
|
112
|
+
const HEX_SIG = /^0x[0-9a-fA-F]+$/;
|
|
113
|
+
const UINT = /^\d+$/;
|
|
114
|
+
/** Validate an `authorization` payload; returns {authorization, signature} or null. */
|
|
115
|
+
export function authorizationFromPayload(p) {
|
|
116
|
+
if (!p || p.type !== "authorization") return null;
|
|
117
|
+
const { from, to, value, validAfter, validBefore, nonce, signature } = p;
|
|
118
|
+
if (!HEX_ADDR.test(String(from)) || !HEX_ADDR.test(String(to)) || !HEX_32.test(String(nonce)) || !HEX_SIG.test(String(signature))) return null;
|
|
119
|
+
if (!UINT.test(String(value)) || !UINT.test(String(validAfter)) || !UINT.test(String(validBefore))) return null;
|
|
120
|
+
return { authorization: { from, to, value: String(value), validAfter: String(validAfter), validBefore: String(validBefore), nonce }, signature };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** x402 v2 PAYMENT-SIGNATURE value (standard base64 JSON) for an accept + authorization. */
|
|
124
|
+
export function paymentSignatureFor(accepted, { authorization, signature }) {
|
|
125
|
+
return b64std(JSON.stringify({ x402Version: 2, accepted, payload: { authorization, signature } }));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** MPP Payment-Receipt value for a settled transaction reference. */
|
|
129
|
+
export function receiptFor(transaction) {
|
|
130
|
+
return b64url(JSON.stringify({ method: "evm", status: "success", reference: transaction, timestamp: new Date().toISOString() }));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Decode a settled x402 PAYMENT-RESPONSE header to its transaction, or null. */
|
|
134
|
+
export function transactionFromPaymentResponse(paymentResponseHeader) {
|
|
135
|
+
let settle;
|
|
136
|
+
try { settle = JSON.parse(unb64std(paymentResponseHeader)); } catch { return null; }
|
|
137
|
+
if (!settle || settle.success !== true || typeof settle.transaction !== "string") return null;
|
|
138
|
+
return settle.transaction;
|
|
139
|
+
}
|
package/mpp.js
CHANGED
|
@@ -29,47 +29,28 @@
|
|
|
29
29
|
// reference client is proven by scripts/test-tollbooth-mpp.js in the parent
|
|
30
30
|
// repo, which drives a REAL mppx client through this codec.
|
|
31
31
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
32
|
+
import {
|
|
33
|
+
META_ACCEPTS_KEY, STABLECOIN_DECIMALS, DEFAULT_MPP_CHAIN_IDS, b64url, unb64url, b64std, unb64std,
|
|
34
|
+
canonicalJson, encodeRequest, challengeIdInput, serializeChallenge, decodeCredentialHeader, challengeShapeOk,
|
|
35
|
+
metaFromChallenge, authorizationFromPayload, paymentSignatureFor, receiptFor, transactionFromPaymentResponse,
|
|
36
|
+
} from "./mpp-codec.js";
|
|
32
37
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
// Base + Celo mainnets); every extra challenge costs ~800 bytes on every 402.
|
|
38
|
-
export const DEFAULT_MPP_CHAIN_IDS = [8453, 42220];
|
|
39
|
-
|
|
40
|
-
// ---- encoding primitives ---------------------------------------------------
|
|
41
|
-
export const b64url = (s) => Buffer.from(s, "utf8").toString("base64url");
|
|
42
|
-
export const unb64url = (s) => Buffer.from(String(s), "base64url").toString("utf8");
|
|
43
|
-
const b64std = (s) => Buffer.from(s, "utf8").toString("base64");
|
|
44
|
-
const unb64std = (s) => Buffer.from(String(s), "base64").toString("utf8");
|
|
45
|
-
|
|
46
|
-
/** RFC 8785-style canonical JSON (sorted object keys, no whitespace) - what the
|
|
47
|
-
* reference implementation uses for the `request`/`opaque` slots. */
|
|
48
|
-
export function canonicalJson(value) {
|
|
49
|
-
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
50
|
-
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
51
|
-
const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
|
|
52
|
-
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
|
|
53
|
-
}
|
|
54
|
-
const encodeRequest = (obj) => b64url(canonicalJson(obj));
|
|
38
|
+
// The wire primitives live in mpp-codec.js (runtime-agnostic, shared with the
|
|
39
|
+
// edge build); this file adds the node:crypto HMAC binding on top.
|
|
40
|
+
export { DEFAULT_MPP_CHAIN_IDS, b64url, unb64url, canonicalJson };
|
|
41
|
+
export { isMppCredential } from "./mpp-codec.js";
|
|
55
42
|
|
|
56
43
|
// Positional HMAC binding of the challenge id - the same slot layout the
|
|
57
44
|
// reference implementation uses, so its Challenge.verify() agrees with ours
|
|
58
45
|
// given the same secret (handy in tests; the spec only requires that WE bind).
|
|
59
46
|
function challengeId(secretKey, c) {
|
|
60
|
-
|
|
61
|
-
return createHmac("sha256", Buffer.from(secretKey, "utf8")).update(input, "utf8").digest("base64url");
|
|
47
|
+
return createHmac("sha256", Buffer.from(secretKey, "utf8")).update(challengeIdInput(c), "utf8").digest("base64url");
|
|
62
48
|
}
|
|
63
49
|
function idMatches(secretKey, c) {
|
|
64
50
|
const a = Buffer.from(String(c.id || ""), "utf8");
|
|
65
51
|
const b = Buffer.from(challengeId(secretKey, c), "utf8");
|
|
66
52
|
return a.length === b.length && timingSafeEqual(a, b);
|
|
67
53
|
}
|
|
68
|
-
const authParam = (name, value) => {
|
|
69
|
-
const v = String(value);
|
|
70
|
-
if (/[\r\n]/.test(v)) throw new Error("invalid auth-param value");
|
|
71
|
-
return `${name}="${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
72
|
-
};
|
|
73
54
|
|
|
74
55
|
// ---- OUTBOUND: PAYMENT-REQUIRED -> WWW-Authenticate: Payment ---------------
|
|
75
56
|
/**
|
|
@@ -110,67 +91,37 @@ export function challengesFromPaymentRequired(paymentRequiredHeader, { secretKey
|
|
|
110
91
|
opaque: encodeRequest({ [META_ACCEPTS_KEY]: JSON.stringify(a) }),
|
|
111
92
|
};
|
|
112
93
|
c.id = challengeId(secretKey, c);
|
|
113
|
-
out.push(
|
|
114
|
-
authParam("id", c.id), authParam("realm", c.realm), authParam("method", c.method), authParam("intent", c.intent),
|
|
115
|
-
authParam("request", c.request), authParam("expires", c.expires), authParam("opaque", c.opaque),
|
|
116
|
-
].join(", "));
|
|
94
|
+
out.push(serializeChallenge(c));
|
|
117
95
|
}
|
|
118
96
|
if (!out.length) return null;
|
|
119
|
-
return out.
|
|
97
|
+
return out.join(", ");
|
|
120
98
|
}
|
|
121
99
|
|
|
122
100
|
// ---- INBOUND: Authorization: Payment -> PAYMENT-SIGNATURE ------------------
|
|
123
|
-
const HEX_ADDR = /^0x[0-9a-fA-F]{40}$/;
|
|
124
|
-
const HEX_32 = /^0x[0-9a-fA-F]{64}$/;
|
|
125
|
-
const HEX_SIG = /^0x[0-9a-fA-F]+$/;
|
|
126
|
-
const UINT = /^\d+$/;
|
|
127
|
-
|
|
128
101
|
/**
|
|
129
102
|
* Validate an MPP credential against our HMAC binding and re-encode it as an
|
|
130
103
|
* x402 v2 PAYMENT-SIGNATURE value. Returns null for anything that is not a
|
|
131
104
|
* valid, unexpired, HMAC-bound evm/charge credential of ours.
|
|
132
105
|
*/
|
|
133
106
|
export function translateCredential(authorizationHeader, { secretKey } = {}) {
|
|
134
|
-
if (!secretKey
|
|
135
|
-
const
|
|
136
|
-
if (!m) return null;
|
|
137
|
-
let wire;
|
|
138
|
-
try { wire = JSON.parse(unb64url(m[1])); } catch { return null; }
|
|
107
|
+
if (!secretKey) return null;
|
|
108
|
+
const wire = decodeCredentialHeader(authorizationHeader);
|
|
139
109
|
const ch = wire?.challenge;
|
|
140
|
-
if (!ch
|
|
141
|
-
if (ch.method !== "evm" || ch.intent !== "charge") return null;
|
|
142
|
-
if (typeof ch.request !== "string" || typeof ch.opaque !== "string") return null;
|
|
110
|
+
if (!challengeShapeOk(ch)) return null;
|
|
143
111
|
// HMAC binding (spec: servers MUST bind ids to challenge params). This also
|
|
144
112
|
// proves the echoed accepts entry in opaque is ours and untampered.
|
|
145
113
|
if (!idMatches(secretKey, ch)) return null;
|
|
146
114
|
if (ch.expires && !(Date.parse(ch.expires) > Date.now())) return null;
|
|
147
115
|
let accepted;
|
|
148
|
-
try {
|
|
149
|
-
const meta = JSON.parse(unb64url(ch.opaque));
|
|
150
|
-
accepted = JSON.parse(meta[META_ACCEPTS_KEY]);
|
|
151
|
-
} catch { return null; }
|
|
116
|
+
try { accepted = JSON.parse(metaFromChallenge(ch)[META_ACCEPTS_KEY]); } catch { return null; }
|
|
152
117
|
if (!accepted || typeof accepted !== "object") return null;
|
|
153
|
-
const
|
|
154
|
-
if (!
|
|
155
|
-
|
|
156
|
-
if (!HEX_ADDR.test(String(from)) || !HEX_ADDR.test(String(to)) || !HEX_32.test(String(nonce)) || !HEX_SIG.test(String(signature))) return null;
|
|
157
|
-
if (!UINT.test(String(value)) || !UINT.test(String(validAfter)) || !UINT.test(String(validBefore))) return null;
|
|
158
|
-
return b64std(JSON.stringify({
|
|
159
|
-
x402Version: 2,
|
|
160
|
-
accepted,
|
|
161
|
-
payload: { authorization: { from, to, value: String(value), validAfter: String(validAfter), validBefore: String(validBefore), nonce }, signature },
|
|
162
|
-
}));
|
|
118
|
+
const auth = authorizationFromPayload(wire.payload);
|
|
119
|
+
if (!auth) return null;
|
|
120
|
+
return paymentSignatureFor(accepted, auth);
|
|
163
121
|
}
|
|
164
122
|
|
|
165
123
|
// ---- RECEIPT: PAYMENT-RESPONSE -> Payment-Receipt -------------------------
|
|
166
124
|
export function receiptFromPaymentResponse(paymentResponseHeader) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (!settle || settle.success !== true || typeof settle.transaction !== "string") return null;
|
|
170
|
-
return b64url(JSON.stringify({ method: "evm", status: "success", reference: settle.transaction, timestamp: new Date().toISOString() }));
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** True when the header looks like an MPP credential (scheme check only). */
|
|
174
|
-
export function isMppCredential(authorizationHeader) {
|
|
175
|
-
return typeof authorizationHeader === "string" && /^Payment\s+\S/i.test(authorizationHeader);
|
|
125
|
+
const tx = transactionFromPaymentResponse(paymentResponseHeader);
|
|
126
|
+
return tx ? receiptFor(tx) : null;
|
|
176
127
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-tollbooth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Agent402 tollbooth: open-source, self-hostable pay-per-crawl for x402 AND MPP (the sell side of Agentic Finance): charge AI bots per request (USDC via x402, or MPP - the Machine Payments Protocol - through the same settlement, or USDG on Robinhood Chain, or free proof-of-work) while humans browse free. Drop-in Express middleware or reverse proxy that settles both wires from env alone - no Cloudflare, no Stripe, no signup.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
"./replay": "./replay.js",
|
|
19
19
|
"./replay.js": "./replay.js",
|
|
20
20
|
"./mpp": "./mpp.js",
|
|
21
|
-
"./mpp.js": "./mpp.js"
|
|
21
|
+
"./mpp.js": "./mpp.js",
|
|
22
|
+
"./edge-mpp": "./edge-mpp.js",
|
|
23
|
+
"./edge-mpp.js": "./edge-mpp.js",
|
|
24
|
+
"./mpp-codec.js": "./mpp-codec.js"
|
|
22
25
|
},
|
|
23
26
|
"files": [
|
|
24
27
|
"index.js",
|
|
@@ -27,6 +30,8 @@
|
|
|
27
30
|
"bots.js",
|
|
28
31
|
"pow.js",
|
|
29
32
|
"mpp.js",
|
|
33
|
+
"mpp-codec.js",
|
|
34
|
+
"edge-mpp.js",
|
|
30
35
|
"tempo.js",
|
|
31
36
|
"dashboard.js",
|
|
32
37
|
"sinks.js",
|
|
@@ -47,11 +52,15 @@
|
|
|
47
52
|
"express": "^5.0.0"
|
|
48
53
|
},
|
|
49
54
|
"peerDependencies": {
|
|
55
|
+
"@coinbase/x402": ">=2.0.0",
|
|
50
56
|
"@x402/core": ">=2.0.0",
|
|
51
57
|
"@x402/evm": ">=2.0.0",
|
|
52
58
|
"@x402/express": ">=2.0.0"
|
|
53
59
|
},
|
|
54
60
|
"peerDependenciesMeta": {
|
|
61
|
+
"@coinbase/x402": {
|
|
62
|
+
"optional": true
|
|
63
|
+
},
|
|
55
64
|
"@x402/core": {
|
|
56
65
|
"optional": true
|
|
57
66
|
},
|