@profullstack/coinpay 0.6.11 → 0.9.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,318 @@
1
+ /**
2
+ * Browser-side x402 payer.
3
+ *
4
+ * Turns a 402 response into a paid retry, using whatever wallet the visitor
5
+ * actually has. CoinPay Wallet is preferred when present; otherwise any
6
+ * injected EIP-1193 wallet — MetaMask, Phantom's EVM provider, Rabby, Coinbase
7
+ * Wallet — can pay, because what gets signed is the standard EIP-3009
8
+ * authorization rather than anything CoinPay-specific.
9
+ *
10
+ * That interoperability is the whole point of signing the real structure. A
11
+ * bespoke payload would still be signable by MetaMask, but the resulting
12
+ * signature would authorise no transfer on-chain and only CoinPayPortal could
13
+ * interpret it — which is not a wallet integration, it is a lock-in.
14
+ *
15
+ * Usage:
16
+ * const res = await fetchWithX402('https://api.example.com/premium');
17
+ *
18
+ * @module x402-browser
19
+ */
20
+
21
+ import {
22
+ buildAuthorization,
23
+ buildEip3009Domain,
24
+ buildExactEvmPayment,
25
+ encodePaymentHeader,
26
+ evmChainId,
27
+ requiredAmount,
28
+ selectAcceptEntry,
29
+ toCaip2,
30
+ TRANSFER_WITH_AUTHORIZATION_TYPES,
31
+ } from './x402-v2.js';
32
+
33
+ /** How long a discovery sweep waits for EIP-6963 wallets to announce. */
34
+ const EIP6963_ANNOUNCE_MS = 100;
35
+
36
+ /**
37
+ * Enumerate injected EVM wallets via EIP-6963.
38
+ *
39
+ * Before EIP-6963 every wallet raced to own `window.ethereum`, so with two
40
+ * installed you got whichever won — the user could not choose, and a site
41
+ * could not tell which it had. EIP-6963 has each wallet announce itself with
42
+ * its own provider object instead.
43
+ *
44
+ * `window.ethereum` is still included as a last resort, for wallets that have
45
+ * not adopted the standard.
46
+ */
47
+ export async function discoverEvmWallets({ timeoutMs = EIP6963_ANNOUNCE_MS } = {}) {
48
+ const found = new Map();
49
+
50
+ if (typeof window === 'undefined') return [];
51
+
52
+ const onAnnounce = (event) => {
53
+ const detail = event.detail;
54
+ if (!detail?.info?.uuid || !detail.provider) return;
55
+ found.set(detail.info.uuid, { info: detail.info, provider: detail.provider });
56
+ };
57
+
58
+ window.addEventListener('eip6963:announceProvider', onAnnounce);
59
+ window.dispatchEvent(new Event('eip6963:requestProvider'));
60
+ await new Promise((resolve) => setTimeout(resolve, timeoutMs));
61
+ window.removeEventListener('eip6963:announceProvider', onAnnounce);
62
+
63
+ const wallets = [...found.values()].map(({ info, provider }) => ({
64
+ id: info.rdns || info.uuid,
65
+ name: info.name,
66
+ icon: info.icon,
67
+ provider,
68
+ }));
69
+
70
+ // Legacy fallback: only if nothing announced, so a 6963-aware wallet is not
71
+ // listed twice under two different identities.
72
+ if (wallets.length === 0 && window.ethereum) {
73
+ wallets.push({ id: 'injected', name: detectLegacyName(window.ethereum), provider: window.ethereum });
74
+ }
75
+
76
+ return wallets;
77
+ }
78
+
79
+ function detectLegacyName(provider) {
80
+ if (provider.isMetaMask) return 'MetaMask';
81
+ if (provider.isPhantom) return 'Phantom';
82
+ if (provider.isCoinbaseWallet) return 'Coinbase Wallet';
83
+ if (provider.isRabby) return 'Rabby';
84
+ return 'Injected Wallet';
85
+ }
86
+
87
+ /**
88
+ * True when a CoinPay Wallet that can actually pay an x402 invoice is present.
89
+ *
90
+ * Checks for the method, not merely for the wallet. `payX402` shipped after
91
+ * the provider itself, so an installed-but-older extension has `isCoinPay`
92
+ * and no way to pay — treating that as "CoinPay is here" would hand the
93
+ * payment to a wallet that cannot take it and throw a TypeError, when the
94
+ * visitor may well have MetaMask sitting right there.
95
+ */
96
+ export function hasCoinPayWallet() {
97
+ return (
98
+ typeof window !== 'undefined' &&
99
+ window.coinpay?.isCoinPay === true &&
100
+ typeof window.coinpay.payX402 === 'function'
101
+ );
102
+ }
103
+
104
+ /** True when CoinPay Wallet is installed but predates x402 support. */
105
+ export function hasOutdatedCoinPayWallet() {
106
+ return (
107
+ typeof window !== 'undefined' &&
108
+ window.coinpay?.isCoinPay === true &&
109
+ typeof window.coinpay.payX402 !== 'function'
110
+ );
111
+ }
112
+
113
+ /**
114
+ * Choose a wallet to pay with.
115
+ *
116
+ * CoinPay Wallet first when installed — it is the only one that can pay on
117
+ * every rail CoinPayPortal accepts, including Bitcoin and Lightning, which no
118
+ * EVM wallet can touch. Otherwise the first injected EVM wallet, or one named
119
+ * explicitly by `preferWalletId`.
120
+ */
121
+ export async function selectWallet({ preferWalletId, allowCoinPay = true } = {}) {
122
+ if (allowCoinPay && hasCoinPayWallet() && !preferWalletId) {
123
+ return { kind: 'coinpay', id: 'coinpay', name: 'CoinPay Wallet', provider: window.coinpay };
124
+ }
125
+
126
+ const evm = await discoverEvmWallets();
127
+ if (evm.length === 0) {
128
+ if (allowCoinPay && hasCoinPayWallet()) {
129
+ return { kind: 'coinpay', id: 'coinpay', name: 'CoinPay Wallet', provider: window.coinpay };
130
+ }
131
+ return null;
132
+ }
133
+
134
+ const chosen = preferWalletId ? evm.find((w) => w.id === preferWalletId) : evm[0];
135
+ if (!chosen) return null;
136
+ return { kind: 'evm', ...chosen };
137
+ }
138
+
139
+ /** Ask an EIP-1193 provider for the active account, prompting if needed. */
140
+ async function requireEvmAccount(provider) {
141
+ const accounts = await provider.request({ method: 'eth_requestAccounts' });
142
+ if (!Array.isArray(accounts) || accounts.length === 0) {
143
+ throw new Error('Wallet returned no accounts');
144
+ }
145
+ return accounts[0];
146
+ }
147
+
148
+ /**
149
+ * Put an EIP-1193 wallet on the chain an `accepts` entry requires.
150
+ *
151
+ * Signing an EIP-712 payload whose domain names chain X while the wallet is on
152
+ * chain Y produces a signature that verifies against neither — wallets bind
153
+ * the domain's chainId to their active chain. So the switch is mandatory, not
154
+ * a nicety.
155
+ */
156
+ async function ensureEvmChain(provider, network) {
157
+ const chainId = evmChainId(network);
158
+ if (chainId === null) throw new Error(`Not an EVM network: ${network}`);
159
+ const hexChainId = `0x${chainId.toString(16)}`;
160
+
161
+ const current = await provider.request({ method: 'eth_chainId' });
162
+ if (typeof current === 'string' && current.toLowerCase() === hexChainId) return;
163
+
164
+ try {
165
+ await provider.request({
166
+ method: 'wallet_switchEthereumChain',
167
+ params: [{ chainId: hexChainId }],
168
+ });
169
+ } catch (err) {
170
+ // 4902 = chain unknown to the wallet. Adding it needs RPC details we do
171
+ // not have here, so surface it rather than guessing an RPC URL.
172
+ if (err?.code === 4902) {
173
+ throw new Error(
174
+ `Your wallet does not have chain ${chainId} configured. Add it, then try again.`,
175
+ );
176
+ }
177
+ throw err;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Sign an EIP-3009 authorization with an EIP-1193 wallet.
183
+ *
184
+ * @returns {{signature: string, authorization: object, from: string}}
185
+ */
186
+ export async function signExactEvm(provider, entry, { from } = {}) {
187
+ const payer = from ?? (await requireEvmAccount(provider));
188
+ await ensureEvmChain(provider, entry.network);
189
+
190
+ const domain = buildEip3009Domain({
191
+ network: entry.network,
192
+ asset: entry.asset,
193
+ name: entry.extra?.name,
194
+ version: entry.extra?.version,
195
+ });
196
+
197
+ const authorization = buildAuthorization({
198
+ from: payer,
199
+ to: entry.payTo,
200
+ value: requiredAmount(entry),
201
+ validForSeconds: entry.maxTimeoutSeconds ?? 600,
202
+ });
203
+
204
+ const typedData = {
205
+ types: {
206
+ EIP712Domain: [
207
+ { name: 'name', type: 'string' },
208
+ { name: 'version', type: 'string' },
209
+ { name: 'chainId', type: 'uint256' },
210
+ { name: 'verifyingContract', type: 'address' },
211
+ ],
212
+ ...TRANSFER_WITH_AUTHORIZATION_TYPES,
213
+ },
214
+ primaryType: 'TransferWithAuthorization',
215
+ domain,
216
+ message: authorization,
217
+ };
218
+
219
+ const signature = await provider.request({
220
+ method: 'eth_signTypedData_v4',
221
+ params: [payer, JSON.stringify(typedData)],
222
+ });
223
+
224
+ return { signature, authorization, from: payer };
225
+ }
226
+
227
+ /**
228
+ * Produce the `X-PAYMENT` header value for a 402 body.
229
+ *
230
+ * @param {object} paymentRequired the parsed 402 JSON (`{x402Version, accepts}`)
231
+ * @param {object} [options]
232
+ * @param {string} [options.preferWalletId]
233
+ * @param {string[]} [options.capabilities] override which networks to consider
234
+ */
235
+ export async function createPaymentHeader(paymentRequired, options = {}) {
236
+ const accepts = paymentRequired?.accepts;
237
+ if (!Array.isArray(accepts) || accepts.length === 0) {
238
+ throw new Error('402 response carried no `accepts` options');
239
+ }
240
+
241
+ const wallet = await selectWallet(options);
242
+ if (!wallet) {
243
+ if (hasOutdatedCoinPayWallet()) {
244
+ throw new Error(
245
+ 'Your CoinPay Wallet is too old to pay x402 invoices and no other wallet is ' +
246
+ 'available. Update it, or install MetaMask.',
247
+ );
248
+ }
249
+ throw new Error(
250
+ 'No wallet available to pay. Install CoinPay Wallet, MetaMask, or another EVM wallet.',
251
+ );
252
+ }
253
+
254
+ if (wallet.kind === 'coinpay') {
255
+ // The extension picks among the offered options itself — it knows which
256
+ // chains the user actually holds funds on, which this page does not.
257
+ const header = await wallet.provider.payX402(paymentRequired);
258
+ return { header, wallet };
259
+ }
260
+
261
+ // An injected EVM wallet can only pay the EVM entries.
262
+ const evmCapable = accepts
263
+ .map((entry) => entry.network)
264
+ .filter((network) => evmChainId(network) !== null);
265
+
266
+ const entry = selectAcceptEntry(accepts, options.capabilities ?? evmCapable);
267
+ if (!entry) {
268
+ throw new Error(
269
+ `${wallet.name} cannot pay any of the offered options ` +
270
+ `(${accepts.map((a) => toCaip2(a.network)).join(', ')}). ` +
271
+ 'CoinPay Wallet supports Bitcoin, Lightning and card rails as well.',
272
+ );
273
+ }
274
+
275
+ const { signature, authorization } = await signExactEvm(wallet.provider, entry);
276
+ const payment = buildExactEvmPayment({
277
+ network: entry.network,
278
+ signature,
279
+ authorization,
280
+ scheme: entry.scheme ?? 'exact',
281
+ });
282
+
283
+ return { header: encodePaymentHeader(payment), wallet, entry };
284
+ }
285
+
286
+ /**
287
+ * `fetch` that pays once if the resource asks for payment.
288
+ *
289
+ * Retries exactly one time. A second 402 after paying means the payment was
290
+ * rejected rather than missing, and retrying again would sign a fresh
291
+ * authorization for a resource that just refused one — at the user's expense.
292
+ *
293
+ * @param {string|URL|Request} input
294
+ * @param {RequestInit} [init]
295
+ * @param {object} [options] forwarded to {@link createPaymentHeader}
296
+ */
297
+ export async function fetchWithX402(input, init = {}, options = {}) {
298
+ const first = await fetch(input, init);
299
+ if (first.status !== 402) return first;
300
+
301
+ let paymentRequired;
302
+ try {
303
+ paymentRequired = await first.clone().json();
304
+ } catch {
305
+ throw new Error('Resource returned 402 but its body was not valid x402 JSON');
306
+ }
307
+
308
+ const { header } = await createPaymentHeader(paymentRequired, options);
309
+
310
+ const headers = new Headers(init.headers ?? {});
311
+ headers.set('X-PAYMENT', header);
312
+
313
+ const paid = await fetch(input, { ...init, headers });
314
+ if (paid.status === 402) {
315
+ throw new Error('Payment was rejected by the resource; not retrying to avoid double-paying');
316
+ }
317
+ return paid;
318
+ }
@@ -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
+ }