x402-safe-fetch 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A `fetch` for AI agents that checks an unknown x402 endpoint before paying it.
4
4
 
5
- A free endpoint is answered as is. For a paid one (HTTP 402), your agent first pays **$0.001** for an [x402 Doctor](https://x402-doctor.onrender.com) preflight and acts on it:
5
+ A free endpoint is answered as is. For a paid one (HTTP 402), your agent first pays **$0.001** for an [x402 Doctor](https://x402-doctor.fizzl.eu) preflight and acts on it:
6
6
 
7
7
  | Preflight | What `safeFetch` does |
8
8
  |---|---|
@@ -35,7 +35,7 @@ const safeFetch = createSafeFetch({
35
35
  });
36
36
 
37
37
  try {
38
- const res = await safeFetch("https://ichimoku-signal.onrender.com/signal/BTC-USDT");
38
+ const res = await safeFetch("https://ichimoku-signal.fizzl.eu/signal/BTC-USDT");
39
39
  console.log(await res.json());
40
40
  } catch (err) {
41
41
  if (err instanceof SafePayError) console.log(err.code, err.message, err.preflight?.reasons);
@@ -57,7 +57,8 @@ Solana works the same way: `network: "solana"` and register an `ExactSvmScheme`
57
57
  | `onPreflight` | | `(preflight, { url, method, cached }) => void`, e.g. for logging |
58
58
  | `cacheMs` | 10 minutes | How long a verdict is reused per method and URL |
59
59
  | `verifyReceipts` | `"require"` | Check Doctor's signed receipt on every preflight; `"off"` skips it |
60
- | `doctorSigners` | Doctor's published signer | Accepted signer addresses (for a self-hosted Doctor, or after a key rotation) |
60
+ | `doctorSigners` | Doctor's published signer | Accepted signer addresses (for a self-hosted Doctor) |
61
+ | `authority` | the Fizzl payout wallet | Wallet whose certificates also make a signer trusted (key rotation); `null` accepts only `doctorSigners` |
61
62
 
62
63
  The preflight itself is capped at $0.002 and paid with the same schemes. `receiptOf(response)` returns the payment receipt (transaction hash) of a paid response.
63
64
 
@@ -69,6 +70,8 @@ x402 Doctor signs every paid preflight (EIP-191 over canonical JSON, with the re
69
70
  - the signature must cover exactly this request: your endpoint, method, budget and network;
70
71
  - otherwise it throws `SafePayError` with code `bad_receipt` and **the endpoint is not paid**. A tampered "go", a missing receipt or a verdict for another request never turns into a payment.
71
72
 
73
+ **Key rotation:** Doctor's signing key can change without a safe-fetch update. The Fizzl payout wallet (`0x6B0F4651eD42893ab58139938175E4a69f175F25`, the `payTo` of every payment) certifies each key, and the certificate travels inside the receipt (`receipt.cert`). safe-fetch accepts a key that is pinned *or* certified by that wallet for x402 Doctor, for signatures made on or after the certificate's date. Set `authority: null` to accept only `doctorSigners`.
74
+
72
75
  `verifyReceipt(body, { signers, route, input })`, `recoverSigner`, `canonicalJson` and `inputHash` are exported, so you can check any signed Doctor or presign-guard answer yourself.
73
76
 
74
77
  ## Costs and limits
package/index.d.ts CHANGED
@@ -5,7 +5,7 @@ export declare const PREFLIGHT_CAP: string;
5
5
  export declare const BASE: "eip155:8453";
6
6
  export declare const SOLANA: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
7
7
 
8
- /** The x402 Doctor preflight: see https://x402-doctor.onrender.com/openapi.json */
8
+ /** The x402 Doctor preflight: see https://x402-doctor.fizzl.eu/openapi.json */
9
9
  export interface Preflight {
10
10
  verdict: "go" | "caution" | "no_go";
11
11
  summary?: string;
@@ -39,6 +39,8 @@ export interface SafeFetchOptions {
39
39
  verifyReceipts?: "require" | "off";
40
40
  /** Accepted Doctor signer addresses. Default: the published signer (DOCTOR_SIGNERS). */
41
41
  doctorSigners?: string[];
42
+ /** Wallet whose certificates (receipt.cert) also make a signer trusted, so a rotated Doctor key keeps working. Default: the Fizzl payout wallet; null accepts only doctorSigners. */
43
+ authority?: string | null;
42
44
  fetch?: typeof globalThis.fetch;
43
45
  /** Advanced/testing: a paying fetch capped at `cap` (e.g. "$0.05"). */
44
46
  createPayingFetch?: (cap: string) => typeof globalThis.fetch;
@@ -54,4 +56,6 @@ export declare const DOCTOR_SIGNERS: string[];
54
56
  export declare function canonicalJson(value: unknown): string;
55
57
  export declare function inputHash(route: string, input: unknown): string;
56
58
  export declare function recoverSigner(message: string, signature: string): string | null;
57
- export declare function verifyReceipt(body: unknown, options?: { signers?: string[]; route?: string; input?: unknown }): { valid: boolean; signer?: string; reason?: string };
59
+ export declare const AUTHORITY: string;
60
+ export declare function certMessage(cert: { service: string; signer: string; valid_from: string }): string;
61
+ export declare function verifyReceipt(body: unknown, options?: { signers?: string[]; route?: string; input?: unknown; authority?: string | null; service?: string }): { valid: boolean; signer?: string; reason?: string };
package/index.js CHANGED
@@ -17,11 +17,11 @@
17
17
  // your code): register: (client) => client.register("eip155:8453", new ExactEvmScheme(account))
18
18
 
19
19
  import { wrapFetchWithPayment, x402Client, decodePaymentResponseHeader } from "@x402/fetch";
20
- import { verifyReceipt, DOCTOR_SIGNERS } from "./receipt.js";
20
+ import { verifyReceipt, DOCTOR_SIGNERS, AUTHORITY } from "./receipt.js";
21
21
 
22
- export { verifyReceipt, recoverSigner, canonicalJson, inputHash, DOCTOR_SIGNERS } from "./receipt.js";
22
+ export { verifyReceipt, recoverSigner, canonicalJson, inputHash, certMessage, DOCTOR_SIGNERS, AUTHORITY } from "./receipt.js";
23
23
 
24
- export const DOCTOR_URL = "https://x402-doctor.onrender.com";
24
+ export const DOCTOR_URL = "https://x402-doctor.fizzl.eu";
25
25
  export const PREFLIGHT_CAP = "$0.002"; // the preflight costs $0.001; never more than twice that
26
26
  export const BASE = "eip155:8453";
27
27
  export const SOLANA = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
@@ -94,6 +94,7 @@ function requestOf(input, init) {
94
94
  * @param {string} [options.doctorUrl]
95
95
  * @param {"require"|"off"} [options.verifyReceipts] check Doctor's signature on every preflight (default "require")
96
96
  * @param {string[]} [options.doctorSigners] accepted Doctor signer addresses (default: the published signer)
97
+ * @param {string|null} [options.authority] wallet whose certificates also make a signer trusted (default: the Fizzl payout wallet; null to accept only doctorSigners)
97
98
  * @param {typeof fetch} [options.fetch] the underlying fetch (default globalThis.fetch)
98
99
  * @param {(cap: string) => typeof fetch} [options.createPayingFetch] advanced/testing: a paying fetch capped at `cap`
99
100
  * @param {() => number} [options.now]
@@ -109,6 +110,7 @@ export function createSafeFetch({
109
110
  doctorUrl = DOCTOR_URL,
110
111
  verifyReceipts = "require",
111
112
  doctorSigners = DOCTOR_SIGNERS,
113
+ authority = AUTHORITY,
112
114
  fetch: baseFetch = globalThis.fetch,
113
115
  createPayingFetch,
114
116
  now = Date.now,
@@ -152,7 +154,7 @@ export function createSafeFetch({
152
154
  if (verifyReceipts === "require") {
153
155
  // Bound to this request: the query Doctor saw, as strings (see Doctor's input_sha256).
154
156
  const input = Object.fromEntries(new URL(pfUrl).searchParams);
155
- const check = verifyReceipt(preflight, { signers: doctorSigners, route: "GET /api/v1/preflight", input });
157
+ const check = verifyReceipt(preflight, { signers: doctorSigners, route: "GET /api/v1/preflight", input, authority, service: "x402-doctor" });
156
158
  if (!check.valid) throw new SafePayError(`preflight not trusted (${check.reason}); the endpoint was not paid`, { code: "bad_receipt", preflight, url });
157
159
  }
158
160
  verdicts.set(key, { preflight, expires: now() + cacheMs });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-safe-fetch",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "A fetch for AI agents that checks an unknown x402 endpoint ($0.001 x402 Doctor preflight) before paying it, and never pays above your budget.",
5
5
  "type": "module",
6
6
  "exports": {
package/receipt.js CHANGED
@@ -4,21 +4,42 @@
4
4
  // README). safe-fetch checks that receipt before acting on a preflight, so a
5
5
  // tampered or forged "go" never leads to a payment.
6
6
  //
7
- // Canonical JSON: keys sorted at every level, no whitespace, non-ASCII as
8
- // \uXXXX (the same bytes as Python's json.dumps(sort_keys=True,
9
- // separators=(",", ":"), ensure_ascii=True)).
7
+ // Canonical JSON (profile js-json-stringify-sorted-utf16-ascii-v1): keys
8
+ // sorted by UTF-16 code units at every level, no whitespace, every code unit
9
+ // from U+007F up as lowercase \uXXXX, numbers as JSON.stringify writes them,
10
+ // UTF-8 bytes. Python's json.dumps matches only for ASCII keys and integers; a
11
+ // Python equivalent: github.com/Fizzl13/presign-guard/blob/main/examples/canonical.py
10
12
 
11
13
  import { createHash } from "node:crypto";
12
14
  import { secp256k1 } from "@noble/curves/secp256k1";
13
15
  import { keccak_256 } from "@noble/hashes/sha3";
14
16
 
15
- // Doctor's published signers (https://x402-doctor.onrender.com/.well-known/x402-doctor-signer.json).
17
+ // Doctor's published signers (https://x402-doctor.fizzl.eu/.well-known/x402-doctor-signer.json).
16
18
  // Pinned here on purpose: a signer list fetched from the same server would not
17
19
  // protect against that server being compromised.
18
20
  export const DOCTOR_SIGNERS = ["0xAaE66eF9Ee234397df33901568c8FBc36d43277d"];
19
21
 
22
+ // The payout wallet (the payTo of every Fizzl payment) certifies signing keys:
23
+ // a key it authorised for the service is accepted too, so a rotated Doctor key
24
+ // verifies without a new safe-fetch release. The certificate travels inside
25
+ // the receipt (receipt.cert) as a personal_sign over certMessage.
26
+ export const AUTHORITY = "0x6B0F4651eD42893ab58139938175E4a69f175F25";
27
+
28
+ export function certMessage({ service, signer, valid_from }) {
29
+ return `fizzl receipt signer\nservice: ${service}\nsigner: ${signer}\nvalid_from: ${valid_from}`;
30
+ }
31
+
32
+ // A key the authority certified for `service`, used on or after valid_from.
33
+ export function certifiedSigner(receipt, recovered, { authority = AUTHORITY, service = "x402-doctor" } = {}) {
34
+ const c = receipt && receipt.cert;
35
+ if (!c || c.service !== service || String(c.signer).toLowerCase() !== recovered) return false;
36
+ if (String(c.authority).toLowerCase() !== authority.toLowerCase()) return false;
37
+ if (!(String(receipt.signed_at).slice(0, 10) >= String(c.valid_from))) return false;
38
+ return recoverSigner(certMessage(c), c.signature) === authority.toLowerCase();
39
+ }
40
+
20
41
  export function canonicalJson(value) {
21
- const ascii = (s) => JSON.stringify(s).replace(/[\u007f-￿]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
42
+ const ascii = (s) => JSON.stringify(s).replace(/[\u007f-\uffff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
22
43
  const walk = (v) => {
23
44
  if (v === null || typeof v !== "object") return v === undefined ? undefined : typeof v === "string" ? ascii(v) : JSON.stringify(v);
24
45
  if (typeof v.toJSON === "function") return walk(v.toJSON());
@@ -56,13 +77,16 @@ export function recoverSigner(message, signature) {
56
77
  * and input) for exactly that request.
57
78
  * @returns {{ valid: boolean, signer?: string, reason?: string }}
58
79
  */
59
- export function verifyReceipt(body, { signers = DOCTOR_SIGNERS, route, input } = {}) {
80
+ export function verifyReceipt(body, { signers = DOCTOR_SIGNERS, route, input, authority = AUTHORITY, service = "x402-doctor" } = {}) {
60
81
  const r = body && body.receipt;
61
82
  if (!r || typeof r.signature !== "string") return { valid: false, reason: "no signed receipt" };
62
83
  const { signature, ...rest } = r;
63
84
  const recovered = recoverSigner(canonicalJson({ ...body, receipt: rest }), signature);
64
85
  if (!recovered || recovered.toLowerCase() !== String(r.signer).toLowerCase()) return { valid: false, reason: "signature does not match: the answer was changed" };
65
- if (!signers.some((s) => s.toLowerCase() === recovered)) return { valid: false, signer: recovered, reason: "signed by an unknown key, not x402 Doctor" };
86
+ const pinned = signers.some((s) => s.toLowerCase() === recovered);
87
+ if (!pinned && !(authority && certifiedSigner(r, recovered, { authority, service }))) {
88
+ return { valid: false, signer: recovered, reason: "signed by an unknown key, not x402 Doctor" };
89
+ }
66
90
  if (route !== undefined && input !== undefined && inputHash(route, input) !== r.input_sha256) {
67
91
  return { valid: false, signer: recovered, reason: "signed for a different request" };
68
92
  }