@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/card.js ADDED
@@ -0,0 +1,241 @@
1
+ // Card fill: read the checkout total from the merchant page, then fill a
2
+ // card from foxvault into the top document and submit, after approval
3
+ // (docs/failure-modes.md C1-C10).
4
+ import { matchesPattern, normalizeHost, parsePattern } from "foxgate";
5
+ import { PayRefusal } from "./intent.js";
6
+ // The page functions below run in the merchant page through
7
+ // scripting.executeScript. Firefox sends them as source text, so each one
8
+ // must not use anything from outside its own body.
9
+ export function readTotal(selector, host) {
10
+ if (location.hostname !== host)
11
+ return null;
12
+ return document.querySelector(selector)?.textContent ?? null;
13
+ }
14
+ export function fillDetails(expSelector, cvcSelector, exp, cvc, host) {
15
+ if (location.hostname !== host)
16
+ return "host-changed";
17
+ const exp1 = document.querySelector(expSelector);
18
+ const cvc1 = document.querySelector(cvcSelector);
19
+ if (!(exp1 instanceof HTMLInputElement) || !(cvc1 instanceof HTMLInputElement))
20
+ return "not-found";
21
+ for (const [field, value] of [[exp1, exp], [cvc1, cvc]]) {
22
+ field.focus();
23
+ field.value = value;
24
+ field.dispatchEvent(new Event("input", { bubbles: true }));
25
+ field.dispatchEvent(new Event("change", { bubbles: true }));
26
+ }
27
+ return "filled";
28
+ }
29
+ export function submitCheckout(submitSelector, totalSelector, totalText, host) {
30
+ if (location.hostname !== host)
31
+ return "host-changed";
32
+ if ((document.querySelector(totalSelector)?.textContent ?? null) !== totalText)
33
+ return "amount-changed";
34
+ const button = document.querySelector(submitSelector);
35
+ if (!(button instanceof HTMLElement))
36
+ return "not-found";
37
+ button.click();
38
+ return "submitted";
39
+ }
40
+ export function clearFields(selectors, host) {
41
+ if (location.hostname !== host)
42
+ return "host-changed";
43
+ for (const selector of selectors) {
44
+ const field = document.querySelector(selector);
45
+ if (!(field instanceof HTMLInputElement))
46
+ continue;
47
+ field.value = "";
48
+ field.dispatchEvent(new Event("input", { bubbles: true }));
49
+ field.dispatchEvent(new Event("change", { bubbles: true }));
50
+ }
51
+ return "cleared";
52
+ }
53
+ /** Whole minor units from a total such as `Total $26.00`, or undefined when the text has not exactly one amount. */
54
+ export function parseTotal(text, decimals = 2) {
55
+ const found = (text ?? "").match(/\d[\d,]*(?:\.\d+)?/g);
56
+ if (!found || found.length !== 1)
57
+ return undefined;
58
+ const [whole = "", frac = ""] = found[0].replace(/,/g, "").split(".");
59
+ if (frac.length > decimals)
60
+ return undefined;
61
+ const value = Number(whole) * 10 ** decimals + Number(frac.padEnd(decimals, "0") || "0");
62
+ return Number.isSafeInteger(value) && value > 0 ? value : undefined;
63
+ }
64
+ const allows = (domains, host) => domains.some((d) => {
65
+ try {
66
+ return matchesPattern(host, parsePattern(d));
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ });
72
+ export function cardFill(options) {
73
+ const { vault, browser, card, provider, fields, currency } = options;
74
+ if (!card === !provider)
75
+ throw new TypeError("cardFill needs exactly one of card and provider.");
76
+ const decimals = options.decimals ?? 2;
77
+ const waitMs = options.waitMs ?? 10_000;
78
+ // The top document of the tab, when it is the merchant page.
79
+ async function topPage(tabId, merchant) {
80
+ const frame = await browser.webNavigation.getFrame({ tabId, frameId: 0 }).catch(() => undefined);
81
+ if (!frame)
82
+ throw new PayRefusal("no-tab", "The tab is not there.");
83
+ let url;
84
+ try {
85
+ url = new URL(frame.url);
86
+ }
87
+ catch {
88
+ throw new PayRefusal("merchant-mismatch", "The tab does not show a web page.");
89
+ }
90
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && options.allowHttp))
91
+ throw new PayRefusal(url.protocol === "http:" ? "http" : "merchant-mismatch", "The checkout page must use https.");
92
+ // URL gives the punycode host, so a Unicode look-alike is a different host (C2).
93
+ if (normalizeHost(url.hostname) !== merchant)
94
+ throw new PayRefusal("merchant-mismatch", `The tab shows ${url.hostname}, not ${merchant}.`);
95
+ if (!frame.documentId)
96
+ throw new PayRefusal("page-changed", "The tab has no document id.");
97
+ return { url: url.href, host: url.hostname, documentId: frame.documentId };
98
+ }
99
+ const run = async (tabId, documentId, func, args) => {
100
+ try {
101
+ return (await browser.scripting.executeScript({ target: { tabId, documentIds: [documentId] }, func, args }))[0]?.result;
102
+ }
103
+ catch {
104
+ throw new PayRefusal("page-changed", "The checkout page changed.");
105
+ }
106
+ };
107
+ const total = async (tabId, page) => {
108
+ const text = await run(tabId, page.documentId, readTotal, [options.total, page.host]);
109
+ const value = parseTotal(typeof text === "string" ? text : null, decimals);
110
+ if (value === undefined)
111
+ throw new PayRefusal("no-total", "The page has no total that foxpay can read.");
112
+ return { text: text, value };
113
+ };
114
+ // The same page and the same total as at quote time (C3, C4).
115
+ async function samePage(intent, hold) {
116
+ const page = await topPage(hold.tabId, intent.merchant);
117
+ if (page.documentId !== hold.documentId)
118
+ throw new PayRefusal("page-changed", "The tab shows a new page since the approval.");
119
+ if ((await total(hold.tabId, page)).text !== hold.totalText)
120
+ throw new PayRefusal("amount-changed", "The checkout total changed since the approval.");
121
+ return page;
122
+ }
123
+ async function quote(intent) {
124
+ const tabId = intent.target?.tabId;
125
+ if (!Number.isSafeInteger(tabId) || tabId < 0)
126
+ throw new PayRefusal("bad-target", "Card fill needs target.tabId.");
127
+ const page = await topPage(tabId, intent.merchant);
128
+ if (card) {
129
+ const stored = (await vault.list()).filter((s) => s.handle === card.number || s.handle === card.details);
130
+ if (stored.length !== 2)
131
+ throw new PayRefusal("no-card", "The vault does not hold the card.");
132
+ if (!stored.every((s) => allows(s.domains, page.host)))
133
+ throw new PayRefusal("domain", `The stored card does not allow ${page.host}.`);
134
+ }
135
+ const { text, value } = await total(tabId, page);
136
+ const hold = { tabId: tabId, documentId: page.documentId, totalText: text };
137
+ return { amount: value, currency, payee: page.host, hold };
138
+ }
139
+ async function check({ intent, quote: q }) {
140
+ try {
141
+ await samePage(intent, q.hold);
142
+ }
143
+ catch (error) {
144
+ if (error instanceof PayRefusal)
145
+ return error.reason;
146
+ throw error;
147
+ }
148
+ if ((await vault.status()) === "unlocked")
149
+ return undefined;
150
+ // Device mode unlocks with no passphrase. Passphrase mode stays locked (I17).
151
+ return vault.unlock().then(() => undefined, () => "locked");
152
+ }
153
+ async function fill(intent, hold, handles, state) {
154
+ // The page can change after the check. Check it again before the number goes out (C11),
155
+ // and pin the foxvault fill to the checked document, so a later load gets nothing (C16).
156
+ await samePage(intent, hold);
157
+ // oxlint-disable-next-line unicorn/no-array-fill-with-reference-type -- this is foxvault fill, not Array#fill
158
+ const filled = await vault.fill({ handle: handles.number, tabId: hold.tabId, selector: fields.number, documentId: hold.documentId });
159
+ if (filled.status !== "filled")
160
+ return filled.status === "refused" ? filled.reason : "fill-asks";
161
+ state.filled = true;
162
+ const page = await samePage(intent, hold);
163
+ const details = await vault.use(handles.details, async (value, info) => {
164
+ if (!allows(info.domains, page.host))
165
+ return "domain";
166
+ const [exp = "", cvc = ""] = value.split(" ");
167
+ return run(hold.tabId, hold.documentId, fillDetails, [fields.exp, fields.cvc, exp, cvc, page.host]);
168
+ });
169
+ if (details !== "filled")
170
+ return String(details);
171
+ // Once the submit script starts, a lost result can still mean a placed order (C13).
172
+ const submitted = await run(hold.tabId, hold.documentId, submitCheckout, [options.submit, options.total, hold.totalText, page.host]).catch(() => "submit-unknown");
173
+ return String(submitted);
174
+ }
175
+ // The page after submit: wait until the tab has a new document.
176
+ async function after(hold) {
177
+ for (let waited = 0; waited <= waitMs; waited += 100) {
178
+ const frame = await browser.webNavigation.getFrame({ tabId: hold.tabId, frameId: 0 }).catch(() => undefined);
179
+ if (frame && frame.documentId !== hold.documentId)
180
+ return frame.url;
181
+ await new Promise((r) => setTimeout(r, 100));
182
+ }
183
+ return undefined;
184
+ }
185
+ async function pay({ id, intent, quote: q }) {
186
+ const hold = q.hold;
187
+ let handles = card;
188
+ let proof = {};
189
+ let vcard;
190
+ if (provider) {
191
+ let made;
192
+ try {
193
+ made = await provider.createCard({ intentId: id, merchant: intent.merchant, amount: { value: q.amount, currency: q.currency } });
194
+ }
195
+ catch {
196
+ return { status: "failed", reason: "provider-error" };
197
+ }
198
+ const name = `vault:fpy-vc-${crypto.randomUUID().slice(0, 8)}`;
199
+ handles = { number: name, details: `${name}.details` };
200
+ proof = { last4: made.number.slice(-4), card: made.id };
201
+ vcard = made;
202
+ }
203
+ else {
204
+ proof = { last4: await vault.use(card.number, (value) => value.slice(-4)), card: card.number };
205
+ }
206
+ const state = { filled: false };
207
+ let failed;
208
+ try {
209
+ if (vcard) {
210
+ // Inside the try, so a half-stored card is removed too (C12).
211
+ await vault.set(handles.number, vcard.number, { domains: [q.payee], allowHttp: options.allowHttp ?? false });
212
+ await vault.set(handles.details, `${vcard.exp} ${vcard.cvc}`, { domains: [q.payee], allowHttp: options.allowHttp ?? false });
213
+ }
214
+ const result = await fill(intent, hold, handles, state);
215
+ if (result === "submitted") {
216
+ const page = await after(hold);
217
+ return { status: "submitted", proof: { ...proof, ...(page && { page }) } };
218
+ }
219
+ if (result === "submit-unknown")
220
+ return { status: "unsettled", reason: result, proof };
221
+ failed = { status: "failed", reason: result, proof };
222
+ }
223
+ catch (error) {
224
+ const reason = error instanceof PayRefusal ? error.reason : error.code === "bad-value" ? "provider-card" : "fill-error";
225
+ failed = { status: "failed", reason, proof };
226
+ }
227
+ finally {
228
+ // A virtual card is for this payment only (C10).
229
+ if (provider)
230
+ for (const handle of [handles.number, handles.details])
231
+ await vault.remove(handle).catch(() => false);
232
+ }
233
+ // Nothing was submitted: take the card out of the form, and close a virtual card (C14, C15).
234
+ if (state.filled)
235
+ await run(hold.tabId, hold.documentId, clearFields, [[fields.number, fields.exp, fields.cvc], q.payee]).catch(() => undefined);
236
+ if (vcard)
237
+ await provider.cancelCard?.(vcard.id).catch(() => undefined);
238
+ return failed;
239
+ }
240
+ return { quote, check, pay };
241
+ }
@@ -0,0 +1,26 @@
1
+ /** The token contract that checks the signature. For USDC on Base Sepolia: name "USDC", version "2", chainId 84532. */
2
+ export interface TokenDomain {
3
+ name: string;
4
+ version: string;
5
+ chainId: number;
6
+ verifyingContract: string;
7
+ }
8
+ /** EIP-3009 TransferWithAuthorization. Numbers are decimal strings, as in x402. */
9
+ export interface Authorization {
10
+ from: string;
11
+ to: string;
12
+ value: string;
13
+ validAfter: string;
14
+ validBefore: string;
15
+ nonce: string;
16
+ }
17
+ /** The EIP-55 mixed-case form of an address. */
18
+ export declare function checksumAddress(value: string): string;
19
+ /** The 32-byte EIP-712 digest that the wallet signs. */
20
+ export declare function authorizationDigest(domain: TokenDomain, auth: Authorization): Uint8Array;
21
+ /** The address of a wallet key. */
22
+ export declare function addressOf(privateKey: string): string;
23
+ /** Sign the authorization. Returns 0x and 130 hex digits: r, s, v. */
24
+ export declare function signAuthorization(privateKey: string, domain: TokenDomain, auth: Authorization): string;
25
+ /** The address that signed the authorization. Throws for a signature that is not valid, also for a high s. */
26
+ export declare function recoverAuthorizer(domain: TokenDomain, auth: Authorization, signature: string): string;
package/dist/eip712.js ADDED
@@ -0,0 +1,96 @@
1
+ // The minimal EIP-712 signing that x402 "exact" on EVM needs: one struct,
2
+ // EIP-3009 TransferWithAuthorization, signed with secp256k1 (failure mode X13).
3
+ // It uses @noble/curves and @noble/hashes (MIT, audited) and nothing else.
4
+ //
5
+ // digest = keccak256(0x19 0x01 || domainSeparator || structHash)
6
+ // domainSeparator = keccak256(abi.encode(DOMAIN_TYPEHASH, keccak256(name), keccak256(version), chainId, verifyingContract))
7
+ // structHash = keccak256(abi.encode(TYPEHASH, from, to, value, validAfter, validBefore, nonce))
8
+ // The signature is r || s || v, with v = 27 + recovery bit and a low s.
9
+ import { secp256k1 } from "@noble/curves/secp256k1.js";
10
+ import { keccak_256 } from "@noble/hashes/sha3.js";
11
+ const text = new TextEncoder();
12
+ const keccak = (bytes) => keccak_256(bytes);
13
+ const DOMAIN_TYPEHASH = keccak(text.encode("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"));
14
+ const TYPEHASH = keccak(text.encode("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"));
15
+ const MAX_UINT = 2n ** 256n - 1n;
16
+ // Errors name the field, never its value, so a key cannot end up in a message.
17
+ function bytesOf(hex, length, field) {
18
+ if (typeof hex !== "string" || !new RegExp(`^0x[0-9a-fA-F]{${length * 2}}$`).test(hex))
19
+ throw new TypeError(`${field} must be 0x and ${length * 2} hex digits.`);
20
+ return Uint8Array.from(hex.slice(2).match(/../g), (b) => parseInt(b, 16));
21
+ }
22
+ function word(value) {
23
+ const out = new Uint8Array(32);
24
+ for (let i = 31, v = value; i >= 0; i--, v >>= 8n)
25
+ out[i] = Number(v & 0xffn);
26
+ return out;
27
+ }
28
+ function uint(value, field) {
29
+ if (typeof value !== "string" || !/^(0|[1-9]\d{0,77})$/.test(value) || BigInt(value) > MAX_UINT)
30
+ throw new TypeError(`${field} must be a decimal uint256.`);
31
+ return word(BigInt(value));
32
+ }
33
+ const address = (value, field) => {
34
+ const out = new Uint8Array(32);
35
+ out.set(bytesOf(value, 20, field), 12);
36
+ return out;
37
+ };
38
+ function concat(...parts) {
39
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
40
+ let at = 0;
41
+ for (const p of parts) {
42
+ out.set(p, at);
43
+ at += p.length;
44
+ }
45
+ return out;
46
+ }
47
+ const toHex = (bytes) => `0x${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
48
+ /** The EIP-55 mixed-case form of an address. */
49
+ export function checksumAddress(value) {
50
+ const lower = toHex(bytesOf(value, 20, "address")).slice(2);
51
+ const hash = toHex(keccak(text.encode(lower))).slice(2);
52
+ return `0x${[...lower].map((c, i) => (parseInt(hash[i], 16) >= 8 ? c.toUpperCase() : c)).join("")}`;
53
+ }
54
+ function domainSeparator(domain) {
55
+ if (!Number.isSafeInteger(domain.chainId) || domain.chainId <= 0)
56
+ throw new TypeError("chainId must be a positive whole number.");
57
+ if (typeof domain.name !== "string" || typeof domain.version !== "string")
58
+ throw new TypeError("name and version must be strings.");
59
+ return keccak(concat(DOMAIN_TYPEHASH, keccak(text.encode(domain.name)), keccak(text.encode(domain.version)), word(BigInt(domain.chainId)), address(domain.verifyingContract, "verifyingContract")));
60
+ }
61
+ /** The 32-byte EIP-712 digest that the wallet signs. */
62
+ export function authorizationDigest(domain, auth) {
63
+ const struct = keccak(concat(TYPEHASH, address(auth.from, "from"), address(auth.to, "to"), uint(auth.value, "value"), uint(auth.validAfter, "validAfter"), uint(auth.validBefore, "validBefore"), bytesOf(auth.nonce, 32, "nonce")));
64
+ return keccak(concat(Uint8Array.of(0x19, 0x01), domainSeparator(domain), struct));
65
+ }
66
+ function secretKey(privateKey) {
67
+ const key = bytesOf(privateKey, 32, "The wallet key");
68
+ if (!secp256k1.utils.isValidSecretKey(key))
69
+ throw new TypeError("The wallet key is not a valid secp256k1 key.");
70
+ return key;
71
+ }
72
+ const addressOfPublic = (publicKey) => checksumAddress(toHex(keccak(publicKey.slice(1)).slice(12)));
73
+ /** The address of a wallet key. */
74
+ export function addressOf(privateKey) {
75
+ return addressOfPublic(secp256k1.getPublicKey(secretKey(privateKey), false));
76
+ }
77
+ /** Sign the authorization. Returns 0x and 130 hex digits: r, s, v. */
78
+ export function signAuthorization(privateKey, domain, auth) {
79
+ const digest = authorizationDigest(domain, auth);
80
+ const sig = secp256k1.sign(digest, secretKey(privateKey), { prehash: false, lowS: true, format: "recovered" });
81
+ return toHex(concat(sig.slice(1), Uint8Array.of(27 + sig[0])));
82
+ }
83
+ /** The address that signed the authorization. Throws for a signature that is not valid, also for a high s. */
84
+ export function recoverAuthorizer(domain, auth, signature) {
85
+ const sig = bytesOf(signature, 65, "signature");
86
+ const v = sig[64];
87
+ if (v !== 27 && v !== 28)
88
+ throw new TypeError("v must be 27 or 28.");
89
+ const digest = authorizationDigest(domain, auth);
90
+ const recovered = concat(Uint8Array.of(v - 27), sig.slice(0, 64));
91
+ const publicKey = secp256k1.recoverPublicKey(recovered, digest, { prehash: false });
92
+ // recoverPublicKey accepts a high s. EIP-2 and USDC do not, so refuse it here.
93
+ if (!secp256k1.verify(sig.slice(0, 64), digest, publicKey, { prehash: false, lowS: true }))
94
+ throw new TypeError("The signature is not valid.");
95
+ return addressOfPublic(secp256k1.Point.fromBytes(publicKey).toBytes(false));
96
+ }
@@ -0,0 +1,8 @@
1
+ export { GATE_CURRENCY, PAY_TOOL, PayRefusal, createFoxpay, payTools } from "./intent.js";
2
+ export type { CompleteResult, Foxpay, FoxpayOptions, Intent, PayContext, PayEvent, PayMethod, PayOutcome, PayStatus, Quote, Receipt, RequestResult } from "./intent.js";
3
+ export { cardFill } from "./card.js";
4
+ export type { CardBrowser, CardFillOptions, VirtualCardProvider } from "./card.js";
5
+ export { addressOf, authorizationDigest, checksumAddress, recoverAuthorizer, signAuthorization } from "./eip712.js";
6
+ export type { Authorization, TokenDomain } from "./eip712.js";
7
+ export { BASE_SEPOLIA, decodeHeader, encodeHeader, x402 } from "./x402.js";
8
+ export type { PaymentPayload, PaymentRequirements, X402Options } from "./x402.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // The public API of foxpay.
2
+ export { GATE_CURRENCY, PAY_TOOL, PayRefusal, createFoxpay, payTools } from "./intent.js";
3
+ export { cardFill } from "./card.js";
4
+ export { addressOf, authorizationDigest, checksumAddress, recoverAuthorizer, signAuthorization } from "./eip712.js";
5
+ export { BASE_SEPOLIA, decodeHeader, encodeHeader, x402 } from "./x402.js";
@@ -0,0 +1,134 @@
1
+ import { type Gate, type Store, type ToolSpec } from "foxgate";
2
+ /** The foxgate tool name of every payment. Register it with `payTools()`. */
3
+ export declare const PAY_TOOL = "foxpay.pay";
4
+ /**
5
+ * foxgate takes 3-letter currency codes. Test USDC on Base Sepolia counts as
6
+ * XTS, the ISO 4217 code for testing, in whole atomic units (6 decimals).
7
+ */
8
+ export declare const GATE_CURRENCY: Readonly<Record<string, string>>;
9
+ /** The foxgate tool registry for foxpay. The amount comes from the quote, which foxpay puts in the args. */
10
+ export declare function payTools(): Record<string, ToolSpec>;
11
+ /** One wish to pay, from the planner. */
12
+ export interface Intent {
13
+ /** The merchant host, for example `shop.example.com`. */
14
+ merchant: string;
15
+ /** Whole minor units: cents for USD, atomic units for USDC. */
16
+ amount: number;
17
+ currency: string;
18
+ /** Why the agent pays. The human sees it. 1 to 200 characters. */
19
+ reason: string;
20
+ /** The name of a method in `createFoxpay({ methods })`. */
21
+ method: string;
22
+ /** The same key for every retry of this payment. 8 to 128 characters. */
23
+ idempotencyKey: string;
24
+ /** What the method needs to find the payment: `{ tabId }` for card fill, `{ url }` for x402. */
25
+ target?: Record<string, unknown>;
26
+ }
27
+ /** What the host reads itself, from the merchant page or the 402 answer. */
28
+ export interface Quote {
29
+ amount: number;
30
+ currency: string;
31
+ /** Who gets the money, as the human sees it. */
32
+ payee: string;
33
+ /** More facts that the human must see, for example the URL that a payment is for. The token binds them. */
34
+ details?: Record<string, string>;
35
+ /** Plain JSON that the method keeps for check and pay. It goes into the store, so never a secret. */
36
+ hold?: unknown;
37
+ }
38
+ export interface PayContext {
39
+ id: string;
40
+ intent: Intent;
41
+ quote: Quote;
42
+ }
43
+ export type PayStatus = "paid" | "submitted" | "unsettled" | "failed";
44
+ export interface PayOutcome {
45
+ status: PayStatus;
46
+ reason?: string;
47
+ /** Plain JSON, never a secret: last 4 digits, a transaction hash, a page URL. */
48
+ proof?: Record<string, unknown>;
49
+ /** The response body, for x402. It is not stored. */
50
+ body?: string;
51
+ }
52
+ /**
53
+ * A way to pay. Throw `PayRefusal` to refuse with a reason. From `pay`, throw
54
+ * it only before any money can move: foxpay records it as `failed`. Any other
55
+ * error from `pay` is recorded as `unsettled`.
56
+ */
57
+ export interface PayMethod {
58
+ quote(intent: Intent): Promise<Quote>;
59
+ /** Runs before foxgate uses the token. Return a reason to stop, for example `amount-changed` or `locked`. */
60
+ check?(context: PayContext): Promise<string | undefined>;
61
+ pay(context: PayContext): Promise<PayOutcome>;
62
+ }
63
+ /** Throw it from a method to refuse with a reason that callers can switch on. From `pay`, only before any money can move. */
64
+ export declare class PayRefusal extends Error {
65
+ readonly reason: string;
66
+ constructor(reason: string, message?: string);
67
+ }
68
+ export interface Receipt {
69
+ id: string;
70
+ merchant: string;
71
+ amount: number;
72
+ currency: string;
73
+ reason: string;
74
+ method: string;
75
+ payee: string;
76
+ status: PayStatus;
77
+ failure?: string;
78
+ requestId?: string;
79
+ paidAt: number;
80
+ proof: Record<string, unknown>;
81
+ }
82
+ /** A foxtrail-compatible entry: pass `log.append` as `onEvent`. */
83
+ export interface PayEvent {
84
+ actor: "foxpay";
85
+ kind: "pay.request" | "pay.ask" | "pay.refused" | "pay.start" | "pay.approved" | "pay.result";
86
+ data: Record<string, unknown>;
87
+ }
88
+ export type RequestResult = {
89
+ status: "ask";
90
+ id: string;
91
+ requestId: string;
92
+ } | {
93
+ status: "refused";
94
+ id?: string;
95
+ reason: string;
96
+ message: string;
97
+ } | {
98
+ status: PayStatus;
99
+ id: string;
100
+ receipt: Receipt;
101
+ body?: string;
102
+ };
103
+ export type CompleteResult = Exclude<RequestResult, {
104
+ status: "ask";
105
+ }>;
106
+ export interface FoxpayOptions {
107
+ /** A foxgate gate made with `payTools()`. */
108
+ gate: Gate;
109
+ methods: Record<string, PayMethod>;
110
+ /** Where intents and receipts live. Use the same store as foxgate. */
111
+ store: Store;
112
+ onEvent?: (event: PayEvent) => void | Promise<void>;
113
+ now?: () => number;
114
+ }
115
+ export declare function createFoxpay(options: FoxpayOptions): Readonly<{
116
+ /** Check an intent, quote it, and ask foxgate. A retry with the same key gives the same answer. */
117
+ request: (input: unknown) => Promise<RequestResult>;
118
+ /** Pay an approved intent with the foxgate token. It pays one time; later calls give the receipt. */
119
+ complete: (id: string, token: string) => Promise<CompleteResult>;
120
+ /** Every intent that waits, runs, or ran, with its quote and status. */
121
+ intents: () => Promise<{
122
+ id: string;
123
+ merchant: string;
124
+ amount: number;
125
+ currency: string;
126
+ reason: string;
127
+ method: string;
128
+ payee: string;
129
+ status: "awaiting" | "paying" | PayStatus;
130
+ requestId?: string | undefined;
131
+ }[]>;
132
+ receipts: () => Promise<Receipt[]>;
133
+ }>;
134
+ export type Foxpay = ReturnType<typeof createFoxpay>;