agent402-tollbooth 0.8.2 → 0.9.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 +34 -0
- package/index.js +120 -0
- package/package.json +2 -1
- package/tempo.js +225 -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,80 @@ 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) return reject("verification-failed", `Payment verification failed: ${String(v.error || "the Tempo relay rejected the credential").slice(0, 200)}.`);
|
|
353
|
+
// Buffer the handler's response (writeHead/write/end/flushHeaders) -
|
|
354
|
+
// Node's real 'finish' never fires while end is buffered, so the sync
|
|
355
|
+
// primitive is a promise resolved inside the buffered end.
|
|
356
|
+
const originalWriteHead = res.writeHead.bind(res);
|
|
357
|
+
const originalWrite = res.write.bind(res);
|
|
358
|
+
const originalEnd = res.end.bind(res);
|
|
359
|
+
const originalFlushHeaders = typeof res.flushHeaders === "function" ? res.flushHeaders.bind(res) : null;
|
|
360
|
+
let buffered = [];
|
|
361
|
+
let settled = false;
|
|
362
|
+
let endCalled;
|
|
363
|
+
const endPromise = new Promise((resolve) => { endCalled = resolve; });
|
|
364
|
+
const restore = () => {
|
|
365
|
+
settled = true;
|
|
366
|
+
res.writeHead = originalWriteHead; res.write = originalWrite; res.end = originalEnd;
|
|
367
|
+
if (originalFlushHeaders) res.flushHeaders = originalFlushHeaders;
|
|
368
|
+
};
|
|
369
|
+
res.writeHead = (...a) => { if (!settled) { buffered.push(["writeHead", a]); return res; } return originalWriteHead(...a); };
|
|
370
|
+
res.write = (...a) => { if (!settled) { buffered.push(["write", a]); return true; } return originalWrite(...a); };
|
|
371
|
+
res.end = (...a) => { if (!settled) { buffered.push(["end", a]); endCalled(); return res; } return originalEnd(...a); };
|
|
372
|
+
if (originalFlushHeaders) res.flushHeaders = () => { if (!settled) { buffered.push(["flushHeaders", []]); return; } return originalFlushHeaders(); };
|
|
373
|
+
const replay = () => {
|
|
374
|
+
for (const [fn, a] of buffered) {
|
|
375
|
+
if (fn === "writeHead") originalWriteHead(...a);
|
|
376
|
+
else if (fn === "write") originalWrite(...a);
|
|
377
|
+
else if (fn === "flushHeaders") { if (originalFlushHeaders) originalFlushHeaders(); }
|
|
378
|
+
else originalEnd(...a);
|
|
379
|
+
}
|
|
380
|
+
buffered = [];
|
|
381
|
+
};
|
|
382
|
+
try { next(); } catch (err) { restore(); return next(err); }
|
|
383
|
+
await endPromise;
|
|
384
|
+
if (res.statusCode >= 400) { restore(); replay(); return; } // handler failed: never broadcast, nobody charged
|
|
385
|
+
const b = await tempoRelayClient.broadcast(input, { idempotencyKey: broadcastIdempotencyKey(input) }).catch((e) => ({ ok: false, error: String(e?.message || e) }));
|
|
386
|
+
if (!b.ok) {
|
|
387
|
+
// Broadcast failed AFTER a successful handler: discard the body, 402
|
|
388
|
+
// (mirrors @x402/express's settle-failure path). Loud - this is the
|
|
389
|
+
// one path that can be our latency rather than the relay's verdict.
|
|
390
|
+
console.warn(`[agent402-tollbooth] tempo broadcast failed after a successful handler (${req.method} ${req.url}) - answered 402, not charged: ${b.error}`);
|
|
391
|
+
buffered = [];
|
|
392
|
+
restore();
|
|
393
|
+
return reject("verification-failed", `Payment verification failed: Tempo settlement was not accepted (${String(b.error || "no relay detail").slice(0, 200)}).`);
|
|
394
|
+
}
|
|
395
|
+
incr("tempoPaid");
|
|
396
|
+
restore();
|
|
397
|
+
res.setHeader("Payment-Receipt", tempoReceiptHeader(b.receipt));
|
|
398
|
+
res.setHeader("X-Tollbooth-Paid", "mpp-tempo");
|
|
399
|
+
replay();
|
|
400
|
+
};
|
|
401
|
+
|
|
286
402
|
// Paid rail, middleware mode: delegate to the operator's x402 middleware
|
|
287
403
|
// with the REAL response so verify -> handler -> settle run in ITS order,
|
|
288
404
|
// exactly once. Its 402 (no/invalid payment) is intercepted and replaced by
|
|
@@ -403,6 +519,10 @@ export function createTollbooth(config = {}) {
|
|
|
403
519
|
// either directly or after the free rail declined, so it lives in a function
|
|
404
520
|
// the (possibly async) proof-of-work branch can hand control back to.
|
|
405
521
|
const paidRail = () => {
|
|
522
|
+
if (tempoCfg) {
|
|
523
|
+
const cred = parseTempoCredential(req.headers.authorization);
|
|
524
|
+
if (cred) return tempoRail(cred);
|
|
525
|
+
}
|
|
406
526
|
if (x402) return middlewareRail();
|
|
407
527
|
const payHeader = req.headers["x-payment"] || req.headers["payment-signature"];
|
|
408
528
|
if (payTo && typeof verifyX402 === "function" && payHeader) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-tollbooth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -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,225 @@
|
|
|
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
|
+
function relayFailure(res, body) {
|
|
171
|
+
const code = body && typeof body === "object" && body.error && typeof body.error === "object" && typeof body.error.code === "string" ? body.error.code : null;
|
|
172
|
+
const msg = body && typeof body === "object" && body.error && typeof body.error.message === "string" ? body.error.message : "";
|
|
173
|
+
return `relay HTTP ${res?.status ?? "?"}${code ? ` ${code}` : ""}${msg ? ` ${msg.slice(0, 160)}` : ""}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The relay client. Injectable `relay` (tests) takes precedence. */
|
|
177
|
+
export function tempoRelay(tempo) {
|
|
178
|
+
if (tempo.relay) return tempo.relay;
|
|
179
|
+
const post = async (path, input, headers = {}) => {
|
|
180
|
+
let res;
|
|
181
|
+
try {
|
|
182
|
+
res = await tempo.fetch(`${tempo.apiBaseUrl}/v1/mpp/${path}`, {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: { Accept: "application/json", "content-type": "application/json", "tempo-api-key": tempo.apiKey, ...headers },
|
|
185
|
+
body: JSON.stringify(input),
|
|
186
|
+
signal: AbortSignal.timeout(tempo.relayTimeoutMs),
|
|
187
|
+
});
|
|
188
|
+
} catch (e) {
|
|
189
|
+
return { ok: false, error: `relay unreachable: ${String(e?.message || e).slice(0, 120)}` };
|
|
190
|
+
}
|
|
191
|
+
let body = null;
|
|
192
|
+
try { body = await res.json(); } catch { body = null; }
|
|
193
|
+
if (!res.ok || !body || body.success !== true) return { ok: false, error: relayFailure(res, body), body };
|
|
194
|
+
return { ok: true, body };
|
|
195
|
+
};
|
|
196
|
+
return {
|
|
197
|
+
async validate(input) {
|
|
198
|
+
const r = await post("validate", input);
|
|
199
|
+
return r.ok ? { ok: true } : { ok: false, error: r.error };
|
|
200
|
+
},
|
|
201
|
+
async broadcast(input, { idempotencyKey }) {
|
|
202
|
+
const r = await post("broadcast", input, { "idempotency-key": idempotencyKey });
|
|
203
|
+
if (!r.ok) return { ok: false, error: r.error };
|
|
204
|
+
const receipt = r.body.receipt;
|
|
205
|
+
if (!receipt || typeof receipt !== "object" || typeof receipt.reference !== "string" || receipt.method !== "tempo") return { ok: false, error: "relay broadcast answered success without a tempo receipt" };
|
|
206
|
+
return { ok: true, receipt };
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function relayInput(credential) {
|
|
212
|
+
return { challenge: credential.challenge, payload: credential.payload, ...(credential.source ? { source: credential.source } : {}) };
|
|
213
|
+
}
|
|
214
|
+
export function broadcastIdempotencyKey(input) {
|
|
215
|
+
return `mpp_${createHash("sha256").update(canonicalJson(input), "utf8").digest("hex")}`;
|
|
216
|
+
}
|
|
217
|
+
/** Payment-Receipt header value (base64url JSON, as mppx's Receipt.serialize). */
|
|
218
|
+
export function tempoReceiptHeader(receipt) {
|
|
219
|
+
return b64url(JSON.stringify({ method: "tempo", status: "success", reference: receipt.reference, timestamp: receipt.timestamp || new Date().toISOString(), ...(receipt.externalId ? { externalId: receipt.externalId } : {}) }));
|
|
220
|
+
}
|
|
221
|
+
/** RFC 9457 problem document for a refused tempo credential. */
|
|
222
|
+
export function tempoProblem(kind, detail) {
|
|
223
|
+
const titles = { "invalid-challenge": "Invalid Challenge", "verification-failed": "Verification Failed", "malformed-credential": "Malformed Credential", "payment-insufficient": "Payment Insufficient" };
|
|
224
|
+
return { type: `https://paymentauth.org/problems/${kind}`, title: titles[kind] || kind, status: 402, detail };
|
|
225
|
+
}
|