@profullstack/coinpay 0.6.10 → 0.8.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.
@@ -0,0 +1,272 @@
1
+ /**
2
+ * A one-shot local payment page, for paying an x402 invoice from the CLI.
3
+ *
4
+ * A terminal cannot sign with a browser wallet: the extension lives in the
5
+ * browser process and exposes itself only to pages. So the CLI serves a page on
6
+ * loopback, opens it, and waits. The page signs with CoinPay Wallet (or
7
+ * MetaMask, or anything else injected), posts the `X-PAYMENT` header back, and
8
+ * the CLI retries the original request with it.
9
+ *
10
+ * Security posture, because this is a local server that can spend money:
11
+ * * bound to 127.0.0.1, never a routable interface
12
+ * * every request must carry a single-use token from the URL, so another
13
+ * process on the box cannot drive it or read the invoice
14
+ * * one payment, then the server closes — it is not a standing service
15
+ * * the private key never comes near this process; it stays in the wallet
16
+ *
17
+ * @module x402-pay-server
18
+ */
19
+
20
+ import { createServer } from 'node:http';
21
+ import { randomBytes } from 'node:crypto';
22
+ import { readFile } from 'node:fs/promises';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { dirname, join } from 'node:path';
25
+
26
+ const HERE = dirname(fileURLToPath(import.meta.url));
27
+
28
+ /** SDK modules the page imports. Served from disk so the page can `import` them. */
29
+ const MODULES = {
30
+ '/x402-browser.js': join(HERE, 'x402-browser.js'),
31
+ '/x402-v2.js': join(HERE, 'x402-v2.js'),
32
+ };
33
+
34
+ function renderPage() {
35
+ // Kept dependency-free and inline: this page is served from a temporary
36
+ // loopback server, so anything external would simply fail to load.
37
+ return `<!doctype html>
38
+ <html lang="en">
39
+ <head>
40
+ <meta charset="utf-8">
41
+ <meta name="viewport" content="width=device-width,initial-scale=1">
42
+ <title>CoinPay — Approve payment</title>
43
+ <style>
44
+ :root { color-scheme: light dark; --bg:#faf9f7; --fg:#1a1a1a; --muted:#6b6b6b; --card:#fff; --line:#e6e3de; --accent:#2f6f4f; }
45
+ @media (prefers-color-scheme: dark) {
46
+ :root { --bg:#141414; --fg:#f2f2f2; --muted:#9a9a9a; --card:#1e1e1e; --line:#333; --accent:#5ea37a; }
47
+ }
48
+ * { box-sizing: border-box; }
49
+ body { margin:0; min-height:100vh; display:grid; place-items:center; padding:24px;
50
+ background:var(--bg); color:var(--fg);
51
+ font:16px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif; }
52
+ .card { width:100%; max-width:420px; background:var(--card); border:1px solid var(--line);
53
+ border-radius:14px; padding:28px; }
54
+ h1 { margin:0 0 4px; font-size:19px; }
55
+ .sub { color:var(--muted); font-size:14px; margin:0 0 20px; }
56
+ dl { display:grid; grid-template-columns:auto 1fr; gap:10px 16px; margin:0 0 22px;
57
+ padding:16px 0; border-top:1px solid var(--line); border-bottom:1px solid var(--line); }
58
+ dt { color:var(--muted); font-size:14px; }
59
+ dd { margin:0; text-align:right; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
60
+ .amount { font-size:22px; font-weight:600; }
61
+ button { width:100%; padding:13px; font-size:15px; font-weight:600; border:0; border-radius:9px;
62
+ background:var(--accent); color:#fff; cursor:pointer; }
63
+ button:disabled { opacity:.55; cursor:default; }
64
+ .status { margin-top:16px; font-size:14px; color:var(--muted); min-height:1.5em; text-align:center; overflow-wrap:anywhere; }
65
+ .status.error { color:#b3261e; }
66
+ @media (prefers-color-scheme: dark) { .status.error { color:#f2b8b5; } }
67
+ .status.ok { color:var(--accent); }
68
+ code { font:13px ui-monospace,SFMono-Regular,Menlo,monospace; }
69
+ </style>
70
+ </head>
71
+ <body>
72
+ <main class="card">
73
+ <h1>Approve payment</h1>
74
+ <p class="sub" id="resource"></p>
75
+ <dl>
76
+ <dt>Amount</dt><dd class="amount" id="amount">—</dd>
77
+ <dt>Network</dt><dd id="network">—</dd>
78
+ <dt>To</dt><dd><code id="payTo">—</code></dd>
79
+ <dt>Wallet</dt><dd id="wallet">looking…</dd>
80
+ </dl>
81
+ <button id="pay" disabled>Pay</button>
82
+ <p class="status" id="status"></p>
83
+ </main>
84
+
85
+ <script type="module">
86
+ import { fetchWithX402, createPaymentHeader, selectWallet } from './x402-browser.js';
87
+ import { requiredAmount, evmChainId, selectAcceptEntry, toCaip2 } from './x402-v2.js';
88
+
89
+ const token = new URLSearchParams(location.search).get('t') ?? '';
90
+ const $ = (id) => document.getElementById(id);
91
+
92
+ const setStatus = (text, kind = '') => {
93
+ $('status').textContent = text;
94
+ $('status').className = 'status ' + kind;
95
+ };
96
+
97
+ const CHAIN_NAMES = { 1: 'Ethereum', 137: 'Polygon', 8453: 'Base' };
98
+
99
+ function scaled(raw, decimals = 6) {
100
+ const value = BigInt(raw);
101
+ const div = 10n ** BigInt(decimals);
102
+ const whole = value / div;
103
+ const frac = (value % div).toString().padStart(decimals, '0').replace(/0+$/, '');
104
+ return frac ? \`\${whole}.\${frac}\` : String(whole);
105
+ }
106
+
107
+ let invoice;
108
+
109
+ async function init() {
110
+ const res = await fetch(\`/invoice?t=\${token}\`);
111
+ if (!res.ok) return setStatus('This payment link is no longer valid.', 'error');
112
+
113
+ const data = await res.json();
114
+ invoice = data.paymentRequired;
115
+ $('resource').textContent = data.resourceUrl;
116
+
117
+ // Show the option a browser wallet would actually take.
118
+ const evm = (invoice.accepts ?? [])
119
+ .map((a) => a.network)
120
+ .filter((n) => evmChainId(n) !== null);
121
+ const entry = selectAcceptEntry(invoice.accepts ?? [], evm) ?? invoice.accepts?.[0];
122
+
123
+ if (entry) {
124
+ $('amount').textContent = \`\${scaled(requiredAmount(entry))} \${entry.extra?.name ?? ''}\`.trim();
125
+ $('network').textContent = CHAIN_NAMES[evmChainId(entry.network)] ?? toCaip2(entry.network);
126
+ $('payTo').textContent = entry.payTo ?? '—';
127
+ }
128
+
129
+ const wallet = await selectWallet();
130
+ if (!wallet) {
131
+ $('wallet').textContent = 'none found';
132
+ return setStatus('No wallet found. Install CoinPay Wallet or MetaMask, then reload.', 'error');
133
+ }
134
+ $('wallet').textContent = wallet.name;
135
+ $('pay').disabled = false;
136
+ setStatus('');
137
+ }
138
+
139
+ $('pay').addEventListener('click', async () => {
140
+ $('pay').disabled = true;
141
+ setStatus('Waiting for your wallet…');
142
+ try {
143
+ const { header, wallet } = await createPaymentHeader(invoice);
144
+ setStatus('Signed. Handing back to the terminal…');
145
+ await fetch(\`/payment?t=\${token}\`, {
146
+ method: 'POST',
147
+ headers: { 'Content-Type': 'application/json' },
148
+ body: JSON.stringify({ header, wallet: wallet?.name }),
149
+ });
150
+ setStatus('Paid. You can close this tab.', 'ok');
151
+ } catch (err) {
152
+ $('pay').disabled = false;
153
+ setStatus(err?.message ?? String(err), 'error');
154
+ }
155
+ });
156
+
157
+ init().catch((err) => setStatus(err?.message ?? String(err), 'error'));
158
+ </script>
159
+ </body>
160
+ </html>`;
161
+ }
162
+
163
+ /**
164
+ * Serve a one-shot payment page.
165
+ *
166
+ * @param {object} options
167
+ * @param {object} options.paymentRequired parsed body of the 402 response
168
+ * @param {string} options.resourceUrl what is being bought, for display
169
+ * @param {number} [options.port=0] 0 lets the OS pick a free port
170
+ * @returns {Promise<{url: string, waitForPayment: () => Promise<string>, close: () => Promise<void>}>}
171
+ */
172
+ export async function startPaymentServer({ paymentRequired, resourceUrl, port = 0 }) {
173
+ // A single-use token, so another local process cannot read the invoice or
174
+ // post a payment into this session.
175
+ const token = randomBytes(24).toString('hex');
176
+
177
+ let resolvePayment;
178
+ let rejectPayment;
179
+ const payment = new Promise((resolve, reject) => {
180
+ resolvePayment = resolve;
181
+ rejectPayment = reject;
182
+ });
183
+
184
+ const server = createServer(async (req, res) => {
185
+ const url = new URL(req.url, 'http://127.0.0.1');
186
+
187
+ const send = (status, body, type = 'text/plain; charset=utf-8') => {
188
+ res.writeHead(status, {
189
+ 'Content-Type': type,
190
+ // The page must not be embeddable or reachable cross-origin: it can
191
+ // authorise a payment.
192
+ 'X-Frame-Options': 'DENY',
193
+ 'Cache-Control': 'no-store',
194
+ });
195
+ res.end(body);
196
+ };
197
+
198
+ if (url.pathname === '/' && req.method === 'GET') {
199
+ // The token is checked on the data routes rather than here, so a
200
+ // mistyped link shows the page and a clear error instead of a bare 403.
201
+ return send(200, renderPage(), 'text/html; charset=utf-8');
202
+ }
203
+
204
+ const modulePath = MODULES[url.pathname];
205
+ if (modulePath && req.method === 'GET') {
206
+ try {
207
+ const source = await readFile(modulePath, 'utf-8');
208
+ return send(200, source, 'text/javascript; charset=utf-8');
209
+ } catch {
210
+ return send(404, 'not found');
211
+ }
212
+ }
213
+
214
+ if (url.searchParams.get('t') !== token) {
215
+ return send(403, 'forbidden');
216
+ }
217
+
218
+ if (url.pathname === '/invoice' && req.method === 'GET') {
219
+ return send(200, JSON.stringify({ paymentRequired, resourceUrl }), 'application/json');
220
+ }
221
+
222
+ if (url.pathname === '/payment' && req.method === 'POST') {
223
+ let body = '';
224
+ req.on('data', (chunk) => {
225
+ body += chunk;
226
+ // A payment header is a few hundred bytes; anything larger is not one.
227
+ if (body.length > 64_000) req.destroy();
228
+ });
229
+ req.on('end', () => {
230
+ try {
231
+ const parsed = JSON.parse(body);
232
+ if (!parsed.header) throw new Error('No payment header supplied');
233
+ send(200, JSON.stringify({ ok: true }), 'application/json');
234
+ resolvePayment(parsed.header);
235
+ } catch (err) {
236
+ send(400, JSON.stringify({ error: err.message }), 'application/json');
237
+ }
238
+ });
239
+ return;
240
+ }
241
+
242
+ return send(404, 'not found');
243
+ });
244
+
245
+ await new Promise((resolve, reject) => {
246
+ server.once('error', reject);
247
+ // Loopback only. Binding 0.0.0.0 would expose a pay-me button to the LAN.
248
+ server.listen(port, '127.0.0.1', resolve);
249
+ });
250
+
251
+ const address = server.address();
252
+ const url = `http://127.0.0.1:${address.port}/?t=${token}`;
253
+
254
+ const close = () =>
255
+ new Promise((resolve) => {
256
+ server.close(() => resolve());
257
+ // Don't hold the process open on a browser tab that stayed connected.
258
+ server.closeAllConnections?.();
259
+ });
260
+
261
+ return {
262
+ url,
263
+ waitForPayment: (timeoutMs = 5 * 60 * 1000) => {
264
+ const timer = setTimeout(
265
+ () => rejectPayment(new Error('Timed out waiting for approval in the browser')),
266
+ timeoutMs,
267
+ );
268
+ return payment.finally(() => clearTimeout(timer));
269
+ },
270
+ close,
271
+ };
272
+ }
package/src/x402-v2.js ADDED
@@ -0,0 +1,416 @@
1
+ /**
2
+ * x402 v2 protocol primitives.
3
+ *
4
+ * `x402.js` speaks a dialect only CoinPayPortal understands: `x402Version: 1`,
5
+ * bare network names like `"base"`, `maxAmountRequired` instead of `amount`,
6
+ * and an EIP-712 domain of our own invention (`name: 'x402'`). Nothing else in
7
+ * the ecosystem can read a 402 we emit, and no proof from a standard client
8
+ * verifies against us.
9
+ *
10
+ * That is survivable while CoinPay Wallet is the only payer. It stops being
11
+ * survivable the moment MetaMask or Phantom is expected to pay, because those
12
+ * wallets sign standard structures and nothing else: MetaMask will happily
13
+ * sign our bespoke domain, but the resulting signature authorises nothing
14
+ * on-chain and only we could ever interpret it.
15
+ *
16
+ * This module is the real thing, and is shared by the browser payer and the
17
+ * facilitator so the two cannot drift.
18
+ *
19
+ * Field shapes here were taken from live `/supported` and
20
+ * `/discovery/resources` responses on facilitator.goplausible.xyz and
21
+ * Coinbase's facilitator, not from memory of the spec.
22
+ *
23
+ * @module x402-v2
24
+ */
25
+
26
+ /** The protocol version the ecosystem is actually on. */
27
+ export const X402_VERSION = 2;
28
+
29
+ /**
30
+ * CAIP-2 chain identifiers.
31
+ *
32
+ * The ecosystem addresses chains as `namespace:reference`, never as a bare
33
+ * name — `"base"` is meaningless to another facilitator, `eip155:8453` is not.
34
+ *
35
+ * `eip155:*` and `solana:*` are verified against live facilitator responses.
36
+ * Solana's reference is the first 32 characters of the genesis hash, per
37
+ * CAIP-30.
38
+ */
39
+ export const CAIP2 = {
40
+ ethereum: 'eip155:1',
41
+ polygon: 'eip155:137',
42
+ base: 'eip155:8453',
43
+ 'base-sepolia': 'eip155:84532',
44
+ solana: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
45
+ // CAIP-2 namespace for Bitcoin-family chains is `bip122`, referenced by the
46
+ // genesis block hash. Bitcoin Cash shares Bitcoin's genesis block, so the two
47
+ // are not distinguishable by CAIP-2 alone; BCH is left out rather than given
48
+ // an identifier that collides with Bitcoin's.
49
+ bitcoin: 'bip122:000000000019d6689c085ae165831e93',
50
+ };
51
+
52
+ /**
53
+ * Rails CoinPayPortal supports that have no CAIP-2 identity.
54
+ *
55
+ * Nobody else's facilitator accepts these, so there is no interoperable name
56
+ * to use. They keep their legacy identifiers and are flagged here so callers
57
+ * can tell "not yet mapped" from "deliberately outside CAIP-2".
58
+ */
59
+ export const NON_CAIP2_NETWORKS = new Set(['bitcoin-cash', 'lightning', 'stripe']);
60
+
61
+ /** Legacy bare name -> CAIP-2, for translating our own v1 records. */
62
+ export function toCaip2(network) {
63
+ if (!network) return '';
64
+ if (network.includes(':')) return network; // already CAIP-2
65
+ return CAIP2[network] ?? network;
66
+ }
67
+
68
+ /** CAIP-2 -> legacy bare name, for code that still keys on the old names. */
69
+ export function fromCaip2(caip2) {
70
+ if (!caip2) return '';
71
+ if (!caip2.includes(':')) return caip2; // already a bare name
72
+ const match = Object.entries(CAIP2).find(([, id]) => id === caip2);
73
+ return match ? match[0] : caip2;
74
+ }
75
+
76
+ /** Numeric EVM chain id from a CAIP-2 id, or null if it is not an eip155 chain. */
77
+ export function evmChainId(network) {
78
+ const caip2 = toCaip2(network);
79
+ const match = /^eip155:(\d+)$/.exec(caip2);
80
+ return match ? Number(match[1]) : null;
81
+ }
82
+
83
+ /**
84
+ * EIP-712 domains for the tokens we quote, keyed by `chainId:address`.
85
+ *
86
+ * A payer cannot sign without `name` and `version`, and they are not derivable:
87
+ * `version()` is not part of ERC-20 and tokens disagree about the value. So a
88
+ * v2 `accepts` entry has to carry them, which means the quoting side has to
89
+ * know them.
90
+ *
91
+ * Every entry below was read from the deployed contract — `name()`,
92
+ * `version()` and `DOMAIN_SEPARATOR()` over JSON-RPC — not copied from
93
+ * documentation. The Base values additionally match what GoPlausible's
94
+ * discovery index publishes for the same token.
95
+ *
96
+ * A token that is not in this table cannot be quoted for v2, because guessing
97
+ * its domain produces signatures that recover to the wrong address and fail
98
+ * verification with no useful diagnostic.
99
+ */
100
+ export const TOKEN_DOMAINS = {
101
+ // USDC. All three deployments report the same domain.
102
+ 'eip155:1:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': { name: 'USD Coin', version: '2' },
103
+ 'eip155:137:0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359': { name: 'USD Coin', version: '2' },
104
+ 'eip155:8453:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913': { name: 'USD Coin', version: '2' },
105
+ };
106
+
107
+ /** The published EIP-712 domain for a token, or null if we do not know it. */
108
+ export function tokenDomain(network, asset) {
109
+ if (!asset) return null;
110
+ const caip2 = toCaip2(network);
111
+ const exact = TOKEN_DOMAINS[`${caip2}:${asset}`];
112
+ if (exact) return exact;
113
+
114
+ // Addresses are case-insensitive, so a caller passing a differently-cased
115
+ // address must not silently miss.
116
+ const wanted = `${caip2}:${asset}`.toLowerCase();
117
+ const match = Object.entries(TOKEN_DOMAINS).find(([key]) => key.toLowerCase() === wanted);
118
+ return match ? match[1] : null;
119
+ }
120
+
121
+ /**
122
+ * The v2-quotable tokens, as `{ methodKey: {network, asset, decimals} }`.
123
+ *
124
+ * Only tokens: EIP-3009 is an ERC-20 extension, so native ETH or POL cannot be
125
+ * paid under the `exact` scheme at all — there is no contract to authorise
126
+ * against. Quoting native currency as `exact` would advertise something no
127
+ * wallet can sign.
128
+ */
129
+ export const V2_METHODS = {
130
+ usdc_eth: {
131
+ network: 'eip155:1',
132
+ asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
133
+ decimals: 6,
134
+ label: 'USDC on Ethereum',
135
+ },
136
+ usdc_polygon: {
137
+ network: 'eip155:137',
138
+ asset: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359',
139
+ decimals: 6,
140
+ label: 'USDC on Polygon',
141
+ },
142
+ usdc_base: {
143
+ network: 'eip155:8453',
144
+ asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
145
+ decimals: 6,
146
+ label: 'USDC on Base',
147
+ },
148
+ };
149
+
150
+ /**
151
+ * EIP-3009 `TransferWithAuthorization` type.
152
+ *
153
+ * This — not a bespoke `Payment` struct — is what x402's `exact` scheme signs
154
+ * on EVM. The signature IS the payment: whoever holds it can call
155
+ * `transferWithAuthorization` on the token and move the funds, which is why
156
+ * the facilitator can broadcast and pay the gas while the payer needs no
157
+ * native currency at all.
158
+ */
159
+ export const TRANSFER_WITH_AUTHORIZATION_TYPES = {
160
+ TransferWithAuthorization: [
161
+ { name: 'from', type: 'address' },
162
+ { name: 'to', type: 'address' },
163
+ { name: 'value', type: 'uint256' },
164
+ { name: 'validAfter', type: 'uint256' },
165
+ { name: 'validBefore', type: 'uint256' },
166
+ { name: 'nonce', type: 'bytes32' },
167
+ ],
168
+ };
169
+
170
+ /**
171
+ * Build the EIP-712 domain for an EIP-3009 authorization.
172
+ *
173
+ * The domain belongs to the TOKEN, not to x402: `verifyingContract` is the
174
+ * token's own address, and `name`/`version` are the values that token returns
175
+ * from its own EIP-712 domain. Tokens disagree about `version` ("1" vs "2")
176
+ * and it is not derivable, which is why a v2 `accepts` entry carries it in
177
+ * `extra` — that is what `extra: { name: 'USD Coin', version: '2' }` in live
178
+ * discovery data is for.
179
+ *
180
+ * Getting this wrong is not a soft failure: the recovered signer is simply
181
+ * some other address, so the authorization is void.
182
+ *
183
+ * @param {object} args
184
+ * @param {string} args.network CAIP-2 id or legacy name
185
+ * @param {string} args.asset token contract address
186
+ * @param {string} args.name token's EIP-712 domain name
187
+ * @param {string} args.version token's EIP-712 domain version
188
+ */
189
+ export function buildEip3009Domain({ network, asset, name, version }) {
190
+ const chainId = evmChainId(network);
191
+ if (chainId === null) {
192
+ throw new Error(`Not an EVM network, cannot build an EIP-3009 domain: ${network}`);
193
+ }
194
+ if (!asset) {
195
+ throw new Error('EIP-3009 requires the token contract address as verifyingContract');
196
+ }
197
+ if (!name || !version) {
198
+ throw new Error(
199
+ 'EIP-3009 requires the token EIP-712 domain name and version; ' +
200
+ 'a v2 `accepts` entry supplies them in `extra.name` / `extra.version`',
201
+ );
202
+ }
203
+
204
+ return { name, version, chainId, verifyingContract: asset };
205
+ }
206
+
207
+ /**
208
+ * A random 32-byte nonce, hex encoded.
209
+ *
210
+ * EIP-3009 nonces are arbitrary bytes32 rather than a sequence — the token
211
+ * tracks used ones per authorizer, so they need only be unpredictable and not
212
+ * repeated. Uses WebCrypto, which is present in browsers, Workers and Node 18+.
213
+ */
214
+ export function randomNonce() {
215
+ const bytes = new Uint8Array(32);
216
+ globalThis.crypto.getRandomValues(bytes);
217
+ return `0x${Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')}`;
218
+ }
219
+
220
+ /**
221
+ * Build the authorization message to be signed.
222
+ *
223
+ * `validAfter` is 0 rather than "now": clocks differ between the payer and the
224
+ * chain, and a validAfter in the payer's near future makes the authorization
225
+ * briefly unusable for no benefit.
226
+ *
227
+ * @param {object} args
228
+ * @param {string} args.from payer address
229
+ * @param {string} args.to payee address (`payTo` from `accepts`)
230
+ * @param {string|bigint} args.value amount in the token's smallest unit
231
+ * @param {number} [args.validForSeconds=600]
232
+ * @param {string} [args.nonce] defaults to a fresh random nonce
233
+ */
234
+ export function buildAuthorization({ from, to, value, validForSeconds = 600, nonce }) {
235
+ if (!from) throw new Error('buildAuthorization requires `from`');
236
+ if (!to) throw new Error('buildAuthorization requires `to`');
237
+ if (value === undefined || value === null || value === '') {
238
+ throw new Error('buildAuthorization requires `value`');
239
+ }
240
+
241
+ const now = Math.floor(Date.now() / 1000);
242
+
243
+ return {
244
+ from,
245
+ to,
246
+ value: String(value),
247
+ validAfter: '0',
248
+ validBefore: String(now + validForSeconds),
249
+ nonce: nonce ?? randomNonce(),
250
+ };
251
+ }
252
+
253
+ /**
254
+ * Assemble the payment payload that goes in the `X-PAYMENT` header.
255
+ *
256
+ * @param {object} args
257
+ * @param {string} args.network CAIP-2 id
258
+ * @param {string} args.signature 65-byte EIP-712 signature, hex
259
+ * @param {object} args.authorization from `buildAuthorization`
260
+ * @param {string} [args.scheme='exact']
261
+ */
262
+ export function buildExactEvmPayment({ network, signature, authorization, scheme = 'exact' }) {
263
+ return {
264
+ x402Version: X402_VERSION,
265
+ scheme,
266
+ network: toCaip2(network),
267
+ payload: { signature, authorization },
268
+ };
269
+ }
270
+
271
+ /**
272
+ * base64 encoding of a payment payload, as the `X-PAYMENT` header carries it.
273
+ *
274
+ * Uses base64url-safe output only where the runtime gives it; the header is
275
+ * standard base64 in the spec, so plain base64 is what is produced.
276
+ */
277
+ export function encodePaymentHeader(payment) {
278
+ const json = JSON.stringify(payment);
279
+ if (typeof globalThis.btoa === 'function') {
280
+ // Browsers: btoa is latin1-only, so UTF-8 must be widened first.
281
+ const bytes = new TextEncoder().encode(json);
282
+ let binary = '';
283
+ for (const byte of bytes) binary += String.fromCharCode(byte);
284
+ return globalThis.btoa(binary);
285
+ }
286
+ return Buffer.from(json, 'utf-8').toString('base64');
287
+ }
288
+
289
+ /** Inverse of {@link encodePaymentHeader}. */
290
+ export function decodePaymentHeader(header) {
291
+ let json;
292
+ if (typeof globalThis.atob === 'function') {
293
+ const binary = globalThis.atob(header);
294
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
295
+ json = new TextDecoder().decode(bytes);
296
+ } else {
297
+ json = Buffer.from(header, 'base64').toString('utf-8');
298
+ }
299
+ return JSON.parse(json);
300
+ }
301
+
302
+ /**
303
+ * Pick the best `accepts` entry a given set of wallets can actually pay.
304
+ *
305
+ * A 402 lists several options and the payer must choose one it can sign. The
306
+ * order of `accepts` is the merchant's preference, so it is honoured rather
307
+ * than re-ranked — the first entry whose network is supported wins.
308
+ *
309
+ * @param {object[]} accepts the 402's `accepts` array
310
+ * @param {string[]} capabilities CAIP-2 ids (or bare names) the payer can sign
311
+ * @returns {object|null}
312
+ */
313
+ export function selectAcceptEntry(accepts, capabilities) {
314
+ if (!Array.isArray(accepts) || accepts.length === 0) return null;
315
+ const supported = new Set(capabilities.map(toCaip2));
316
+ return accepts.find((entry) => supported.has(toCaip2(entry?.network))) ?? null;
317
+ }
318
+
319
+ /**
320
+ * Build a v2 `402 Payment Required` body.
321
+ *
322
+ * `buildPaymentRequired` in x402.js emits our v1 dialect — `x402Version: 1`,
323
+ * bare network names, `maxAmountRequired` — which no standard payer can read.
324
+ * A facilitator that verifies v2 but only ever quotes v1 still cannot be paid
325
+ * by anyone, so this is the other half of speaking the protocol.
326
+ *
327
+ * Only tokens with a known EIP-712 domain are quoted. An entry a payer cannot
328
+ * sign is worse than an absent one: it looks payable, fails at signing time,
329
+ * and the failure surfaces as an opaque signature error.
330
+ *
331
+ * @param {object} options
332
+ * @param {string|Record<string,string>} options.payTo merchant address, or per-network map
333
+ * @param {number} [options.amountUsd] price in USD (USDC is quoted 1:1)
334
+ * @param {string} [options.amount] explicit smallest-unit amount, overrides amountUsd
335
+ * @param {string[]} [options.methods] keys from V2_METHODS; defaults to all
336
+ * @param {string} options.resource the URL being sold
337
+ * @param {string} [options.description]
338
+ * @param {string} [options.mimeType='application/json']
339
+ * @param {number} [options.maxTimeoutSeconds=300]
340
+ */
341
+ export function buildPaymentRequiredV2({
342
+ payTo,
343
+ amountUsd,
344
+ amount,
345
+ methods,
346
+ resource,
347
+ description = 'Payment required',
348
+ mimeType = 'application/json',
349
+ maxTimeoutSeconds = 300,
350
+ } = {}) {
351
+ if (!payTo) throw new Error('buildPaymentRequiredV2 requires `payTo`');
352
+ if (!resource) throw new Error('buildPaymentRequiredV2 requires `resource`');
353
+ if (amount === undefined && amountUsd === undefined) {
354
+ throw new Error('buildPaymentRequiredV2 requires `amount` or `amountUsd`');
355
+ }
356
+
357
+ const keys = methods ?? Object.keys(V2_METHODS);
358
+ const accepts = [];
359
+
360
+ for (const key of keys) {
361
+ const method = V2_METHODS[key];
362
+ if (!method) continue;
363
+
364
+ const domain = tokenDomain(method.network, method.asset);
365
+ if (!domain) continue; // unsignable — leave it out rather than advertise it
366
+
367
+ const address =
368
+ typeof payTo === 'object' ? (payTo[method.network] ?? payTo[fromCaip2(method.network)] ?? payTo[key]) : payTo;
369
+ if (!address) continue; // merchant has no address on this chain
370
+
371
+ // USDC is a dollar stablecoin with 6 decimals, so a USD price converts
372
+ // exactly. Rounding up rather than down: rounding a fraction of a cent
373
+ // down would quote less than the asking price and verify as underpayment.
374
+ const smallestUnit =
375
+ amount !== undefined
376
+ ? String(amount)
377
+ : String(Math.ceil(amountUsd * 10 ** method.decimals));
378
+
379
+ accepts.push({
380
+ scheme: 'exact',
381
+ network: method.network,
382
+ amount: smallestUnit,
383
+ asset: method.asset,
384
+ payTo: address,
385
+ resource,
386
+ description,
387
+ mimeType,
388
+ maxTimeoutSeconds,
389
+ // The token's EIP-712 domain. Without this the payer cannot construct a
390
+ // signature at all — it is the whole reason `extra` exists on a v2 entry.
391
+ extra: { name: domain.name, version: domain.version },
392
+ });
393
+ }
394
+
395
+ if (accepts.length === 0) {
396
+ throw new Error(
397
+ 'No payable options could be built. Check `payTo` covers at least one supported chain.',
398
+ );
399
+ }
400
+
401
+ return { x402Version: X402_VERSION, accepts };
402
+ }
403
+
404
+ /**
405
+ * The amount an `accepts` entry asks for, in the asset's smallest unit.
406
+ *
407
+ * v2 names this `amount`; our own v1 emitted `maxAmountRequired`. Both are
408
+ * read so a payer can pay a CoinPayPortal 402 that has not been migrated yet.
409
+ */
410
+ export function requiredAmount(entry) {
411
+ const raw = entry?.amount ?? entry?.maxAmountRequired;
412
+ if (raw === undefined || raw === null || raw === '') {
413
+ throw new Error('`accepts` entry has neither `amount` nor `maxAmountRequired`');
414
+ }
415
+ return String(raw);
416
+ }