agent402-tollbooth 0.8.2 → 0.9.1

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 +34 -0
  2. package/index.js +125 -0
  3. package/package.json +2 -1
  4. package/tempo.js +242 -0
package/README.md CHANGED
@@ -222,6 +222,40 @@ for custom verifiers you wrote yourself, and now warns once at construction;
222
222
  everyone else should pass the middleware as `x402:` above. `verifyX402` still
223
223
  receives `opts.signal` (aborted on `TOLLBOOTH_VERIFY_TIMEOUT_MS`).
224
224
 
225
+ ## Native MPP on Tempo (0.9.0)
226
+
227
+ Tollbooth can charge crawlers in USDC.e **natively on Tempo** - MPP's own
228
+ payment method - with no x402 facilitator at all, and (optionally) split every
229
+ payment with a platform fee in the same on-chain transaction:
230
+
231
+ ```js
232
+ app.use(createTollbooth({
233
+ price: "$0.001",
234
+ tempo: {
235
+ apiKey: process.env.TEMPO_API_KEY, // Tempo API key with the mpp:write scope
236
+ recipient: "0xYourTempoPayTo",
237
+ // currency: defaults to USDC.e on Tempo mainnet; currencies: [...] to offer more than one
238
+ splits: [{ recipient: "0xPlatform", amount: "0.0002" }], // optional, up to 10, total < price
239
+ },
240
+ }));
241
+ ```
242
+
243
+ 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"`.
246
+
247
+ Every 402 then carries a `WWW-Authenticate: Payment` tempo/charge challenge
248
+ (next to any evm challenges from the x402 rail, if you run both); a stock
249
+ `mppx` client with `tempo.charge({ account })` pays it. The gate validates the
250
+ credential with Tempo's relay BEFORE your handler runs, buffers the response,
251
+ and broadcasts ONLY after a successful (<400) response - the same
252
+ settle-after-handler discipline as the x402 rail - then replays the response
253
+ with a `Payment-Receipt` header and `X-Tollbooth-Paid: mpp-tempo`. A refused
254
+ credential gets a 402 with fresh challenges and an RFC 9457 `problem` in the
255
+ body. Challenges are HMAC-bound to your `TOLLBOOTH_SECRET`; each credential is
256
+ single-use (share a `replayStore` across workers). No new dependency: the
257
+ relay is spoken to over plain `fetch`.
258
+
225
259
  ## Configuration
226
260
 
227
261
  | Option | Default | What |
package/index.js CHANGED
@@ -19,12 +19,14 @@ 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
23
 
23
24
  export { AI_BOTS, makeBotMatcher } from "./bots.js";
24
25
  export { createPow, leadingZeroBits } from "./pow.js";
25
26
  export { memorySink, kvStatsSink, httpStatsSink } from "./sinks.js";
26
27
  export { sqliteReplayStore, redisReplayStore } from "./replay.js";
27
28
  export { challengesFromPaymentRequired, translateCredential, receiptFromPaymentResponse, DEFAULT_MPP_CHAIN_IDS } from "./mpp.js";
29
+ export { tempoConfig, mintTempoChallenges, parseTempoCredential, checkTempoBinding, tempoRelay, toBaseUnits, TEMPO_USDC_E, TEMPO_PATHUSD, TEMPO_MAINNET_CHAIN_ID } from "./tempo.js";
28
30
 
29
31
  const VERIFY_TIMEOUT_MS = Number(process.env.TOLLBOOTH_VERIFY_TIMEOUT_MS) || 10_000;
30
32
 
@@ -127,6 +129,24 @@ const STRIP_INBOUND = new Set([
127
129
  * @param {boolean} [config.observe] observe-only: classify, count, never 402 (deploy a week before enforcing)
128
130
  * @param {object} [config.statsSink] pluggable durable-stats sink (default: in-memory). See sinks.js.
129
131
  */
132
+ /** Tempo config from env (null when TOLLBOOTH_TEMPO_API_KEY is unset). */
133
+ export function tempoFromEnv(env = process.env) {
134
+ const apiKey = (env.TOLLBOOTH_TEMPO_API_KEY || "").trim();
135
+ if (!apiKey) return null;
136
+ const recipient = (env.TOLLBOOTH_TEMPO_RECIPIENT || env.TOLLBOOTH_PAYTO || "").trim();
137
+ const currencies = (env.TOLLBOOTH_TEMPO_CURRENCY || "").split(",").map((s) => s.trim()).filter(Boolean);
138
+ const splits = (env.TOLLBOOTH_TEMPO_SPLITS || "").split(",").map((s) => s.trim()).filter(Boolean).map((pair) => {
139
+ const [recipient, amount] = pair.split(":").map((x) => x.trim());
140
+ return { recipient, amount };
141
+ });
142
+ return {
143
+ apiKey, recipient,
144
+ ...(currencies.length ? { currencies } : {}),
145
+ ...(splits.length ? { splits } : {}),
146
+ ...(env.TOLLBOOTH_TEMPO_API_BASE ? { apiBaseUrl: env.TOLLBOOTH_TEMPO_API_BASE } : {}),
147
+ };
148
+ }
149
+
130
150
  export function createTollbooth(config = {}) {
131
151
  const {
132
152
  price = process.env.TOLLBOOTH_PRICE || "$0.001",
@@ -162,6 +182,13 @@ export function createTollbooth(config = {}) {
162
182
  mppNetworks = process.env.TOLLBOOTH_MPP_NETWORKS
163
183
  ? (process.env.TOLLBOOTH_MPP_NETWORKS.trim().toLowerCase() === "all" ? "all" : process.env.TOLLBOOTH_MPP_NETWORKS.split(",").map((s) => Number(s.trim())).filter(Number.isInteger))
164
184
  : DEFAULT_MPP_CHAIN_IDS,
185
+ // Native MPP on Tempo (0.9.0): { apiKey, recipient, currency|currencies,
186
+ // splits: [{recipient, amount}], decimals, chainId, apiBaseUrl }. Settles
187
+ // through Tempo's relay, independent of `x402` - a tollbooth can charge in
188
+ // USDC.e on Tempo with no x402 facilitator at all. From env:
189
+ // TOLLBOOTH_TEMPO_API_KEY + TOLLBOOTH_TEMPO_RECIPIENT (+ TOLLBOOTH_TEMPO_CURRENCY,
190
+ // TOLLBOOTH_TEMPO_SPLITS="0xabc:0.0002,0xdef:0.0001").
191
+ tempo = config.tempo !== undefined ? config.tempo : tempoFromEnv(process.env),
165
192
  resourceBaseUrl = process.env.TOLLBOOTH_RESOURCE_BASE || "",
166
193
  // Adaptive proof-of-work: raise difficulty as charged-request load climbs, so
167
194
  // high-volume abuse pays escalating CPU regardless of how it disguises itself.
@@ -193,6 +220,25 @@ export function createTollbooth(config = {}) {
193
220
  if (mppOn && !(mppSecret || powSecret || process.env.TOLLBOOTH_MPP_SECRET || process.env.TOLLBOOTH_SECRET)) {
194
221
  console.warn("[agent402-tollbooth] MPP enabled with a per-process random secret - set TOLLBOOTH_SECRET (or mppSecret) for multi-worker deploys.");
195
222
  }
223
+ const tempoCfg = tempo ? tempoConfig(tempo) : null; // throws on a bad config - never boot a gate that mints unpayable challenges
224
+ const tempoKey = tempoCfg
225
+ ? (mppKey || String(mppSecret || powSecret || process.env.TOLLBOOTH_MPP_SECRET || process.env.TOLLBOOTH_SECRET || randomBytes(32).toString("hex")))
226
+ : null;
227
+ if (tempoCfg && !mppKey && !(mppSecret || powSecret || process.env.TOLLBOOTH_MPP_SECRET || process.env.TOLLBOOTH_SECRET)) {
228
+ console.warn("[agent402-tollbooth] Tempo enabled with a per-process random secret - set TOLLBOOTH_SECRET (or mppSecret) for multi-worker deploys.");
229
+ }
230
+ const tempoRelayClient = tempoCfg ? tempoRelay(tempoCfg) : null;
231
+ // Single-use record for tempo credentials (challenge id), sharing the
232
+ // operator's replayStore when given (same atomic `claim` contract as PoW).
233
+ const tempoSeen = new Map(); // id -> expiresAt (in-process fallback)
234
+ const tempoClaim = async (id, expiresAtMs) => {
235
+ if (replayStore && typeof replayStore.claim === "function") return replayStore.claim(`tempo:${id}`, expiresAtMs);
236
+ const now = Date.now();
237
+ for (const [k, exp] of tempoSeen) if (exp <= now) tempoSeen.delete(k);
238
+ if (tempoSeen.has(id)) return false;
239
+ tempoSeen.set(id, expiresAtMs);
240
+ return true;
241
+ };
196
242
 
197
243
  // Passive analytics — never affects request handling, just counts what happens.
198
244
  // `mem` is an always-on in-process mirror so `.stats()` stays synchronous for
@@ -279,10 +325,85 @@ export function createTollbooth(config = {}) {
279
325
  ...extra,
280
326
  };
281
327
  if (powEngine) body.proofOfWork = powEngine.challenge(powResource, difficultyNow());
328
+ // Native Tempo challenge(s) on every 402, next to whatever evm challenges
329
+ // the x402 rail lifted - a stock mppx client pays whichever it speaks.
330
+ if (tempoCfg) {
331
+ const tempoWww = mintTempoChallenges({ price, realm: mppRealm(req), secretKey: tempoKey, tempo: tempoCfg });
332
+ if (tempoWww) headers = { ...headers, "WWW-Authenticate": [headers["WWW-Authenticate"], tempoWww].filter(Boolean).join(", ") };
333
+ }
282
334
  for (const [k, v] of Object.entries(headers)) { if (v != null && v !== "") res.setHeader(k, v); }
283
335
  res.status(402).json(body);
284
336
  };
285
337
 
338
+ // Native Tempo rail: validate with the relay BEFORE the handler, buffer
339
+ // the handler's response, broadcast ONLY after a <400 response (the same
340
+ // settle-after-handler discipline @x402/express enforces), then replay
341
+ // the buffered response with a Payment-Receipt. A refused credential gets
342
+ // the gate's 402 with fresh challenges plus an RFC 9457 `problem`.
343
+ const tempoRail = async (cred) => {
344
+ const reject = (kind, detail) => { res.setHeader("X-Tollbooth-Error", `tempo-${kind}`); return send402({ problem: tempoProblem(kind, detail) }); };
345
+ const binding = checkTempoBinding(cred, { secretKey: tempoKey, realm: mppRealm(req), price, tempo: tempoCfg });
346
+ if (!binding.ok) return reject(/below this route's price/.test(binding.reason) ? "payment-insufficient" : "invalid-challenge", `Challenge is invalid: ${binding.reason}. Request the resource again for a fresh challenge.`);
347
+ let claimed;
348
+ try { claimed = await tempoClaim(cred.challenge.id, Date.parse(cred.challenge.expires) || Date.now() + 300_000); } catch { claimed = false; }
349
+ if (!claimed) return reject("invalid-challenge", "Challenge is invalid: this credential was already used or is in flight. Request the resource again for a fresh challenge.");
350
+ const input = relayInput(cred);
351
+ const v = await tempoRelayClient.validate(input).catch((e) => ({ ok: false, error: String(e?.message || e) }));
352
+ if (!v.ok) {
353
+ // Operator log gets the relay's full message (`detail`); the buyer's
354
+ // 402 problem gets status + code only (`error`) - never an upstream body.
355
+ console.warn(`[agent402-tollbooth] tempo credential rejected by validate (${req.method} ${req.url}): ${v.detail || v.error}`);
356
+ return reject("verification-failed", `Payment verification failed: ${String(v.error || "the Tempo relay rejected the credential").slice(0, 160)}.`);
357
+ }
358
+ // Buffer the handler's response (writeHead/write/end/flushHeaders) -
359
+ // Node's real 'finish' never fires while end is buffered, so the sync
360
+ // primitive is a promise resolved inside the buffered end.
361
+ const originalWriteHead = res.writeHead.bind(res);
362
+ const originalWrite = res.write.bind(res);
363
+ const originalEnd = res.end.bind(res);
364
+ const originalFlushHeaders = typeof res.flushHeaders === "function" ? res.flushHeaders.bind(res) : null;
365
+ let buffered = [];
366
+ let settled = false;
367
+ let endCalled;
368
+ const endPromise = new Promise((resolve) => { endCalled = resolve; });
369
+ const restore = () => {
370
+ settled = true;
371
+ res.writeHead = originalWriteHead; res.write = originalWrite; res.end = originalEnd;
372
+ if (originalFlushHeaders) res.flushHeaders = originalFlushHeaders;
373
+ };
374
+ res.writeHead = (...a) => { if (!settled) { buffered.push(["writeHead", a]); return res; } return originalWriteHead(...a); };
375
+ res.write = (...a) => { if (!settled) { buffered.push(["write", a]); return true; } return originalWrite(...a); };
376
+ res.end = (...a) => { if (!settled) { buffered.push(["end", a]); endCalled(); return res; } return originalEnd(...a); };
377
+ if (originalFlushHeaders) res.flushHeaders = () => { if (!settled) { buffered.push(["flushHeaders", []]); return; } return originalFlushHeaders(); };
378
+ const replay = () => {
379
+ for (const [fn, a] of buffered) {
380
+ if (fn === "writeHead") originalWriteHead(...a);
381
+ else if (fn === "write") originalWrite(...a);
382
+ else if (fn === "flushHeaders") { if (originalFlushHeaders) originalFlushHeaders(); }
383
+ else originalEnd(...a);
384
+ }
385
+ buffered = [];
386
+ };
387
+ try { next(); } catch (err) { restore(); return next(err); }
388
+ await endPromise;
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) }));
391
+ if (!b.ok) {
392
+ // Broadcast failed AFTER a successful handler: discard the body, 402
393
+ // (mirrors @x402/express's settle-failure path). Loud - this is the
394
+ // one path that can be our latency rather than the relay's verdict.
395
+ console.warn(`[agent402-tollbooth] tempo broadcast failed after a successful handler (${req.method} ${req.url}) - answered 402, not charged: ${b.detail || b.error}`);
396
+ buffered = [];
397
+ restore();
398
+ return reject("verification-failed", `Payment verification failed: Tempo settlement was not accepted (${String(b.error || "no relay detail").slice(0, 160)}).`);
399
+ }
400
+ incr("tempoPaid");
401
+ restore();
402
+ res.setHeader("Payment-Receipt", tempoReceiptHeader(b.receipt));
403
+ res.setHeader("X-Tollbooth-Paid", "mpp-tempo");
404
+ replay();
405
+ };
406
+
286
407
  // Paid rail, middleware mode: delegate to the operator's x402 middleware
287
408
  // with the REAL response so verify -> handler -> settle run in ITS order,
288
409
  // exactly once. Its 402 (no/invalid payment) is intercepted and replaced by
@@ -403,6 +524,10 @@ export function createTollbooth(config = {}) {
403
524
  // either directly or after the free rail declined, so it lives in a function
404
525
  // the (possibly async) proof-of-work branch can hand control back to.
405
526
  const paidRail = () => {
527
+ if (tempoCfg) {
528
+ const cred = parseTempoCredential(req.headers.authorization);
529
+ if (cred) return tempoRail(cred);
530
+ }
406
531
  if (x402) return middlewareRail();
407
532
  const payHeader = req.headers["x-payment"] || req.headers["payment-signature"];
408
533
  if (payTo && typeof verifyX402 === "function" && payHeader) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-tollbooth",
3
- "version": "0.8.2",
3
+ "version": "0.9.1",
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",
@@ -27,6 +27,7 @@
27
27
  "bots.js",
28
28
  "pow.js",
29
29
  "mpp.js",
30
+ "tempo.js",
30
31
  "dashboard.js",
31
32
  "sinks.js",
32
33
  "replay.js",
package/tempo.js ADDED
@@ -0,0 +1,242 @@
1
+ // Native MPP on Tempo for the tollbooth - dependency-free (0.9.0).
2
+ //
3
+ // MPP's own native payment method is `tempo/charge`: a TIP-20 stablecoin
4
+ // transfer on Tempo (chain 4217), settled through Tempo's hosted relay
5
+ // (`POST /v1/mpp/validate` before the handler, `POST /v1/mpp/broadcast` only
6
+ // after a successful response). It is NOT EIP-3009, so it cannot ride an x402
7
+ // facilitator the way mpp.js translates the `evm` method - it needs its own
8
+ // gate, and that gate needs a Tempo API key with the `mpp:write` scope.
9
+ //
10
+ // Same posture as mpp.js: no mppx dependency. The challenge is minted with
11
+ // the same HMAC id binding mpp.js uses (so a stock mppx client pays it and
12
+ // mppx's Challenge.verify agrees on the id given the same secret), the
13
+ // request shape is byte-for-byte what mppx's tempo/charge schema emits (base
14
+ // units `amount` string, NO `decimals` on the wire, `methodDetails.chainId`,
15
+ // optional `methodDetails.splits` in base units - every one of those drifts
16
+ // bit the main app live in 2026-08), and the relay is spoken to over plain
17
+ // fetch with the wire mppx's own Relay.js uses ({challenge, payload}, header
18
+ // `tempo-api-key`, `idempotency-key` on broadcast, `{success:true}` /
19
+ // `{success:true, receipt}` on the way back).
20
+ //
21
+ // Split payments (`splits`): Tempo settles one transfer to up to 10 extra
22
+ // recipients atomically with the main one - a platform fee in the same
23
+ // transaction, which x402 cannot express. Each split amount must be > 0 and
24
+ // the splits must total strictly LESS than the price (the remainder is the
25
+ // operator's); validated at config time so a bad split never mints a
26
+ // challenge nobody can pay.
27
+ import { createHmac, createHash, timingSafeEqual } from "node:crypto";
28
+
29
+ const SCHEME = "Payment";
30
+ export const TEMPO_MAINNET_CHAIN_ID = 4217;
31
+ /** USDC.e on Tempo mainnet (138/141 registry sellers + mppx's mainnet default). */
32
+ export const TEMPO_USDC_E = "0x20C000000000000000000000b9537d11c60E8b50";
33
+ /** PathUSD - mppx's TESTNET default; accepted if the operator lists it. */
34
+ export const TEMPO_PATHUSD = "0x20c0000000000000000000000000000000000000";
35
+ const DEFAULT_API_BASE = "https://api.tempo.xyz";
36
+ const HEX_ADDR = /^0x[0-9a-fA-F]{40}$/;
37
+ const HEX = /^0x[0-9a-fA-F]+$/;
38
+
39
+ export const b64url = (s) => Buffer.from(s, "utf8").toString("base64url");
40
+ export const unb64url = (s) => Buffer.from(String(s), "base64url").toString("utf8");
41
+ export function canonicalJson(value) {
42
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
43
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
44
+ const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
45
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
46
+ }
47
+ function challengeId(secretKey, c) {
48
+ const input = [c.realm, c.method, c.intent, c.request, c.expires ?? "", c.digest ?? "", c.opaque ?? ""].join("|");
49
+ return createHmac("sha256", Buffer.from(secretKey, "utf8")).update(input, "utf8").digest("base64url");
50
+ }
51
+ function idMatches(secretKey, c) {
52
+ const a = Buffer.from(String(c.id || ""), "utf8");
53
+ const b = Buffer.from(challengeId(secretKey, c), "utf8");
54
+ return a.length === b.length && timingSafeEqual(a, b);
55
+ }
56
+ const authParam = (name, value) => {
57
+ const v = String(value);
58
+ if (/[\r\n]/.test(v)) throw new Error("invalid auth-param value");
59
+ return `${name}="${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
60
+ };
61
+
62
+ /** "$0.001" | 0.001 | "0.001" -> base-units BigInt at `decimals`. */
63
+ export function toBaseUnits(price, decimals = 6) {
64
+ const s = String(price).trim().replace(/^\$/, "");
65
+ if (!/^\d+(\.\d+)?$/.test(s)) throw new Error(`tempo: price "${price}" is not a decimal amount`);
66
+ const [int, frac = ""] = s.split(".");
67
+ if (frac.length > decimals) throw new Error(`tempo: price "${price}" has more than ${decimals} decimals`);
68
+ return BigInt(int + frac.padEnd(decimals, "0"));
69
+ }
70
+
71
+ /** Normalise + validate the operator's tempo config once, at createTollbooth time. */
72
+ export function tempoConfig(cfg = {}) {
73
+ const apiKey = String(cfg.apiKey || "").trim();
74
+ const recipient = String(cfg.recipient || "").trim();
75
+ if (!apiKey) throw new Error("tollbooth tempo: `apiKey` (Tempo API key with the mpp:write scope) is required");
76
+ if (!HEX_ADDR.test(recipient)) throw new Error("tollbooth tempo: `recipient` must be a 0x EVM address (your Tempo payTo)");
77
+ const decimals = Number.isInteger(cfg.decimals) ? cfg.decimals : 6;
78
+ const currencies = (Array.isArray(cfg.currencies) ? cfg.currencies : [cfg.currency || TEMPO_USDC_E]).map((c) => String(c).trim()).filter(Boolean);
79
+ if (!currencies.length || !currencies.every((c) => HEX_ADDR.test(c))) throw new Error("tollbooth tempo: `currency`/`currencies` must be TIP-20 token addresses (default USDC.e)");
80
+ const chainId = Number.isInteger(cfg.chainId) ? cfg.chainId : TEMPO_MAINNET_CHAIN_ID;
81
+ const timeoutSeconds = Number.isFinite(cfg.timeoutSeconds) && cfg.timeoutSeconds > 0 ? cfg.timeoutSeconds : 300;
82
+ const splits = Array.isArray(cfg.splits) ? cfg.splits.map((s, i) => {
83
+ if (!s || !HEX_ADDR.test(String(s.recipient || ""))) throw new Error(`tollbooth tempo: splits[${i}].recipient must be a 0x address`);
84
+ const amount = toBaseUnits(s.amount, decimals);
85
+ if (amount <= 0n) throw new Error(`tollbooth tempo: splits[${i}].amount must be > 0`);
86
+ return { recipient: s.recipient, amount };
87
+ }) : [];
88
+ if (splits.length > 10) throw new Error("tollbooth tempo: at most 10 splits");
89
+ return {
90
+ apiKey, recipient, decimals, currencies, chainId, timeoutSeconds, splits,
91
+ apiBaseUrl: String(cfg.apiBaseUrl || DEFAULT_API_BASE).replace(/\/$/, ""),
92
+ description: cfg.description ? String(cfg.description).slice(0, 200) : undefined,
93
+ fetch: cfg.fetch || globalThis.fetch,
94
+ relay: cfg.relay || null, // { validate(input) -> {ok, error?}, broadcast(input, {idempotencyKey}) -> {ok, receipt?, error?} } (tests)
95
+ relayTimeoutMs: Number.isFinite(cfg.relayTimeoutMs) ? cfg.relayTimeoutMs : 20_000,
96
+ };
97
+ }
98
+
99
+ /** Mint one tempo/charge challenge per currency. Returns the WWW-Authenticate
100
+ * value ("Payment id=..., ..." joined by ", ") or null when unpriceable. */
101
+ export function mintTempoChallenges({ price, realm, secretKey, tempo }) {
102
+ let amount;
103
+ try { amount = toBaseUnits(price, tempo.decimals); } catch { return null; }
104
+ if (amount <= 0n) return null;
105
+ const splitTotal = tempo.splits.reduce((a, s) => a + s.amount, 0n);
106
+ if (tempo.splits.length && !(splitTotal < amount)) return null; // config-time check should have caught it; never mint an unpayable challenge
107
+ const expires = new Date(Date.now() + tempo.timeoutSeconds * 1000).toISOString();
108
+ const out = [];
109
+ for (const currency of tempo.currencies) {
110
+ const request = {
111
+ amount: amount.toString(),
112
+ currency,
113
+ recipient: tempo.recipient,
114
+ ...(tempo.description ? { description: tempo.description } : {}),
115
+ methodDetails: {
116
+ chainId: tempo.chainId,
117
+ ...(tempo.splits.length ? { splits: tempo.splits.map((s) => ({ recipient: s.recipient, amount: s.amount.toString() })) } : {}),
118
+ },
119
+ };
120
+ const c = { realm, method: "tempo", intent: "charge", request: b64url(canonicalJson(request)), expires };
121
+ c.id = challengeId(secretKey, c);
122
+ out.push([authParam("id", c.id), authParam("realm", c.realm), authParam("method", c.method), authParam("intent", c.intent), authParam("request", c.request), authParam("expires", c.expires)].join(", "));
123
+ }
124
+ return out.map((p) => `${SCHEME} ${p}`).join(", ");
125
+ }
126
+
127
+ /** Parse an Authorization: Payment credential; null unless it is a
128
+ * well-formed tempo/charge credential (no binding check here). */
129
+ export function parseTempoCredential(authorizationHeader) {
130
+ if (typeof authorizationHeader !== "string") return null;
131
+ const m = authorizationHeader.match(/^Payment\s+([A-Za-z0-9_-]+=*)\s*$/i);
132
+ if (!m) return null;
133
+ let wire;
134
+ try { wire = JSON.parse(unb64url(m[1])); } catch { return null; }
135
+ const ch = wire?.challenge;
136
+ if (!ch || typeof ch !== "object" || ch.method !== "tempo" || (ch.intent || "charge") !== "charge") return null;
137
+ if (typeof ch.request !== "string" || typeof ch.id !== "string") return null;
138
+ const p = wire.payload;
139
+ if (!p || typeof p !== "object") return null;
140
+ if (p.type === "transaction" || p.type === "proof") { if (!HEX.test(String(p.signature || ""))) return null; }
141
+ else if (p.type === "hash") { if (!HEX.test(String(p.hash || ""))) return null; }
142
+ else return null;
143
+ return { challenge: ch, payload: p, ...(wire.source ? { source: wire.source } : {}) };
144
+ }
145
+
146
+ /** Binding: is this a challenge WE minted, unexpired, for our recipient, in a
147
+ * currency we offer, on our chain, for at least this price? The relay checks
148
+ * the signed transaction against the challenge's OWN request - never that we
149
+ * minted it - so a forged 1-unit challenge would buy anything without this. */
150
+ export function checkTempoBinding(credential, { secretKey, realm, price, tempo, now = Date.now() }) {
151
+ const bad = (reason) => ({ ok: false, reason });
152
+ const ch = credential?.challenge;
153
+ if (!ch) return bad("no challenge");
154
+ if (!secretKey) return bad("no secret to verify the challenge binding");
155
+ if (!idMatches(secretKey, ch)) return bad("challenge id does not HMAC-verify - not minted by this gate");
156
+ if (realm && ch.realm !== realm) return bad("challenge realm is not ours");
157
+ const exp = Date.parse(ch.expires);
158
+ if (!Number.isFinite(exp) || exp <= now) return bad("challenge expired");
159
+ let r;
160
+ try { r = JSON.parse(unb64url(ch.request)); } catch { return bad("challenge request does not decode"); }
161
+ if (!tempo.currencies.map((c) => c.toLowerCase()).includes(String(r.currency || "").toLowerCase())) return bad("challenge currency is not one this gate offers");
162
+ if (String(r.recipient || "").toLowerCase() !== tempo.recipient.toLowerCase()) return bad("challenge recipient is not this gate's payTo");
163
+ if (Number(r.methodDetails?.chainId) !== tempo.chainId) return bad("challenge chainId is not this gate's chain");
164
+ let amount, expected;
165
+ try { amount = BigInt(String(r.amount)); expected = toBaseUnits(price, tempo.decimals); } catch { return bad("challenge amount is not an integer base-units string"); }
166
+ if (amount < expected) return bad(`challenge amount ${amount} is below this route's price ${expected}`);
167
+ return { ok: true, amount };
168
+ }
169
+
170
+ // Buyer-facing relay failure: status + the relay's error CODE only. The
171
+ // relay's free-text `message` is an upstream body and goes to the operator
172
+ // log (console.warn at the call sites), never into the 402 problem document
173
+ // a buyer reads - a relay that echoes a key or account detail in an error
174
+ // must not hand it to every buyer. (Leak audit 2026-08-19.)
175
+ function relayFailure(res, body) {
176
+ const code = body && typeof body === "object" && body.error && typeof body.error === "object" && typeof body.error.code === "string" ? body.error.code : null;
177
+ return `relay HTTP ${res?.status ?? "?"}${code ? ` ${code.slice(0, 60)}` : ""}`;
178
+ }
179
+ function relayFailureDetail(res, body) {
180
+ const msg = body && typeof body === "object" && body.error && typeof body.error.message === "string" ? body.error.message : "";
181
+ return `${relayFailure(res, body)}${msg ? ` ${msg.slice(0, 160)}` : ""}`;
182
+ }
183
+
184
+ /** The relay client. Injectable `relay` (tests) takes precedence. */
185
+ export function tempoRelay(tempo) {
186
+ if (tempo.relay) return tempo.relay;
187
+ const post = async (path, input, headers = {}) => {
188
+ let res;
189
+ try {
190
+ res = await tempo.fetch(`${tempo.apiBaseUrl}/v1/mpp/${path}`, {
191
+ method: "POST",
192
+ headers: { Accept: "application/json", "content-type": "application/json", "tempo-api-key": tempo.apiKey, ...headers },
193
+ body: JSON.stringify(input),
194
+ signal: AbortSignal.timeout(tempo.relayTimeoutMs),
195
+ });
196
+ } catch (e) {
197
+ return { ok: false, error: `relay unreachable: ${String(e?.message || e).slice(0, 120)}` };
198
+ }
199
+ let body = null;
200
+ try { body = await res.json(); } catch { body = null; }
201
+ if (!res.ok || !body || body.success !== true) return { ok: false, error: relayFailure(res, body), detail: relayFailureDetail(res, body), body };
202
+ return { ok: true, body };
203
+ };
204
+ return {
205
+ async validate(input) {
206
+ const r = await post("validate", input);
207
+ return r.ok ? { ok: true } : { ok: false, error: r.error, detail: r.detail || r.error };
208
+ },
209
+ async broadcast(input, { idempotencyKey }) {
210
+ const r = await post("broadcast", input, { "idempotency-key": idempotencyKey });
211
+ if (!r.ok) return { ok: false, error: r.error, detail: r.detail || r.error };
212
+ const receipt = r.body.receipt;
213
+ if (!receipt || typeof receipt !== "object" || typeof receipt.reference !== "string" || receipt.method !== "tempo") return { ok: false, error: "relay broadcast answered success without a tempo receipt" };
214
+ return { ok: true, receipt };
215
+ },
216
+ };
217
+ }
218
+
219
+ export function relayInput(credential) {
220
+ // The relay's input is mppx's DESERIALIZED credential (Relay.js
221
+ // toRelayInput): the challenge with `request` as the decoded object, not
222
+ // the base64url string that rides on the wire. The first live proof
223
+ // (run 32295980187, 2026-08-19) sent the wire string and the relay refused
224
+ // every credential while the stub relay in the offline suite accepted
225
+ // either - the suite now pins the decoded shape.
226
+ const { request, ...challenge } = credential.challenge;
227
+ let decoded = request;
228
+ if (typeof request === "string") { try { decoded = JSON.parse(unb64url(request)); } catch { decoded = request; } }
229
+ return { challenge: { ...challenge, request: decoded }, payload: credential.payload, ...(credential.source ? { source: credential.source } : {}) };
230
+ }
231
+ export function broadcastIdempotencyKey(input) {
232
+ return `mpp_${createHash("sha256").update(canonicalJson(input), "utf8").digest("hex")}`;
233
+ }
234
+ /** Payment-Receipt header value (base64url JSON, as mppx's Receipt.serialize). */
235
+ export function tempoReceiptHeader(receipt) {
236
+ return b64url(JSON.stringify({ method: "tempo", status: "success", reference: receipt.reference, timestamp: receipt.timestamp || new Date().toISOString(), ...(receipt.externalId ? { externalId: receipt.externalId } : {}) }));
237
+ }
238
+ /** RFC 9457 problem document for a refused tempo credential. */
239
+ export function tempoProblem(kind, detail) {
240
+ const titles = { "invalid-challenge": "Invalid Challenge", "verification-failed": "Verification Failed", "malformed-credential": "Malformed Credential", "payment-insufficient": "Payment Insufficient" };
241
+ return { type: `https://paymentauth.org/problems/${kind}`, title: titles[kind] || kind, status: 402, detail };
242
+ }