agent402-tollbooth 0.9.1 → 0.9.2

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.
Files changed (4) hide show
  1. package/README.md +17 -0
  2. package/index.js +17 -2
  3. package/package.json +1 -1
  4. package/tempo.js +130 -0
package/README.md CHANGED
@@ -256,6 +256,23 @@ body. Challenges are HMAC-bound to your `TOLLBOOTH_SECRET`; each credential is
256
256
  single-use (share a `replayStore` across workers). No new dependency: the
257
257
  relay is spoken to over plain `fetch`.
258
258
 
259
+ **Chain-truth confirm (0.9.2):** a relay can report settlement failure for a
260
+ payment that actually landed - measured live: a buyer whose packed signature
261
+ ends with a yParity-style v byte (0x00/0x01) gets normalized by the Tempo
262
+ node, so the canonical txid stops matching the hash of the submitted bytes
263
+ and the relay's post-broadcast check refuses a settled payment; the buyer is
264
+ told 402 and retries into a double charge. On any broadcast failure the gate
265
+ now derives the only txids the credential's own signed bytes could have
266
+ landed under (submitted form + v-swapped twin - the txid commits to the
267
+ bytes, so the binding is exact) and, if one of them succeeded on-chain paying
268
+ your recipient at least the challenge amount in the challenge currency,
269
+ serves the response as paid. Verification only - nothing is re-broadcast, so
270
+ it can never double-charge; every uncertainty fails closed to the 402.
271
+ Options: `confirm: false` disables, `confirmRpcUrl` overrides the Tempo RPC
272
+ (default `https://rpc.tempo.xyz`, env `TOLLBOOTH_TEMPO_RPC_URL`). Still
273
+ dependency-free (keccak-256 is implemented in-package and pinned against the
274
+ live incident transaction in the tests).
275
+
259
276
  ## Configuration
260
277
 
261
278
  | Option | Default | What |
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
- const b = await tempoRelayClient.broadcast(input, { idempotencyKey: broadcastIdempotencyKey(input) }).catch((e) => ({ ok: false, error: String(e?.message || e) }));
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-tollbooth",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
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",
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
+ }