x402-safe-fetch 0.2.0 → 0.3.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 +4 -1
- package/index.d.ts +5 -1
- package/index.js +5 -3
- package/package.json +1 -1
- package/receipt.js +25 -3
package/README.md
CHANGED
|
@@ -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
|
|
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
|
@@ -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
|
|
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,9 +17,9 @@
|
|
|
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
24
|
export const DOCTOR_URL = "https://x402-doctor.onrender.com";
|
|
25
25
|
export const PREFLIGHT_CAP = "$0.002"; // the preflight costs $0.001; never more than twice that
|
|
@@ -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.
|
|
3
|
+
"version": "0.3.0",
|
|
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
|
@@ -17,8 +17,27 @@ import { keccak_256 } from "@noble/hashes/sha3";
|
|
|
17
17
|
// protect against that server being compromised.
|
|
18
18
|
export const DOCTOR_SIGNERS = ["0xAaE66eF9Ee234397df33901568c8FBc36d43277d"];
|
|
19
19
|
|
|
20
|
+
// The payout wallet (the payTo of every Fizzl payment) certifies signing keys:
|
|
21
|
+
// a key it authorised for the service is accepted too, so a rotated Doctor key
|
|
22
|
+
// verifies without a new safe-fetch release. The certificate travels inside
|
|
23
|
+
// the receipt (receipt.cert) as a personal_sign over certMessage.
|
|
24
|
+
export const AUTHORITY = "0x6B0F4651eD42893ab58139938175E4a69f175F25";
|
|
25
|
+
|
|
26
|
+
export function certMessage({ service, signer, valid_from }) {
|
|
27
|
+
return `fizzl receipt signer\nservice: ${service}\nsigner: ${signer}\nvalid_from: ${valid_from}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A key the authority certified for `service`, used on or after valid_from.
|
|
31
|
+
export function certifiedSigner(receipt, recovered, { authority = AUTHORITY, service = "x402-doctor" } = {}) {
|
|
32
|
+
const c = receipt && receipt.cert;
|
|
33
|
+
if (!c || c.service !== service || String(c.signer).toLowerCase() !== recovered) return false;
|
|
34
|
+
if (String(c.authority).toLowerCase() !== authority.toLowerCase()) return false;
|
|
35
|
+
if (!(String(receipt.signed_at).slice(0, 10) >= String(c.valid_from))) return false;
|
|
36
|
+
return recoverSigner(certMessage(c), c.signature) === authority.toLowerCase();
|
|
37
|
+
}
|
|
38
|
+
|
|
20
39
|
export function canonicalJson(value) {
|
|
21
|
-
const ascii = (s) => JSON.stringify(s).replace(/[\u007f
|
|
40
|
+
const ascii = (s) => JSON.stringify(s).replace(/[\u007f-\uffff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
22
41
|
const walk = (v) => {
|
|
23
42
|
if (v === null || typeof v !== "object") return v === undefined ? undefined : typeof v === "string" ? ascii(v) : JSON.stringify(v);
|
|
24
43
|
if (typeof v.toJSON === "function") return walk(v.toJSON());
|
|
@@ -56,13 +75,16 @@ export function recoverSigner(message, signature) {
|
|
|
56
75
|
* and input) for exactly that request.
|
|
57
76
|
* @returns {{ valid: boolean, signer?: string, reason?: string }}
|
|
58
77
|
*/
|
|
59
|
-
export function verifyReceipt(body, { signers = DOCTOR_SIGNERS, route, input } = {}) {
|
|
78
|
+
export function verifyReceipt(body, { signers = DOCTOR_SIGNERS, route, input, authority = AUTHORITY, service = "x402-doctor" } = {}) {
|
|
60
79
|
const r = body && body.receipt;
|
|
61
80
|
if (!r || typeof r.signature !== "string") return { valid: false, reason: "no signed receipt" };
|
|
62
81
|
const { signature, ...rest } = r;
|
|
63
82
|
const recovered = recoverSigner(canonicalJson({ ...body, receipt: rest }), signature);
|
|
64
83
|
if (!recovered || recovered.toLowerCase() !== String(r.signer).toLowerCase()) return { valid: false, reason: "signature does not match: the answer was changed" };
|
|
65
|
-
|
|
84
|
+
const pinned = signers.some((s) => s.toLowerCase() === recovered);
|
|
85
|
+
if (!pinned && !(authority && certifiedSigner(r, recovered, { authority, service }))) {
|
|
86
|
+
return { valid: false, signer: recovered, reason: "signed by an unknown key, not x402 Doctor" };
|
|
87
|
+
}
|
|
66
88
|
if (route !== undefined && input !== undefined && inputHash(route, input) !== r.input_sha256) {
|
|
67
89
|
return { valid: false, signer: recovered, reason: "signed for a different request" };
|
|
68
90
|
}
|