@pooriaarab/foxpay 0.0.0-stage → 0.1.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/dist/intent.js ADDED
@@ -0,0 +1,263 @@
1
+ // Payment intents: check an intent, quote it on the host side, ask foxgate,
2
+ // and run the method one time after the human approves
3
+ // (docs/failure-modes.md I1-I17).
4
+ import { canonicalJson, normalizeHost } from "foxgate";
5
+ /** The foxgate tool name of every payment. Register it with `payTools()`. */
6
+ export const PAY_TOOL = "foxpay.pay";
7
+ /**
8
+ * foxgate takes 3-letter currency codes. Test USDC on Base Sepolia counts as
9
+ * XTS, the ISO 4217 code for testing, in whole atomic units (6 decimals).
10
+ */
11
+ export const GATE_CURRENCY = Object.freeze({ USDC: "XTS" });
12
+ const gateCurrency = (currency) => GATE_CURRENCY[currency] ?? currency;
13
+ /** The foxgate tool registry for foxpay. The amount comes from the quote, which foxpay puts in the args. */
14
+ export function payTools() {
15
+ return {
16
+ [PAY_TOOL]: { scope: "pay", amount: (args) => ({ value: args.amount, currency: gateCurrency(args.currency) }) },
17
+ };
18
+ }
19
+ /** Throw it from a method to refuse with a reason that callers can switch on. From `pay`, only before any money can move. */
20
+ export class PayRefusal extends Error {
21
+ reason;
22
+ constructor(reason, message = reason) {
23
+ super(message);
24
+ this.reason = reason;
25
+ }
26
+ }
27
+ const KEY = "foxpay";
28
+ const INTENT_KEYS = new Set(["merchant", "amount", "currency", "reason", "method", "idempotencyKey", "target"]);
29
+ const plain = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
30
+ const refused = (reason, message, id) => ({ status: "refused", ...(id && { id }), reason, message });
31
+ const bad = (why) => {
32
+ throw new PayRefusal("bad-intent", `The intent is not valid: ${why}.`);
33
+ };
34
+ const summary = (rec) => ({ id: rec.id, merchant: rec.intent.merchant, amount: rec.quote.amount, currency: rec.quote.currency, reason: rec.intent.reason, method: rec.intent.method, payee: rec.quote.payee });
35
+ // A method error that is not a PayRefusal can hold anything, so its message is dropped.
36
+ async function guard(fn) {
37
+ try {
38
+ return await fn();
39
+ }
40
+ catch (error) {
41
+ throw error instanceof PayRefusal ? error : new PayRefusal("method-error", "The payment method failed.");
42
+ }
43
+ }
44
+ function parseIntent(input, methods) {
45
+ if (!plain(input))
46
+ return bad("not an object");
47
+ for (const k of Object.keys(input))
48
+ if (!INTENT_KEYS.has(k))
49
+ bad(`unknown field ${k}`);
50
+ const { merchant, amount, currency, reason, method, idempotencyKey, target } = input;
51
+ let host = "";
52
+ try {
53
+ host = normalizeHost(merchant);
54
+ }
55
+ catch {
56
+ bad("merchant must be a host name");
57
+ }
58
+ if (!Number.isSafeInteger(amount) || amount <= 0)
59
+ bad("amount must be a whole number of minor units above 0");
60
+ if (typeof currency !== "string" || !/^[A-Z]{3}$/.test(gateCurrency(currency)))
61
+ bad("currency must be a 3-letter code or USDC");
62
+ if (typeof reason !== "string" || reason.trim().length === 0 || reason.length > 200)
63
+ bad("reason must have 1 to 200 characters");
64
+ if (typeof method !== "string" || !Object.hasOwn(methods, method))
65
+ bad("unknown method");
66
+ if (typeof idempotencyKey !== "string" || idempotencyKey.length < 8 || idempotencyKey.length > 128)
67
+ bad("idempotencyKey must have 8 to 128 characters");
68
+ if (target !== undefined && !plain(target))
69
+ bad("target must be an object");
70
+ const intent = { merchant: host, amount, currency, reason, method, idempotencyKey, ...(target !== undefined && { target }) };
71
+ try {
72
+ canonicalJson(intent);
73
+ }
74
+ catch {
75
+ bad("not plain JSON");
76
+ }
77
+ return intent;
78
+ }
79
+ export function createFoxpay(options) {
80
+ const { gate, methods, store, onEvent } = options;
81
+ const now = options.now ?? Date.now;
82
+ let tail = Promise.resolve();
83
+ const locked = (fn) => {
84
+ const next = tail.then(fn);
85
+ tail = next.catch(() => undefined);
86
+ return next;
87
+ };
88
+ async function load() {
89
+ const raw = await store.get(KEY);
90
+ if (raw === undefined || raw === null)
91
+ return { v: 1, intents: {} };
92
+ if (!plain(raw) || raw.v !== 1 || !plain(raw.intents))
93
+ throw new PayRefusal("storage-error", "The stored foxpay record has a wrong shape.");
94
+ return raw;
95
+ }
96
+ const save = (data) => store.set(KEY, data);
97
+ // Intents that pay now, in this object. After a restart, "paying" means the outcome is not known.
98
+ const running = new Set();
99
+ const done = (rec) => {
100
+ if (rec.status !== "paying")
101
+ return { status: rec.status, id: rec.id, receipt: rec.receipt };
102
+ if (running.has(rec.id))
103
+ return refused("in-progress", "This intent pays now.", rec.id);
104
+ return refused("outcome-unknown", "A payment for this intent started, and its outcome is not known. foxpay does not pay it again.", rec.id);
105
+ };
106
+ async function emit(kind, data) {
107
+ try {
108
+ await onEvent?.({ actor: "foxpay", kind, data });
109
+ }
110
+ catch {
111
+ throw new PayRefusal("hook-failed", "The onEvent hook failed.");
112
+ }
113
+ }
114
+ // Ask foxgate about the stored action. A pending request comes back with the same id.
115
+ // The answer "pay" means that foxgate allowed it with no approval and counted the spend.
116
+ async function decide(data, rec) {
117
+ const decision = await gate.check(rec.action);
118
+ if (decision.decision === "deny") {
119
+ delete data.intents[rec.id];
120
+ await save(data);
121
+ await emit("pay.refused", { ...summary(rec), why: decision.reason }).catch(() => undefined);
122
+ return refused(decision.reason, decision.message, rec.id);
123
+ }
124
+ if (decision.decision === "ask") {
125
+ rec.requestId = decision.requestId;
126
+ data.intents[rec.id] = rec;
127
+ await save(data);
128
+ await emit("pay.ask", { ...summary(rec), requestId: decision.requestId });
129
+ return { status: "ask", id: rec.id, requestId: decision.requestId };
130
+ }
131
+ rec.status = "paying";
132
+ data.intents[rec.id] = rec;
133
+ await save(data);
134
+ running.add(rec.id);
135
+ await emit("pay.approved", { ...summary(rec), grantId: decision.grantId }).catch(() => undefined);
136
+ return "pay";
137
+ }
138
+ async function run(rec) {
139
+ const method = methods[rec.intent.method];
140
+ let outcome;
141
+ try {
142
+ outcome = await method.pay({ id: rec.id, intent: rec.intent, quote: rec.quote });
143
+ }
144
+ catch (error) {
145
+ // A PayRefusal is a promise that no money moved. Any other error can come
146
+ // after money moved, so the outcome is not known (I19).
147
+ outcome = error instanceof PayRefusal ? { status: "failed", reason: error.reason } : { status: "unsettled", reason: "method-error" };
148
+ }
149
+ const receipt = {
150
+ ...summary(rec),
151
+ status: outcome.status,
152
+ ...(outcome.reason && { failure: outcome.reason }),
153
+ ...(rec.requestId && { requestId: rec.requestId }),
154
+ paidAt: now(),
155
+ proof: outcome.proof ?? {},
156
+ };
157
+ return locked(async () => {
158
+ running.delete(rec.id);
159
+ // When the save fails, the store keeps "paying", so a later call refuses outcome-unknown.
160
+ try {
161
+ const data = await load();
162
+ data.intents[rec.id] = { ...(data.intents[rec.id] ?? rec), status: outcome.status, receipt };
163
+ await save(data);
164
+ }
165
+ catch {
166
+ // The result below is still true: the method ran.
167
+ }
168
+ // The money moved. A failed hook cannot undo that, so it does not change the result.
169
+ await emit("pay.result", { ...receipt }).catch(() => undefined);
170
+ return { status: outcome.status, id: rec.id, receipt, ...(outcome.body !== undefined && { body: outcome.body }) };
171
+ });
172
+ }
173
+ async function request(input) {
174
+ try {
175
+ const out = await locked(async () => {
176
+ const intent = parseIntent(input, methods);
177
+ const data = await load();
178
+ const id = `fpy-${intent.idempotencyKey}`;
179
+ const old = data.intents[id];
180
+ if (old) {
181
+ if (canonicalJson(old.intent) !== canonicalJson(intent))
182
+ return refused("key-reused", "This idempotency key belongs to a different intent.", id);
183
+ if (old.status !== "awaiting")
184
+ return done(old);
185
+ // The grant can now allow it with no approval, so the method check runs first (I18).
186
+ const check = methods[old.intent.method].check;
187
+ const stop = check && (await guard(() => check({ id, intent: old.intent, quote: old.quote })));
188
+ if (stop)
189
+ return refused(stop, `The payment cannot run now: ${stop}.`, id);
190
+ const again = await decide(data, old);
191
+ return again === "pay" ? old : again;
192
+ }
193
+ await emit("pay.request", { merchant: intent.merchant, amount: intent.amount, currency: intent.currency, reason: intent.reason, method: intent.method });
194
+ const quote = await guard(() => methods[intent.method].quote(intent));
195
+ if (quote.currency !== intent.currency)
196
+ return refused("currency-mismatch", `The merchant asks for ${quote.currency}, not ${intent.currency}. foxpay does not convert.`);
197
+ if (quote.amount !== intent.amount)
198
+ return refused("amount-mismatch", `The merchant asks for ${quote.amount}, not ${intent.amount}.`);
199
+ const args = { intent: id, merchant: intent.merchant, amount: quote.amount, currency: quote.currency, reason: intent.reason, method: intent.method, payee: quote.payee, ...(quote.details && { details: quote.details }) };
200
+ const rec = { id, intent, quote, action: { tool: PAY_TOOL, scope: "pay", domain: intent.merchant, args }, status: "awaiting" };
201
+ const result = await decide(data, rec);
202
+ return result === "pay" ? rec : result;
203
+ });
204
+ return "action" in out ? run(out) : out;
205
+ }
206
+ catch (error) {
207
+ if (error instanceof PayRefusal)
208
+ return refused(error.reason, error.message);
209
+ return refused("storage-error", "Cannot read or save the foxpay record.");
210
+ }
211
+ }
212
+ async function complete(id, token) {
213
+ let rec;
214
+ try {
215
+ const out = await locked(async () => {
216
+ const data = await load();
217
+ const found = typeof id === "string" && Object.hasOwn(data.intents, id) ? data.intents[id] : undefined;
218
+ if (!found)
219
+ return refused("not-found", "No intent has this id.");
220
+ if (found.status !== "awaiting")
221
+ return done(found);
222
+ // The method checks the page or the vault before foxgate uses the token (I16, I17).
223
+ const check = methods[found.intent.method].check;
224
+ const stop = check && (await guard(() => check({ id, intent: found.intent, quote: found.quote })));
225
+ if (stop)
226
+ return refused(stop, `The payment cannot run now: ${stop}.`, id);
227
+ await emit("pay.start", summary(found));
228
+ const decision = await gate.redeem(token, found.action);
229
+ if (decision.decision !== "allow") {
230
+ const deny = decision.decision === "deny" ? decision : { reason: "bad-token", message: "The token is not valid." };
231
+ return refused(deny.reason, deny.message, id);
232
+ }
233
+ found.status = "paying";
234
+ await save(data);
235
+ running.add(id);
236
+ await emit("pay.approved", { ...summary(found), requestId: found.requestId }).catch(() => undefined);
237
+ rec = found;
238
+ return undefined;
239
+ });
240
+ if (out)
241
+ return out;
242
+ }
243
+ catch (error) {
244
+ if (error instanceof PayRefusal)
245
+ return refused(error.reason, error.message, id);
246
+ return refused("storage-error", "Cannot read or save the foxpay record.", id);
247
+ }
248
+ // A second call at the same time sees "paying" above, so this runs one time (I8).
249
+ return run(rec);
250
+ }
251
+ async function list() {
252
+ return Object.values((await load()).intents);
253
+ }
254
+ return Object.freeze({
255
+ /** Check an intent, quote it, and ask foxgate. A retry with the same key gives the same answer. */
256
+ request,
257
+ /** Pay an approved intent with the foxgate token. It pays one time; later calls give the receipt. */
258
+ complete,
259
+ /** Every intent that waits, runs, or ran, with its quote and status. */
260
+ intents: async () => (await list()).map((r) => ({ ...summary(r), status: r.status, ...(r.requestId && { requestId: r.requestId }) })),
261
+ receipts: async () => (await list()).flatMap((r) => (r.receipt ? [r.receipt] : [])).toSorted((a, b) => a.paidAt - b.paidAt),
262
+ });
263
+ }
@@ -0,0 +1,52 @@
1
+ import { type PaymentRequirements } from "./x402.js";
2
+ export interface FakeX402Options {
3
+ /** The host of the API, for example `api.localhost`. */
4
+ host: string;
5
+ /** The recipient address. */
6
+ payTo: string;
7
+ /** The price in atomic USDC units (6 decimals). */
8
+ price: number;
9
+ /** The body of a paid answer. */
10
+ body?: string;
11
+ /** http for local E2E servers. Default: https. */
12
+ protocol?: "http:" | "https:";
13
+ now?: () => number;
14
+ }
15
+ /**
16
+ * A fake paid API. `handle` takes a Request and returns a Response, so a
17
+ * test can pass `api.fetch` to x402() and an E2E server can wrap `handle`.
18
+ * Change `price`, `override`, or `settle` between calls to test failures.
19
+ */
20
+ export declare function fakeX402Api(options: FakeX402Options): {
21
+ price: number;
22
+ /** Fields that replace the honest requirements, for example `{ network: "eip155:8453" }`. */
23
+ override: Partial<PaymentRequirements> & {
24
+ resourceUrl?: string;
25
+ };
26
+ /** "ok" settles. The others answer success with no real settlement. */
27
+ settle: "ok" | "no-transaction" | "wrong-network" | "insufficient-funds";
28
+ /** When set, the API settles the payment and then answers with this status, as a failing proxy would. */
29
+ statusAfterSettle: number | null;
30
+ /** Settle, then answer 402 with success: false, as a payee that lies would. */
31
+ refuseAfterSettle: boolean;
32
+ /** Settle, then send a body stream that fails while it is read. */
33
+ breakBody: boolean;
34
+ /** When false, the URL answers 200 with no payment. */
35
+ paid: boolean;
36
+ /** Every request: the path and the payment header, if any. */
37
+ log: {
38
+ path: string;
39
+ payment: string | null;
40
+ status: number;
41
+ }[];
42
+ /** One entry for each settled payment. */
43
+ settled: {
44
+ payer: string;
45
+ value: string;
46
+ nonce: string;
47
+ transaction: string;
48
+ }[];
49
+ requirements(): PaymentRequirements;
50
+ handle(request: Request): Promise<Response>;
51
+ fetch: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
52
+ };
@@ -0,0 +1,99 @@
1
+ // Test doubles for foxpay: a fake x402 API with a local fake facilitator.
2
+ // No chain, no money. Use them in tests and demos only.
3
+ import { keccak_256 } from "@noble/hashes/sha3.js";
4
+ import { recoverAuthorizer } from "./eip712.js";
5
+ import { BASE_SEPOLIA, decodeHeader, encodeHeader } from "./x402.js";
6
+ /**
7
+ * A fake paid API. `handle` takes a Request and returns a Response, so a
8
+ * test can pass `api.fetch` to x402() and an E2E server can wrap `handle`.
9
+ * Change `price`, `override`, or `settle` between calls to test failures.
10
+ */
11
+ export function fakeX402Api(options) {
12
+ const now = options.now ?? Date.now;
13
+ const used = new Set();
14
+ const api = {
15
+ price: options.price,
16
+ /** Fields that replace the honest requirements, for example `{ network: "eip155:8453" }`. */
17
+ override: {},
18
+ /** "ok" settles. The others answer success with no real settlement. */
19
+ settle: "ok",
20
+ /** When set, the API settles the payment and then answers with this status, as a failing proxy would. */
21
+ statusAfterSettle: null,
22
+ /** Settle, then answer 402 with success: false, as a payee that lies would. */
23
+ refuseAfterSettle: false,
24
+ /** Settle, then send a body stream that fails while it is read. */
25
+ breakBody: false,
26
+ /** When false, the URL answers 200 with no payment. */
27
+ paid: true,
28
+ /** Every request: the path and the payment header, if any. */
29
+ log: [],
30
+ /** One entry for each settled payment. */
31
+ settled: [],
32
+ requirements() {
33
+ const { resourceUrl: _, ...rest } = api.override;
34
+ return { scheme: "exact", network: BASE_SEPOLIA.network, amount: String(api.price), asset: BASE_SEPOLIA.asset, payTo: options.payTo, maxTimeoutSeconds: 60, extra: { name: BASE_SEPOLIA.name, version: BASE_SEPOLIA.version }, ...rest };
35
+ },
36
+ async handle(request) {
37
+ const url = new URL(request.url);
38
+ const payment = request.headers.get("payment-signature");
39
+ const reply = (status, body, headers = {}) => {
40
+ api.log.push({ path: url.pathname, payment, status });
41
+ return new Response(body, { status, headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "payment-required, payment-response", ...headers } });
42
+ };
43
+ if (!api.paid)
44
+ return reply(200, options.body ?? "ok");
45
+ const resource = { url: api.override.resourceUrl ?? `${options.protocol ?? "https:"}//${options.host}${url.pathname}`, description: "Fake paid API", mimeType: "text/plain" };
46
+ const required = (error) => reply(402, "{}", { "payment-required": encodeHeader({ x402Version: 2, error, resource, accepts: [api.requirements()] }) });
47
+ if (!payment)
48
+ return required("PAYMENT-SIGNATURE header is required");
49
+ const refuse = (errorReason) => reply(402, "{}", { "payment-response": encodeHeader({ success: false, errorReason, transaction: "", network: BASE_SEPOLIA.network }) });
50
+ let payload;
51
+ try {
52
+ payload = decodeHeader(payment);
53
+ }
54
+ catch {
55
+ return reply(400, "Invalid Payment");
56
+ }
57
+ // The fake facilitator: verify, then settle one time per nonce.
58
+ const want = api.requirements();
59
+ const a = payload?.payload?.authorization;
60
+ const got = payload?.accepted;
61
+ if (payload?.x402Version !== 2 || !a || !got)
62
+ return reply(400, "Invalid Payment");
63
+ if (got.network !== want.network || got.asset !== want.asset || got.payTo !== want.payTo || got.amount !== want.amount || a.value !== want.amount || a.to !== want.payTo)
64
+ return refuse("invalid_exact_evm_payload_mismatch");
65
+ const t = Math.floor(now() / 1000);
66
+ if (Number(a.validBefore) <= t || Number(a.validAfter) > t)
67
+ return refuse("invalid_exact_evm_payload_authorization_valid_before");
68
+ if (used.has(a.nonce.toLowerCase()))
69
+ return refuse("nonce_used");
70
+ if (api.settle === "insufficient-funds")
71
+ return refuse("insufficient_funds");
72
+ let payer;
73
+ try {
74
+ payer = recoverAuthorizer({ name: BASE_SEPOLIA.name, version: BASE_SEPOLIA.version, chainId: BASE_SEPOLIA.chainId, verifyingContract: want.asset }, a, payload.payload.signature);
75
+ }
76
+ catch {
77
+ return refuse("invalid_exact_evm_payload_signature");
78
+ }
79
+ if (payer !== a.from)
80
+ return refuse("invalid_exact_evm_payload_signature");
81
+ used.add(a.nonce.toLowerCase());
82
+ const transaction = `0x${Array.from(keccak_256(new TextEncoder().encode(a.nonce)), (b) => b.toString(16).padStart(2, "0")).join("")}`;
83
+ api.settled.push({ payer, value: a.value, nonce: a.nonce, transaction });
84
+ const settlement = { success: true, payer, network: api.settle === "wrong-network" ? "eip155:8453" : BASE_SEPOLIA.network, transaction: api.settle === "no-transaction" ? "" : transaction };
85
+ if (api.statusAfterSettle !== null)
86
+ return reply(api.statusAfterSettle, "error");
87
+ if (api.refuseAfterSettle)
88
+ return refuse("insufficient_funds");
89
+ if (api.breakBody) {
90
+ api.log.push({ path: url.pathname, payment, status: 200 });
91
+ const broken = new ReadableStream({ start: (c) => c.error(new Error("connection reset")) });
92
+ return new Response(broken, { status: 200, headers: { "payment-response": encodeHeader(settlement) } });
93
+ }
94
+ return reply(200, options.body ?? "ok", { "payment-response": encodeHeader(settlement) });
95
+ },
96
+ fetch: (input, init) => api.handle(new Request(input, init)),
97
+ };
98
+ return api;
99
+ }
package/dist/x402.d.ts ADDED
@@ -0,0 +1,59 @@
1
+ import type { Vault } from "foxvault";
2
+ import { type Authorization } from "./eip712.js";
3
+ import { type PayMethod } from "./intent.js";
4
+ /** The only network that foxpay pays on: Base Sepolia, a testnet, with its test USDC. */
5
+ export declare const BASE_SEPOLIA: Readonly<{
6
+ network: "eip155:84532";
7
+ chainId: 84532;
8
+ asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
9
+ name: "USDC";
10
+ version: "2";
11
+ }>;
12
+ export interface PaymentRequirements {
13
+ scheme: string;
14
+ network: string;
15
+ amount: string;
16
+ asset: string;
17
+ payTo: string;
18
+ maxTimeoutSeconds: number;
19
+ extra?: {
20
+ name?: string;
21
+ version?: string;
22
+ };
23
+ }
24
+ export interface PaymentPayload {
25
+ x402Version: 2;
26
+ resource?: {
27
+ url: string;
28
+ description?: string;
29
+ mimeType?: string;
30
+ };
31
+ accepted: PaymentRequirements;
32
+ payload: {
33
+ signature: string;
34
+ authorization: Authorization;
35
+ };
36
+ }
37
+ /** Base64 of the UTF-8 JSON, as the x402 HTTP headers carry it. */
38
+ export declare function encodeHeader(value: unknown): string;
39
+ /** The JSON in an x402 header. Throws for text that is not base64 JSON. */
40
+ export declare function decodeHeader(text: string): unknown;
41
+ export interface X402Options {
42
+ /** The foxvault vault that holds the wallet key. */
43
+ vault: Vault;
44
+ /** The handle of the wallet key: 0x and 64 hex digits. Use a testnet wallet. */
45
+ wallet: string;
46
+ /** The recipient that each merchant host must name, for example `{ "api.example.com": "0x..." }`. */
47
+ payTo: Record<string, string>;
48
+ fetch?: (input: string, init?: RequestInit) => Promise<Response>;
49
+ /** Check the settlement yourself, for example on a Base Sepolia RPC node. When it says no, the receipt is `unsettled`. */
50
+ confirm?: (settlement: {
51
+ transaction: string;
52
+ network: string;
53
+ payer: string;
54
+ }) => Promise<boolean>;
55
+ /** The longest time a signed authorization stays valid. Default: 300 seconds. */
56
+ maxTimeoutSeconds?: number;
57
+ now?: () => number;
58
+ }
59
+ export declare function x402(options: X402Options): PayMethod;
package/dist/x402.js ADDED
@@ -0,0 +1,174 @@
1
+ // x402 v2, scheme "exact", on Base Sepolia only: read the 402 answer, check
2
+ // it against what the host expects, and after approval sign one EIP-3009
3
+ // authorization with the wallet key in foxvault (docs/failure-modes.md X1-X12).
4
+ import { normalizeHost } from "foxgate";
5
+ import { addressOf, signAuthorization } from "./eip712.js";
6
+ import { PayRefusal } from "./intent.js";
7
+ /** The only network that foxpay pays on: Base Sepolia, a testnet, with its test USDC. */
8
+ export const BASE_SEPOLIA = Object.freeze({ network: "eip155:84532", chainId: 84532, asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", name: "USDC", version: "2" });
9
+ /** Base64 of the UTF-8 JSON, as the x402 HTTP headers carry it. */
10
+ export function encodeHeader(value) {
11
+ return btoa(Array.from(new TextEncoder().encode(JSON.stringify(value)), (b) => String.fromCharCode(b)).join(""));
12
+ }
13
+ /** The JSON in an x402 header. Throws for text that is not base64 JSON. */
14
+ export function decodeHeader(text) {
15
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(atob(text), (c) => c.charCodeAt(0))));
16
+ }
17
+ const plain = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
18
+ const same = (a, b) => typeof a === "string" && a.toLowerCase() === b.toLowerCase();
19
+ const loopback = (host) => host === "127.0.0.1" || host === "localhost" || host.endsWith(".localhost");
20
+ const bytes32 = () => `0x${Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) => b.toString(16).padStart(2, "0")).join("")}`;
21
+ // Why one entry of `accepts` is not the payment the host expects, or undefined when it is (X1-X3, X6).
22
+ function mismatch(r, payTo) {
23
+ if (r.scheme !== "exact")
24
+ return "scheme";
25
+ if (r.network !== BASE_SEPOLIA.network)
26
+ return "network";
27
+ if (!same(r.asset, BASE_SEPOLIA.asset))
28
+ return "asset";
29
+ if (!same(r.payTo, payTo))
30
+ return "recipient";
31
+ const extra = plain(r.extra) ? r.extra : {};
32
+ if (extra.name !== BASE_SEPOLIA.name || extra.version !== BASE_SEPOLIA.version)
33
+ return "token domain";
34
+ return undefined;
35
+ }
36
+ function target(intent) {
37
+ let url;
38
+ try {
39
+ url = new URL(String(intent.target?.url));
40
+ }
41
+ catch {
42
+ throw new PayRefusal("bad-target", "x402 needs target.url.");
43
+ }
44
+ // Plain http only to this machine, for local tests.
45
+ const ok = url.protocol === "https:" || (url.protocol === "http:" && loopback(url.hostname));
46
+ if (!ok || normalizeHost(url.hostname) !== intent.merchant)
47
+ throw new PayRefusal("merchant-mismatch", `The URL is not an https URL on ${intent.merchant}.`);
48
+ return url;
49
+ }
50
+ export function x402(options) {
51
+ const { vault, wallet } = options;
52
+ const http = options.fetch ?? ((input, init) => fetch(input, init));
53
+ const now = options.now ?? Date.now;
54
+ const maxTimeout = options.maxTimeoutSeconds ?? 300;
55
+ async function quote(intent) {
56
+ const url = target(intent);
57
+ const payTo = Object.hasOwn(options.payTo, intent.merchant) ? options.payTo[intent.merchant] : undefined;
58
+ if (!payTo)
59
+ throw new PayRefusal("no-recipient", `No recipient is set for ${intent.merchant}.`);
60
+ if (!(await vault.list()).some((s) => s.handle === wallet))
61
+ throw new PayRefusal("no-wallet", "The vault does not hold the wallet key.");
62
+ // No redirects: a signed payload must go to the approved URL only (X15).
63
+ const res = await http(url.href, { redirect: "manual" }).catch(() => undefined);
64
+ if (res?.status !== 402)
65
+ throw new PayRefusal("not-402", "The URL did not answer 402 Payment Required.");
66
+ let required;
67
+ try {
68
+ required = decodeHeader(res.headers.get("payment-required") ?? "");
69
+ }
70
+ catch {
71
+ throw new PayRefusal("bad-requirements", "The PAYMENT-REQUIRED header is not base64 JSON.");
72
+ }
73
+ if (!plain(required) || required.x402Version !== 2 || !Array.isArray(required.accepts))
74
+ throw new PayRefusal("bad-requirements", "The 402 answer is not x402 version 2.");
75
+ const resource = plain(required.resource) ? required.resource : {};
76
+ let resourceHost = "";
77
+ try {
78
+ resourceHost = normalizeHost(new URL(String(resource.url)).hostname);
79
+ }
80
+ catch {
81
+ throw new PayRefusal("bad-requirements", "The 402 answer has no resource URL.");
82
+ }
83
+ if (resourceHost !== intent.merchant)
84
+ throw new PayRefusal("merchant-mismatch", `The 402 resource is on ${resourceHost}, not ${intent.merchant}.`);
85
+ const accepts = required.accepts.filter(plain);
86
+ const fits = accepts.find((r) => mismatch(r, payTo) === undefined);
87
+ if (!fits)
88
+ throw new PayRefusal("requirements-mismatch", `The 402 answer asks for another ${accepts.map((r) => mismatch(r, payTo)).join(", ") || "payment"} than foxpay expects.`);
89
+ const amount = typeof fits.amount === "string" && /^[1-9]\d*$/.test(fits.amount) ? Number(fits.amount) : NaN;
90
+ const timeout = Number(fits.maxTimeoutSeconds);
91
+ if (!Number.isSafeInteger(amount) || !Number.isSafeInteger(timeout) || timeout <= 0)
92
+ throw new PayRefusal("bad-requirements", "The 402 amount or timeout is not a whole number that fits.");
93
+ const accepted = { scheme: "exact", network: BASE_SEPOLIA.network, amount: fits.amount, asset: fits.asset, payTo: fits.payTo, maxTimeoutSeconds: timeout, extra: { name: BASE_SEPOLIA.name, version: BASE_SEPOLIA.version } };
94
+ const hold = { url: url.href, resource: { url: String(resource.url) }, accepted };
95
+ return { amount, currency: "USDC", payee: `${accepted.payTo} on ${BASE_SEPOLIA.network}`, details: { url: url.href, resource: String(resource.url) }, hold };
96
+ }
97
+ // Before foxgate uses the token: the vault is open, and the 402 still asks for the approved payment (X12, X16).
98
+ async function check({ intent, quote: q }) {
99
+ if ((await vault.status()) !== "unlocked" && !(await vault.unlock().then(() => true, () => false)))
100
+ return "locked";
101
+ const { accepted } = q.hold;
102
+ try {
103
+ const now402 = await quote(intent);
104
+ const fresh = now402.hold.accepted;
105
+ return fresh.amount === accepted.amount && same(fresh.payTo, accepted.payTo) && same(fresh.asset, accepted.asset) ? undefined : "amount-changed";
106
+ }
107
+ catch (error) {
108
+ if (error instanceof PayRefusal)
109
+ return error.reason === "requirements-mismatch" ? "amount-changed" : error.reason;
110
+ throw error;
111
+ }
112
+ }
113
+ async function pay({ quote: q }) {
114
+ // Sign exactly the approved requirements, never fresh ones (I16, X10).
115
+ const { url, resource, accepted } = q.hold;
116
+ const t = Math.floor(now() / 1000);
117
+ const nonce = bytes32();
118
+ const domain = { name: BASE_SEPOLIA.name, version: BASE_SEPOLIA.version, chainId: BASE_SEPOLIA.chainId, verifyingContract: accepted.asset };
119
+ let signed;
120
+ try {
121
+ // The key exists only inside this function (X11).
122
+ signed = await vault.use(wallet, (key) => {
123
+ const authorization = { from: addressOf(key), to: accepted.payTo, value: accepted.amount, validAfter: String(t - 600), validBefore: String(t + Math.min(accepted.maxTimeoutSeconds, maxTimeout)), nonce };
124
+ return { authorization, signature: signAuthorization(key, domain, authorization) };
125
+ });
126
+ }
127
+ catch {
128
+ return { status: "failed", reason: "sign-error" };
129
+ }
130
+ const proof = { network: BASE_SEPOLIA.network, payer: signed.authorization.from, nonce };
131
+ const payload = { x402Version: 2, resource, accepted, payload: signed };
132
+ // One send per payload. foxpay never sends it again (X8).
133
+ const res = await http(url, { headers: { "payment-signature": encodeHeader(payload) }, redirect: "manual" }).catch(() => undefined);
134
+ if (!res)
135
+ return { status: "unsettled", reason: "no-response", proof };
136
+ // From here the payee holds a valid authorization until validBefore, so
137
+ // nothing below can prove that no money moved: the worst answer is
138
+ // "unsettled", never "failed" (X10, X14, X17, X18).
139
+ try {
140
+ return await outcome(res, proof);
141
+ }
142
+ catch {
143
+ return { status: "unsettled", reason: "read-error", proof };
144
+ }
145
+ }
146
+ async function outcome(res, proof) {
147
+ let settlement = {};
148
+ try {
149
+ const decoded = decodeHeader(res.headers.get("payment-response") ?? "");
150
+ if (plain(decoded))
151
+ settlement = decoded;
152
+ }
153
+ catch {
154
+ // No settlement header: handled below.
155
+ }
156
+ if (res.status < 200 || res.status > 299) {
157
+ // A refusal from the payee is its claim, not proof of no payment.
158
+ const why = typeof settlement.errorReason === "string" && /^[a-z0-9_]{1,64}$/.test(settlement.errorReason) ? settlement.errorReason : `http-${res.status}`;
159
+ return { status: "unsettled", reason: why, proof };
160
+ }
161
+ // The settlement header first: a body that fails to read does not hide a payment (X17).
162
+ const body = await res.text().catch(() => undefined);
163
+ const extra = body === undefined ? {} : { body };
164
+ const transaction = settlement.transaction;
165
+ const settled = settlement.success === true && typeof transaction === "string" && /^0x[0-9a-fA-F]{64}$/.test(transaction) && settlement.network === BASE_SEPOLIA.network;
166
+ if (!settled)
167
+ return { status: "unsettled", reason: "no-settlement", proof, ...extra };
168
+ const full = { ...proof, transaction: transaction };
169
+ if (options.confirm && !(await options.confirm(full).catch(() => false)))
170
+ return { status: "unsettled", reason: "not-confirmed", proof: full, ...extra };
171
+ return { status: "paid", proof: full, ...extra };
172
+ }
173
+ return { quote, check, pay };
174
+ }