agent402-tollbooth 0.9.1 → 0.9.3
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 +71 -18
- package/index.js +38 -6
- package/package.json +5 -1
- package/tempo.js +130 -0
package/README.md
CHANGED
|
@@ -10,17 +10,12 @@ 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,8 @@ 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, MPP and the native Tempo rail are Node/Express
|
|
160
|
+
> features today; the edge gate offers proof-of-work plus the `verifyX402` callback.
|
|
167
161
|
|
|
168
162
|
## Accepting USDC: x402 and MPP on the same 402
|
|
169
163
|
|
|
@@ -240,9 +234,21 @@ app.use(createTollbooth({
|
|
|
240
234
|
}));
|
|
241
235
|
```
|
|
242
236
|
|
|
237
|
+
The `tempo` object takes `apiKey` (required), `recipient` (required, your
|
|
238
|
+
Tempo payTo), `currency` or `currencies` (TIP-20 token addresses; default USDC.e
|
|
239
|
+
`0x20C000000000000000000000b9537d11c60E8b50`, one challenge minted per currency,
|
|
240
|
+
first = preferred), `splits` (up to 10, total below the price, same transaction),
|
|
241
|
+
`chainId` (default 4217, Tempo mainnet), `apiBaseUrl` (relay, default
|
|
242
|
+
`https://api.tempo.xyz`), `decimals` (6), `timeoutSeconds` (300), plus the
|
|
243
|
+
confirm knobs below. A bad config throws at construction - the gate never boots
|
|
244
|
+
minting unpayable challenges.
|
|
245
|
+
|
|
243
246
|
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"
|
|
247
|
+
(defaults to `TOLLBOOTH_PAYTO`), optional `TOLLBOOTH_TEMPO_CURRENCY` (CSV of
|
|
248
|
+
token addresses), `TOLLBOOTH_TEMPO_SPLITS="0xabc:0.0002,0xdef:0.0001"`,
|
|
249
|
+
`TOLLBOOTH_TEMPO_API_BASE` (relay override), `TOLLBOOTH_TEMPO_RPC_URL` (confirm
|
|
250
|
+
RPC override). Tempo works with NO x402 middleware at all (a Tempo-only
|
|
251
|
+
tollbooth) and next to one.
|
|
246
252
|
|
|
247
253
|
Every 402 then carries a `WWW-Authenticate: Payment` tempo/charge challenge
|
|
248
254
|
(next to any evm challenges from the x402 rail, if you run both); a stock
|
|
@@ -256,6 +262,23 @@ body. Challenges are HMAC-bound to your `TOLLBOOTH_SECRET`; each credential is
|
|
|
256
262
|
single-use (share a `replayStore` across workers). No new dependency: the
|
|
257
263
|
relay is spoken to over plain `fetch`.
|
|
258
264
|
|
|
265
|
+
**Chain-truth confirm (0.9.2):** a relay can report settlement failure for a
|
|
266
|
+
payment that actually landed - measured live: a buyer whose packed signature
|
|
267
|
+
ends with a yParity-style v byte (0x00/0x01) gets normalized by the Tempo
|
|
268
|
+
node, so the canonical txid stops matching the hash of the submitted bytes
|
|
269
|
+
and the relay's post-broadcast check refuses a settled payment; the buyer is
|
|
270
|
+
told 402 and retries into a double charge. On any broadcast failure the gate
|
|
271
|
+
now derives the only txids the credential's own signed bytes could have
|
|
272
|
+
landed under (submitted form + v-swapped twin - the txid commits to the
|
|
273
|
+
bytes, so the binding is exact) and, if one of them succeeded on-chain paying
|
|
274
|
+
your recipient at least the challenge amount in the challenge currency,
|
|
275
|
+
serves the response as paid. Verification only - nothing is re-broadcast, so
|
|
276
|
+
it can never double-charge; every uncertainty fails closed to the 402.
|
|
277
|
+
Options: `confirm: false` disables, `confirmRpcUrl` overrides the Tempo RPC
|
|
278
|
+
(default `https://rpc.tempo.xyz`, env `TOLLBOOTH_TEMPO_RPC_URL`). Still
|
|
279
|
+
dependency-free (keccak-256 is implemented in-package and pinned against the
|
|
280
|
+
live incident transaction in the tests).
|
|
281
|
+
|
|
259
282
|
## Configuration
|
|
260
283
|
|
|
261
284
|
| Option | Default | What |
|
|
@@ -276,6 +299,9 @@ relay is spoken to over plain `fetch`.
|
|
|
276
299
|
| `mpp` | `true` when `x402` set | Accept MPP (`WWW-Authenticate: Payment` / `Authorization: Payment`) through the `x402` middleware |
|
|
277
300
|
| `mppSecret` | `powSecret` / `TOLLBOOTH_SECRET` | HMAC secret binding MPP challenge ids (stable across workers) |
|
|
278
301
|
| `mppNetworks` | `[8453, 42220]` | EVM chain ids to offer as MPP challenges (`"all"` = every EVM chain the middleware advertises) |
|
|
302
|
+
| `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` |
|
|
303
|
+
| `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 |
|
|
304
|
+
| `tempo.confirmRpcUrl` | `https://rpc.tempo.xyz` (`TOLLBOOTH_TEMPO_RPC_URL`) | Tempo JSON-RPC used by the confirm step |
|
|
279
305
|
| `verifyX402(req, opts)` | – | Legacy verify-only USDC check (deprecated for settle-after-handler middlewares; see above) |
|
|
280
306
|
| `resourceBaseUrl` | `""` | Absolute base used for the `resource` field / PoW binding |
|
|
281
307
|
| `observe` | `false` | Observe-only: classify and count, but never 402. For pre-launch traffic measurement. |
|
|
@@ -292,11 +318,18 @@ Read by the bundled proxy / Express entry point (`index.js`):
|
|
|
292
318
|
| `TOLLBOOTH_PAYTO` | – | Wallet address; advertises a USDC x402 quote (and, with `TOLLBOOTH_FACILITATOR_URL`, settles it) |
|
|
293
319
|
| `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 |
|
|
294
320
|
| `TOLLBOOTH_FACILITATOR_HEADERS` | – | Optional JSON object of auth headers sent on the facilitator's `/verify`, `/settle`, `/supported` (e.g. `{"X-API-Key":"…"}`) |
|
|
321
|
+
| `TOLLBOOTH_CDP_API_KEY_ID` + `TOLLBOOTH_CDP_API_KEY_SECRET` | – | Settle through Coinbase's facilitator (CDP) instead of a URL: fee-free on Base, and the endpoint is indexed in Coinbase's x402 Bazaar. 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. |
|
|
295
322
|
| `TOLLBOOTH_PRICE` | `"$0.001"` | Advertised price per request |
|
|
296
323
|
| `TOLLBOOTH_NETWORK` | `"base"` | x402 network. CLI settlement mode accepts `base`, `base-sepolia`, `polygon`, `arbitrum`, `optimism`, `avalanche`, `celo`, `sei`, `monad`, or a raw `eip155:<id>` |
|
|
297
324
|
| `TOLLBOOTH_MPP` | on when `x402` set | `false` to switch MPP off |
|
|
298
325
|
| `TOLLBOOTH_MPP_SECRET` | `TOLLBOOTH_SECRET` | HMAC secret for MPP challenge ids |
|
|
299
326
|
| `TOLLBOOTH_MPP_NETWORKS` | `8453,42220` | CSV of EVM chain ids to offer as MPP challenges, or `all` |
|
|
327
|
+
| `TOLLBOOTH_TEMPO_API_KEY` | – | Tempo API key (`mpp:write` scope). Presence switches on the native Tempo rail (0.9.0) |
|
|
328
|
+
| `TOLLBOOTH_TEMPO_RECIPIENT` | `TOLLBOOTH_PAYTO` | Tempo payTo (0x address) |
|
|
329
|
+
| `TOLLBOOTH_TEMPO_CURRENCY` | USDC.e on Tempo | CSV of TIP-20 token addresses to offer (one challenge each, first = preferred) |
|
|
330
|
+
| `TOLLBOOTH_TEMPO_SPLITS` | – | `0xabc:0.0002,0xdef:0.0001` - split recipients + USD amounts paid in the same transaction (≤10, total below the price) |
|
|
331
|
+
| `TOLLBOOTH_TEMPO_API_BASE` | `https://api.tempo.xyz` | Tempo relay base URL override |
|
|
332
|
+
| `TOLLBOOTH_TEMPO_RPC_URL` | `https://rpc.tempo.xyz` | Tempo JSON-RPC for the chain-truth confirm step (0.9.2) |
|
|
300
333
|
| `TOLLBOOTH_ASSET` | `"USDC"` | Asset symbol in the quote (`USDG` charges in USDG on Robinhood Chain) |
|
|
301
334
|
| `TOLLBOOTH_POW_BITS` | `18` | Proof-of-work difficulty in leading zero bits |
|
|
302
335
|
| `TOLLBOOTH_MODE` | `"bots"` | Who pays: `bots` · `all` · `strict` |
|
|
@@ -364,7 +397,7 @@ changes. Bots see a `X-Tollbooth-Observed: would-charge` header in observe mode
|
|
|
364
397
|
## Analytics
|
|
365
398
|
|
|
366
399
|
The middleware keeps aggregate counters (no per-request data):
|
|
367
|
-
- `gate.stats()` → sync, in-process mirror: `{ requests, freeAllowed, wouldCharge, charged, powSolved, x402Paid, mppPaid, difficultyNow, observe }`.
|
|
400
|
+
- `gate.stats()` → sync, in-process mirror: `{ requests, freeAllowed, wouldCharge, charged, powSolved, x402Paid, mppPaid, tempoPaid, difficultyNow, observe }`.
|
|
368
401
|
- `gate.snapshot()` → async, reads from the configured durable sink (defaults to memory).
|
|
369
402
|
- `gate.flush()` → flush any buffered deltas to the durable sink (call inside `ctx.waitUntil` on edge runtimes).
|
|
370
403
|
|
|
@@ -568,3 +601,23 @@ Celo by default - what a stock mppx client can sign); add `4663` there or pass
|
|
|
568
601
|
## Legal
|
|
569
602
|
|
|
570
603
|
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.
|
|
604
|
+
|
|
605
|
+
## Get paid into a Coinbase Business account
|
|
606
|
+
|
|
607
|
+
Coinbase Business accounts receive x402 payments from AI agents directly. The
|
|
608
|
+
tollbooth is the "integrate x402 into your API" step: point `TOLLBOOTH_PAYTO`
|
|
609
|
+
at the account's USDC receive address on Base and settle through Coinbase's
|
|
610
|
+
facilitator with a CDP API key.
|
|
611
|
+
|
|
612
|
+
```bash
|
|
613
|
+
npm i agent402-tollbooth @x402/express @x402/core @x402/evm @coinbase/x402
|
|
614
|
+
TOLLBOOTH_PAYTO=0xYourCoinbaseBusinessBaseAddress \
|
|
615
|
+
TOLLBOOTH_CDP_API_KEY_ID=... TOLLBOOTH_CDP_API_KEY_SECRET=... \
|
|
616
|
+
TOLLBOOTH_PRICE='$0.005' TOLLBOOTH_UPSTREAM=http://localhost:8080 \
|
|
617
|
+
npx agent402-tollbooth
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
Every paid request settles as USDC into the Coinbase Business account; humans
|
|
621
|
+
browse your site normally. Full walkthrough with an Express example:
|
|
622
|
+
https://agent402.tools/guides/coinbase-business-get-paid-by-agents
|
|
623
|
+
|
package/index.js
CHANGED
|
@@ -19,7 +19,7 @@ import { createPow } from "./pow.js";
|
|
|
19
19
|
import { makeBotMatcher, AI_BOTS } from "./bots.js";
|
|
20
20
|
import { memorySink } from "./sinks.js";
|
|
21
21
|
import { challengesFromPaymentRequired, translateCredential, receiptFromPaymentResponse, isMppCredential, DEFAULT_MPP_CHAIN_IDS } from "./mpp.js";
|
|
22
|
-
import { tempoConfig, mintTempoChallenges, parseTempoCredential, checkTempoBinding, tempoRelay, relayInput, broadcastIdempotencyKey, tempoReceiptHeader, tempoProblem } from "./tempo.js";
|
|
22
|
+
import { tempoConfig, mintTempoChallenges, parseTempoCredential, checkTempoBinding, tempoRelay, relayInput, broadcastIdempotencyKey, tempoReceiptHeader, tempoProblem, confirmTempoSettlement } from "./tempo.js";
|
|
23
23
|
|
|
24
24
|
export { AI_BOTS, makeBotMatcher } from "./bots.js";
|
|
25
25
|
export { createPow, leadingZeroBits } from "./pow.js";
|
|
@@ -387,7 +387,22 @@ export function createTollbooth(config = {}) {
|
|
|
387
387
|
try { next(); } catch (err) { restore(); return next(err); }
|
|
388
388
|
await endPromise;
|
|
389
389
|
if (res.statusCode >= 400) { restore(); replay(); return; } // handler failed: never broadcast, nobody charged
|
|
390
|
-
|
|
390
|
+
let b = await tempoRelayClient.broadcast(input, { idempotencyKey: broadcastIdempotencyKey(input) }).catch((e) => ({ ok: false, error: String(e?.message || e) }));
|
|
391
|
+
if (!b.ok && tempoCfg.confirm) {
|
|
392
|
+
// The relay's verdict and the chain's truth can diverge (measured
|
|
393
|
+
// live 2026-08-20: a yParity-style v byte the node normalizes makes
|
|
394
|
+
// the relay's post-broadcast hash check fail a payment that SETTLED;
|
|
395
|
+
// the buyer retried into a double charge). Ask the chain whether this
|
|
396
|
+
// credential's own transaction landed before discarding the response —
|
|
397
|
+
// exact txid derivation from the signed bytes, verification never a
|
|
398
|
+
// re-broadcast, fails closed to the 402. See tempo.js.
|
|
399
|
+
const check = tempoCfg.confirmSettlement || ((c) => confirmTempoSettlement(c, { rpcUrl: tempoCfg.confirmRpcUrl, fetchImpl: tempoCfg.fetch }));
|
|
400
|
+
const confirmed = await Promise.resolve(check(cred)).catch(() => null);
|
|
401
|
+
if (confirmed?.txId) {
|
|
402
|
+
console.warn(`[agent402-tollbooth] tempo relay reported settlement failure but the credential's transaction SETTLED on-chain (${req.method} ${req.url} tx=${confirmed.txId}) - honouring the settlement (verified from the chain, nothing re-broadcast). Relay said: ${b.detail || b.error}`);
|
|
403
|
+
b = { ok: true, receipt: { method: "tempo", reference: confirmed.txId } };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
391
406
|
if (!b.ok) {
|
|
392
407
|
// Broadcast failed AFTER a successful handler: discard the body, 402
|
|
393
408
|
// (mirrors @x402/express's settle-failure path). Loud - this is the
|
|
@@ -671,9 +686,16 @@ export const CLI_NETWORKS = {
|
|
|
671
686
|
export async function buildCliX402Middleware(env = process.env) {
|
|
672
687
|
const payTo = env.TOLLBOOTH_PAYTO || "";
|
|
673
688
|
const facilitatorUrl = env.TOLLBOOTH_FACILITATOR_URL || "";
|
|
689
|
+
// Coinbase's facilitator (CDP) authenticates every verify/settle call with
|
|
690
|
+
// a short-lived JWT signed by a CDP API key, so a static header cannot
|
|
691
|
+
// reach it; @coinbase/x402's createFacilitatorConfig mints them. With the
|
|
692
|
+
// two keys set the CLI settles through CDP (fee-free on Base, and the
|
|
693
|
+
// endpoint is indexed in Coinbase's x402 Bazaar), no URL needed. This is
|
|
694
|
+
// the path a Coinbase Business account uses to get paid by agents.
|
|
695
|
+
const cdpKeys = env.TOLLBOOTH_CDP_API_KEY_ID && env.TOLLBOOTH_CDP_API_KEY_SECRET;
|
|
674
696
|
if (!payTo) return null;
|
|
675
|
-
if (!facilitatorUrl) {
|
|
676
|
-
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.");
|
|
697
|
+
if (!facilitatorUrl && !cdpKeys) {
|
|
698
|
+
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.");
|
|
677
699
|
return null;
|
|
678
700
|
}
|
|
679
701
|
const asset = (env.TOLLBOOTH_ASSET || "USDC").toUpperCase();
|
|
@@ -702,7 +724,17 @@ export async function buildCliX402Middleware(env = process.env) {
|
|
|
702
724
|
if (env.TOLLBOOTH_FACILITATOR_HEADERS) {
|
|
703
725
|
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); }
|
|
704
726
|
}
|
|
705
|
-
|
|
727
|
+
let cdpConfig = null;
|
|
728
|
+
if (cdpKeys) {
|
|
729
|
+
try {
|
|
730
|
+
const { createFacilitatorConfig } = await import("@coinbase/x402");
|
|
731
|
+
cdpConfig = createFacilitatorConfig(env.TOLLBOOTH_CDP_API_KEY_ID, env.TOLLBOOTH_CDP_API_KEY_SECRET);
|
|
732
|
+
} catch (e) {
|
|
733
|
+
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`);
|
|
734
|
+
process.exit(1);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const client = new HTTPFacilitatorClient(cdpConfig || {
|
|
706
738
|
url: facilitatorUrl,
|
|
707
739
|
...(authHeaders ? { createAuthHeaders: async () => ({ verify: authHeaders, settle: authHeaders, supported: authHeaders }) } : {}),
|
|
708
740
|
});
|
|
@@ -842,7 +874,7 @@ async function startCli() {
|
|
|
842
874
|
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." }));
|
|
843
875
|
}
|
|
844
876
|
app.listen(port, () => {
|
|
845
|
-
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)` : "");
|
|
877
|
+
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)` : "");
|
|
846
878
|
const rails = [gate.pow ? "proof-of-work" : "", paidLabel].filter(Boolean).join(" + ");
|
|
847
879
|
console.log(`agent402-tollbooth listening on :${port} — charging AI bots via ${rails || "proof-of-work"}`);
|
|
848
880
|
if (upstream) console.log(` proxying → ${upstream}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-tollbooth",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
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",
|
|
@@ -47,11 +47,15 @@
|
|
|
47
47
|
"express": "^5.0.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
+
"@coinbase/x402": ">=2.0.0",
|
|
50
51
|
"@x402/core": ">=2.0.0",
|
|
51
52
|
"@x402/evm": ">=2.0.0",
|
|
52
53
|
"@x402/express": ">=2.0.0"
|
|
53
54
|
},
|
|
54
55
|
"peerDependenciesMeta": {
|
|
56
|
+
"@coinbase/x402": {
|
|
57
|
+
"optional": true
|
|
58
|
+
},
|
|
55
59
|
"@x402/core": {
|
|
56
60
|
"optional": true
|
|
57
61
|
},
|
package/tempo.js
CHANGED
|
@@ -93,6 +93,12 @@ export function tempoConfig(cfg = {}) {
|
|
|
93
93
|
fetch: cfg.fetch || globalThis.fetch,
|
|
94
94
|
relay: cfg.relay || null, // { validate(input) -> {ok, error?}, broadcast(input, {idempotencyKey}) -> {ok, receipt?, error?} } (tests)
|
|
95
95
|
relayTimeoutMs: Number.isFinite(cfg.relayTimeoutMs) ? cfg.relayTimeoutMs : 20_000,
|
|
96
|
+
// Chain-truth confirm on broadcast failure (see confirmTempoSettlement):
|
|
97
|
+
// `confirm: false` disables; `confirmRpcUrl` overrides the Tempo RPC;
|
|
98
|
+
// `confirmSettlement` injects the whole check (tests).
|
|
99
|
+
confirm: cfg.confirm !== false,
|
|
100
|
+
confirmRpcUrl: String(cfg.confirmRpcUrl || process.env.TOLLBOOTH_TEMPO_RPC_URL || "https://rpc.tempo.xyz").replace(/\/$/, ""),
|
|
101
|
+
confirmSettlement: typeof cfg.confirmSettlement === "function" ? cfg.confirmSettlement : null,
|
|
96
102
|
};
|
|
97
103
|
}
|
|
98
104
|
|
|
@@ -240,3 +246,127 @@ export function tempoProblem(kind, detail) {
|
|
|
240
246
|
const titles = { "invalid-challenge": "Invalid Challenge", "verification-failed": "Verification Failed", "malformed-credential": "Malformed Credential", "payment-insufficient": "Payment Insufficient" };
|
|
241
247
|
return { type: `https://paymentauth.org/problems/${kind}`, title: titles[kind] || kind, status: 402, detail };
|
|
242
248
|
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Chain-truth confirm on broadcast failure (2026-08-20) — the relay's verdict
|
|
252
|
+
// and the chain's truth can diverge: a buyer whose packed signature ends with
|
|
253
|
+
// a yParity-style v byte (0x00/0x01) gets normalized to 27/28 by the Tempo
|
|
254
|
+
// node, so the canonical txid stops matching keccak(submitted bytes) and the
|
|
255
|
+
// relay's post-broadcast hash check reports `invalid_payment` for a payment
|
|
256
|
+
// that SETTLED (measured live: two double charges in one buy attempt).
|
|
257
|
+
// Before answering 402, ask the chain whether this credential's OWN
|
|
258
|
+
// transaction landed. The txid commits to the signed bytes, so the candidates
|
|
259
|
+
// are exact — the submitted form and its v-swapped twin — never a time-window
|
|
260
|
+
// or payer heuristic. Fails closed on every uncertainty. Dependency-free like
|
|
261
|
+
// the rest of this file: keccak-256 is implemented below (BigInt lanes — it
|
|
262
|
+
// runs at most once per FAILED broadcast, so speed is irrelevant) and pinned
|
|
263
|
+
// against standard vectors plus the live incident transaction in the tests.
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
const KECCAK_RC = [
|
|
267
|
+
"0x0000000000000001", "0x0000000000008082", "0x800000000000808a", "0x8000000080008000",
|
|
268
|
+
"0x000000000000808b", "0x0000000080000001", "0x8000000080008081", "0x8000000000008009",
|
|
269
|
+
"0x000000000000008a", "0x0000000000000088", "0x0000000080008009", "0x000000008000000a",
|
|
270
|
+
"0x000000008000808b", "0x800000000000008b", "0x8000000000008089", "0x8000000000008003",
|
|
271
|
+
"0x8000000000008002", "0x8000000000000080", "0x000000000000800a", "0x800000008000000a",
|
|
272
|
+
"0x8000000080008081", "0x8000000000008080", "0x0000000080000001", "0x8000000080008008",
|
|
273
|
+
].map(BigInt);
|
|
274
|
+
const KECCAK_RHO = [0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39, 41, 45, 15, 21, 8, 18, 2, 61, 56, 14];
|
|
275
|
+
const U64 = (1n << 64n) - 1n;
|
|
276
|
+
|
|
277
|
+
function keccakF(A) {
|
|
278
|
+
const rotl = (v, n) => n === 0n ? v : (((v << n) & U64) | (v >> (64n - n)));
|
|
279
|
+
for (let round = 0; round < 24; round++) {
|
|
280
|
+
const C = [0, 1, 2, 3, 4].map((x) => A[x] ^ A[x + 5] ^ A[x + 10] ^ A[x + 15] ^ A[x + 20]);
|
|
281
|
+
for (let x = 0; x < 5; x++) {
|
|
282
|
+
const D = C[(x + 4) % 5] ^ rotl(C[(x + 1) % 5], 1n);
|
|
283
|
+
for (let y = 0; y < 5; y++) A[x + 5 * y] ^= D;
|
|
284
|
+
}
|
|
285
|
+
const B = new Array(25);
|
|
286
|
+
for (let x = 0; x < 5; x++) for (let y = 0; y < 5; y++) B[y + 5 * ((2 * x + 3 * y) % 5)] = rotl(A[x + 5 * y], BigInt(KECCAK_RHO[x + 5 * y]));
|
|
287
|
+
for (let x = 0; x < 5; x++) for (let y = 0; y < 5; y++) A[x + 5 * y] = B[x + 5 * y] ^ ((~B[((x + 1) % 5) + 5 * y] & U64) & B[((x + 2) % 5) + 5 * y]);
|
|
288
|
+
A[0] ^= KECCAK_RC[round];
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** keccak-256 of a Buffer, as a 0x hex string. Original Keccak padding
|
|
293
|
+
* (0x01 … 0x80), NOT SHA-3's 0x06 — Ethereum txids use the former. */
|
|
294
|
+
export function keccak256Hex(buf) {
|
|
295
|
+
const rate = 136;
|
|
296
|
+
const padded = Buffer.alloc(Math.ceil((buf.length + 1) / rate) * rate);
|
|
297
|
+
buf.copy(padded);
|
|
298
|
+
padded[buf.length] = 0x01;
|
|
299
|
+
padded[padded.length - 1] |= 0x80;
|
|
300
|
+
const A = new Array(25).fill(0n);
|
|
301
|
+
for (let off = 0; off < padded.length; off += rate) {
|
|
302
|
+
for (let i = 0; i < rate / 8; i++) A[i] ^= padded.readBigUInt64LE(off + i * 8);
|
|
303
|
+
keccakF(A);
|
|
304
|
+
}
|
|
305
|
+
const out = Buffer.alloc(32);
|
|
306
|
+
for (let i = 0; i < 4; i++) out.writeBigUInt64LE(A[i], i * 8);
|
|
307
|
+
return `0x${out.toString("hex")}`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The txids this signed Tempo transaction could have landed under: the
|
|
311
|
+
* submitted bytes, plus (when the envelope verifiably ends with the 65-byte
|
|
312
|
+
* packed signature — RLP string header 0xb841 at the right offset) the
|
|
313
|
+
* v-swapped twin: yParity (0/1) <-> legacy (27/28). */
|
|
314
|
+
export function candidateTxIds(signedTx) {
|
|
315
|
+
const hex = String(signedTx || "").toLowerCase();
|
|
316
|
+
if (!/^0x76[0-9a-f]{2,}$/.test(hex) || hex.length % 2 !== 0) return [];
|
|
317
|
+
const out = [keccak256Hex(Buffer.from(hex.slice(2), "hex"))];
|
|
318
|
+
// b841 = RLP header for a 65-byte string; 134 hex chars = header + 65 bytes.
|
|
319
|
+
const sigIsLast = hex.length >= 138 && hex.slice(-134, -130) === "b841";
|
|
320
|
+
if (!sigIsLast) return out;
|
|
321
|
+
const swap = { "00": "1b", "01": "1c", "1b": "00", "1c": "01" }[hex.slice(-2)];
|
|
322
|
+
if (swap) out.push(keccak256Hex(Buffer.from(hex.slice(2, -2) + swap, "hex")));
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
|
327
|
+
|
|
328
|
+
/** Did this credential's transaction settle despite the relay's verdict?
|
|
329
|
+
* Returns { txId } when a candidate receipt exists, succeeded (status 0x1),
|
|
330
|
+
* and carries the challenge's transfer (currency + recipient + >= amount) —
|
|
331
|
+
* else null. Polls briefly (a just-mined tx may not be indexed when the
|
|
332
|
+
* relay answers). Never throws. */
|
|
333
|
+
export async function confirmTempoSettlement(credential, { rpcUrl, fetchImpl = globalThis.fetch, attempts = 4, delayMs = 2000 } = {}) {
|
|
334
|
+
try {
|
|
335
|
+
const payload = credential?.payload;
|
|
336
|
+
if (payload?.type !== "transaction" || typeof payload.signature !== "string") return null;
|
|
337
|
+
let request = credential?.challenge?.request;
|
|
338
|
+
if (typeof request === "string") { try { request = JSON.parse(unb64url(request)); } catch { return null; } }
|
|
339
|
+
const currency = String(request?.currency || "").toLowerCase();
|
|
340
|
+
const recipient = String(request?.recipient || "").toLowerCase();
|
|
341
|
+
let minAmount;
|
|
342
|
+
try { minAmount = BigInt(String(request?.amount)); } catch { return null; }
|
|
343
|
+
if (!currency.startsWith("0x") || !recipient.startsWith("0x") || !(minAmount > 0n)) return null;
|
|
344
|
+
const candidates = candidateTxIds(payload.signature);
|
|
345
|
+
if (!candidates.length) return null;
|
|
346
|
+
for (let i = 0; i < attempts; i++) {
|
|
347
|
+
if (i > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
348
|
+
for (const txId of candidates) {
|
|
349
|
+
let receipt;
|
|
350
|
+
try {
|
|
351
|
+
const res = await fetchImpl(rpcUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getTransactionReceipt", params: [txId] }) });
|
|
352
|
+
if (!res.ok) continue;
|
|
353
|
+
const body = await res.json();
|
|
354
|
+
if (body.error) continue;
|
|
355
|
+
receipt = body.result;
|
|
356
|
+
} catch { continue; }
|
|
357
|
+
if (!receipt || receipt.status !== "0x1") continue;
|
|
358
|
+
for (const log of receipt.logs || []) {
|
|
359
|
+
if (String(log.address || "").toLowerCase() !== currency) continue;
|
|
360
|
+
if ((log.topics || [])[0] !== TRANSFER_TOPIC) continue;
|
|
361
|
+
if (`0x${String(log.topics[2] || "").slice(-40)}`.toLowerCase() !== recipient) continue;
|
|
362
|
+
let value;
|
|
363
|
+
try { value = BigInt(log.data); } catch { continue; }
|
|
364
|
+
if (value >= minAmount) return { txId };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
} catch {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|