@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.
- package/CHANGELOG.md +39 -0
- package/README.md +189 -0
- package/bin/coinpay.js +1869 -93
- package/package.json +41 -9
- package/src/auth.js +2 -1
- package/src/card-payments.js +23 -14
- package/src/client.d.ts +52 -0
- package/src/client.js +93 -9
- package/src/finances-tui.js +1005 -0
- package/src/finances.d.ts +333 -0
- package/src/finances.js +445 -0
- package/src/index.d.ts +37 -1
- package/src/index.js +67 -0
- package/src/invoices.d.ts +78 -0
- package/src/invoices.js +250 -0
- package/src/lightning.js +8 -3
- package/src/swap.js +36 -5
- package/src/taskmarket.js +329 -0
- package/src/tokens.d.ts +14 -0
- package/src/tokens.js +34 -0
- package/src/wallet.d.ts +11 -0
- package/src/wallet.js +283 -25
- package/src/webhooks.js +37 -3
- package/src/x402-browser.js +318 -0
- package/src/x402-pay-server.js +272 -0
- package/src/x402-v2.js +416 -0
- package/src/x402.js +164 -19
|
@@ -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
|
+
}
|