@three-ws/x402-payment-modal 1.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/x402.js ADDED
@@ -0,0 +1,1777 @@
1
+ /*! @three-ws/x402-payment-modal v1.1.0 — Apache-2.0 — https://three.ws */
2
+ // @three-ws/x402-payment-modal — drop-in payment modal for any x402 endpoint.
3
+ //
4
+ // A single, dependency-free ES module. Drop it on any page and any element with
5
+ // `data-x402-endpoint` opens a payment modal on click:
6
+ //
7
+ // <script type="module" src="https://unpkg.com/@three-ws/x402-payment-modal"></script>
8
+ //
9
+ // <button
10
+ // data-x402-endpoint="https://example.com/api/paid/summarize"
11
+ // data-x402-method="POST"
12
+ // data-x402-body='{"text":"hello"}'
13
+ // data-x402-merchant="Acme"
14
+ // data-x402-action="Summarize"
15
+ // >Pay & Run</button>
16
+ //
17
+ // On completion the element receives an `x402:result` CustomEvent whose detail
18
+ // is { ok, result, payment, response }. On error: `x402:error` with { error }.
19
+ //
20
+ // You can also call programmatically:
21
+ //
22
+ // import { pay, configure } from '@three-ws/x402-payment-modal';
23
+ // const out = await pay({
24
+ // endpoint: '/api/paid/summarize',
25
+ // body: { text: 'hello' },
26
+ // merchant: 'Acme',
27
+ // action: 'Summarize',
28
+ // });
29
+ //
30
+ // The modal handles wallet connect (Phantom for Solana, window.ethereum for
31
+ // Base/EVM USDC via EIP-3009), drives the 402 → sign → retry flow, optional
32
+ // SIWX (Sign-In-With-X) re-entry, client-side spending caps, and shows the
33
+ // result. Vanilla JS, no bundler required.
34
+ //
35
+ // Everything host-specific (the Solana checkout origin, branding, builder-code
36
+ // attribution, and the esm.sh CDN URLs used for the Solana/EVM crypto helpers)
37
+ // is configurable via `configure({...})` or `data-*` attributes on the script
38
+ // tag — see CONFIG / configure() below and docs/api-reference.md.
39
+
40
+ const VERSION = '1.1.0';
41
+
42
+ // ─────────────────────────────────────────────────────────── configuration ───
43
+ // All host-specific knobs live here so the modal runs unchanged on any site.
44
+ // Defaults match the three.ws hosted instance; override with configure() or via
45
+ // `data-*` attributes on the <script> tag (read once at load by readScriptConfig).
46
+ const CONFIG = {
47
+ // Origin that serves the Solana checkout endpoints
48
+ // (`/api/x402-checkout?action=prepare|encode`). `null` → resolve from this
49
+ // script's own src, falling back to the current page origin. EVM payments
50
+ // never touch this — the wallet signs EIP-3009 typed-data locally.
51
+ checkoutOrigin: null,
52
+ // Path of the checkout endpoint on `checkoutOrigin`. Override if you mount
53
+ // the server handler somewhere other than /api/x402-checkout.
54
+ checkoutPath: '/api/x402-checkout',
55
+ // Footer attribution shown in the modal.
56
+ brand: { name: 'three.ws', url: 'https://three.ws' },
57
+ // Small text on the left of the footer.
58
+ footerNote: 'x402 · onchain settled',
59
+ // ERC-8021 builder-code self-attribution echoed back when the 402 challenge
60
+ // declares a builder-code extension. Set either field to '' to disable it.
61
+ builderCode: { wallet: '3d_agent', service: '3d_agent_modal' },
62
+ // CDN URLs for the crypto helpers loaded on demand (only when a Solana or an
63
+ // EVM sign-in payment is actually attempted). Repoint these at a self-hosted
64
+ // mirror to satisfy a strict Content-Security-Policy.
65
+ esm: {
66
+ solanaWeb3: 'https://esm.sh/@solana/web3.js@1.95.3?bundle',
67
+ nobleHashesSha3: 'https://esm.sh/@noble/hashes@1.4.0/sha3?bundle',
68
+ },
69
+ };
70
+
71
+ /**
72
+ * Merge caller overrides into CONFIG. Shallow-merges the nested `brand`,
73
+ * `builderCode`, and `esm` objects so you can override a single field. Call
74
+ * before the first `pay()` (or before the auto-bound buttons are clicked).
75
+ * @param {Partial<typeof CONFIG>} opts
76
+ * @returns {typeof CONFIG} the resolved config
77
+ */
78
+ export function configure(opts = {}) {
79
+ if (!opts || typeof opts !== 'object') return CONFIG;
80
+ for (const key of ['checkoutOrigin', 'checkoutPath', 'footerNote']) {
81
+ if (opts[key] !== undefined) CONFIG[key] = opts[key];
82
+ }
83
+ if (opts.brand && typeof opts.brand === 'object') Object.assign(CONFIG.brand, opts.brand);
84
+ if (opts.builderCode && typeof opts.builderCode === 'object') Object.assign(CONFIG.builderCode, opts.builderCode);
85
+ if (opts.esm && typeof opts.esm === 'object') Object.assign(CONFIG.esm, opts.esm);
86
+ return CONFIG;
87
+ }
88
+
89
+ // Resolved base URL for the Solana checkout endpoints.
90
+ function checkoutBase() {
91
+ return `${CONFIG.checkoutOrigin || ORIGIN}${CONFIG.checkoutPath}`;
92
+ }
93
+
94
+ // SIWX ("Sign-In-With-X" / CAIP-122) lets a wallet that has already paid for
95
+ // an endpoint re-enter it by signing a challenge instead of paying again. The
96
+ // server advertises support by including `extensions['sign-in-with-x']` in the
97
+ // 402 body; clients submit signed proofs via the `SIGN-IN-WITH-X` header. See
98
+ // prompts/siwx/PLAN.md for the full architecture.
99
+ const SIWX_HEADER = 'SIGN-IN-WITH-X';
100
+ const SIWX_EXTENSION_KEY = 'sign-in-with-x';
101
+
102
+ const ORIGIN = (() => {
103
+ // Resolve the origin that hosts this script — used as the API origin for
104
+ // the prepare/encode helpers. Falls back to the merchant origin in same-
105
+ // origin mode.
106
+ try {
107
+ const script = document.currentScript;
108
+ if (script?.src) return new URL(script.src).origin;
109
+ const found = document.querySelector('script[src*="/x402.js"]');
110
+ if (found?.src) return new URL(found.src).origin;
111
+ } catch (_) {}
112
+ return location.origin;
113
+ })();
114
+
115
+ // USDC EIP-3009 typed-data sig works against Base USDC at this address. The
116
+ // domain `version` must match the on-chain `EIP712_DOMAIN_SEPARATOR_VERSION`
117
+ // of the deployed USDC implementation — Base USDC is at version "2".
118
+ const EVM_NETWORKS = {
119
+ 'eip155:8453': { chainId: 8453, name: 'Base', explorer: 'https://basescan.org/tx/' },
120
+ 'eip155:84532': { chainId: 84532, name: 'Base Sepolia', explorer: 'https://sepolia.basescan.org/tx/' },
121
+ 'eip155:42161': { chainId: 42161, name: 'Arbitrum', explorer: 'https://arbiscan.io/tx/' },
122
+ 'eip155:10': { chainId: 10, name: 'Optimism', explorer: 'https://optimistic.etherscan.io/tx/' },
123
+ };
124
+
125
+ // Normalize a single 402 `accept` entry to the shape the modal speaks
126
+ // internally. The x402 spec's canonical atomic-price field is
127
+ // `maxAmountRequired`; some merchants (and our own server) emit `amount`. We
128
+ // read `amount` everywhere downstream (price display, cap check, prepare/encode
129
+ // POST body, EIP-3009 signing), so coerce here once at ingestion. Without this,
130
+ // a spec-compliant merchant yields `accept.amount === undefined` → "NaN USDC"
131
+ // in the modal and an `accept.amount: Required` 400 from /api/x402-checkout.
132
+ function normalizeAccept(accept) {
133
+ if (!accept || typeof accept !== 'object') return accept;
134
+ const amount = accept.amount ?? accept.maxAmountRequired;
135
+ return amount != null && accept.amount == null ? { ...accept, amount: String(amount) } : accept;
136
+ }
137
+
138
+ // ─────────────────────────────────────────────── Well-known Solana tokens ────
139
+ // Mints the modal recognizes on sight, so a 402 `accept` can omit
140
+ // `extra.name`/`extra.decimals` and still render with the correct symbol,
141
+ // decimals, and branding. Two settlement assets are first-class on Solana:
142
+ // • USDC — the universal dollar-stable rail.
143
+ // • THREE — the three.ws utility token. Holders can pay any x402 endpoint
144
+ // that opts into accepting it, right alongside USDC. See three.ws/three-token.
145
+ // A merchant offers THREE simply by adding a Solana `accept` whose `asset` is
146
+ // THREE_MINT (the server checkout transfers any SPL mint, so no extra wiring).
147
+ export const USDC_MINT_SOLANA = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
148
+ export const THREE_MINT = 'FeMbDoX7R1Psc4GEcvJdsbNbZA3bfztcyDCatJVJpump';
149
+
150
+ export const KNOWN_SOLANA_TOKENS = Object.freeze({
151
+ [USDC_MINT_SOLANA]: { symbol: 'USDC', name: 'USD Coin', decimals: 6, stable: true },
152
+ [THREE_MINT]: { symbol: 'THREE', name: 'THREE', decimals: 6, accent: '#7aa2ff', glyph: '◆' },
153
+ });
154
+
155
+ // Resolve display metadata for an `accept`. A merchant-supplied `extra.name`/
156
+ // `extra.decimals` always wins; otherwise we fall back to the known-token
157
+ // registry keyed by mint, then to USDC defaults. Never throws.
158
+ function tokenInfo(accept) {
159
+ const known = (accept && accept.asset && KNOWN_SOLANA_TOKENS[accept.asset]) || null;
160
+ const rawName = accept?.extra?.name;
161
+ const symbol = (rawName ? String(rawName).replace(/^USD Coin$/, 'USDC') : known?.symbol) || 'USDC';
162
+ const decimals = Number(accept?.extra?.decimals ?? known?.decimals ?? 6);
163
+ const stable = known?.stable || STABLE_NAMES.has(String(rawName || '').toLowerCase());
164
+ return { symbol, decimals, stable, accent: known?.accent || null, glyph: known?.glyph || null };
165
+ }
166
+
167
+ function isThreeAccept(accept) {
168
+ return accept?.asset === THREE_MINT;
169
+ }
170
+
171
+ // Every Solana `accept` the challenge offers, in challenge order. More than one
172
+ // means the merchant accepts multiple tokens (e.g. USDC and THREE) and the buyer
173
+ // gets to choose which to pay in.
174
+ function listSolanaAccepts(challenge) {
175
+ return (challenge?.accepts || []).filter((a) => isSolanaNetwork(a.network));
176
+ }
177
+
178
+ // Default Solana token to surface first: USDC (stable, predictable price) when
179
+ // offered, then any other stable, then the first accept. The buyer can switch.
180
+ function pickDefaultSolana(list) {
181
+ return (
182
+ list.find((a) => a.asset === USDC_MINT_SOLANA) ||
183
+ list.find((a) => tokenInfo(a).stable) ||
184
+ list[0] ||
185
+ null
186
+ );
187
+ }
188
+
189
+ function isSolanaNetwork(net) {
190
+ return typeof net === 'string' && (net === 'solana' || net.startsWith('solana:'));
191
+ }
192
+ function isEvmNetwork(net) {
193
+ return typeof net === 'string' && net.startsWith('eip155:');
194
+ }
195
+ // The modal only signs EIP-3009 transferWithAuthorization for EVM. When the
196
+ // server publishes both an EIP-3009 entry and a Permit2 sibling (the
197
+ // gas-sponsoring path used by @x402/evm SDK clients), we must pick the
198
+ // EIP-3009 one — signing typed-data against the Permit2 entry would build a
199
+ // payload the facilitator rejects. The sibling carries
200
+ // `extra.assetTransferMethod === 'permit2'`; the legacy entry omits it.
201
+ function isEip3009Accept(accept) {
202
+ if (!isEvmNetwork(accept?.network)) return false;
203
+ const method = accept?.extra?.assetTransferMethod;
204
+ return !method || method === 'eip3009';
205
+ }
206
+ function networkLabel(net, accept) {
207
+ if (isSolanaNetwork(net)) return 'Solana';
208
+ const meta = EVM_NETWORKS[net];
209
+ return meta?.name || accept?.extra?.name || net;
210
+ }
211
+ function explorerUrl(net, tx) {
212
+ if (!tx) return null;
213
+ if (isSolanaNetwork(net)) return `https://solscan.io/tx/${tx}`;
214
+ const meta = EVM_NETWORKS[net];
215
+ return meta ? `${meta.explorer}${tx}` : null;
216
+ }
217
+
218
+ function formatAmount(rawAtomics, decimals = 6) {
219
+ const n = Number(rawAtomics) / 10 ** decimals;
220
+ if (n < 0.01) return n.toFixed(6).replace(/0+$/, '').replace(/\.$/, '');
221
+ if (n < 1) return n.toFixed(4).replace(/0+$/, '').replace(/\.$/, '');
222
+ return n.toFixed(2);
223
+ }
224
+
225
+ function b64encode(obj) {
226
+ const json = JSON.stringify(obj);
227
+ if (typeof Buffer !== 'undefined') return Buffer.from(json, 'utf8').toString('base64');
228
+ return btoa(unescape(encodeURIComponent(json)));
229
+ }
230
+ function b64decode(str) {
231
+ if (!str) return null;
232
+ try {
233
+ const bin = typeof Buffer !== 'undefined' ? Buffer.from(str, 'base64').toString('utf8') : decodeURIComponent(escape(atob(str)));
234
+ return JSON.parse(bin);
235
+ } catch (_) {
236
+ return null;
237
+ }
238
+ }
239
+
240
+ // ──────────────────────────────────────────── Spending caps (USE-22) ────────
241
+ // Persists per-wallet spend in localStorage so reload-survivable caps work
242
+ // in a pure-browser context. Keys are bucketed by UTC hour and UTC day so
243
+ // the sliding windows reset cleanly at midnight UTC for the daily case.
244
+ // All amounts are stored as base-10 BigInt strings of micro-USD; stablecoin
245
+ // payments (USDC, USDT, DAI) flow through as-is since their atomics are
246
+ // already 6-decimal USD-pegged.
247
+
248
+ const SPEND_LS_PREFIX = 'x402.spend.';
249
+ const STABLE_NAMES = new Set([
250
+ 'usdc', 'usd coin', 'usdt', 'tether', 'binance-peg usd coin', 'dai',
251
+ ]);
252
+
253
+ function spendBuckets(timestamp = Date.now()) {
254
+ const hour = Math.floor(timestamp / 3_600_000);
255
+ const day = Math.floor(timestamp / 86_400_000);
256
+ return { hour, day };
257
+ }
258
+
259
+ function spendKey(address, kind, bucket) {
260
+ return `${SPEND_LS_PREFIX}${kind}.${address.toLowerCase()}.${bucket}`;
261
+ }
262
+
263
+ function readSpend(address, kind, bucket) {
264
+ try {
265
+ const raw = localStorage.getItem(spendKey(address, kind, bucket));
266
+ if (!raw) return 0n;
267
+ return BigInt(raw);
268
+ } catch {
269
+ return 0n;
270
+ }
271
+ }
272
+
273
+ function writeSpend(address, kind, bucket, value) {
274
+ try {
275
+ localStorage.setItem(spendKey(address, kind, bucket), value.toString());
276
+ } catch {
277
+ // localStorage full / disabled — caps degrade to per-call only.
278
+ }
279
+ }
280
+
281
+ function toMicroUsdBrowser(amount, accept) {
282
+ const atomic = BigInt(amount);
283
+ const known = (accept && accept.asset && KNOWN_SOLANA_TOKENS[accept.asset]) || null;
284
+ const decimals = Number(accept?.extra?.decimals ?? known?.decimals ?? 6);
285
+ const name = String(accept?.extra?.name || '').toLowerCase();
286
+ if (STABLE_NAMES.has(name) || known?.stable) {
287
+ if (decimals === 6) return atomic;
288
+ if (decimals > 6) return atomic / 10n ** BigInt(decimals - 6);
289
+ return atomic * 10n ** BigInt(6 - decimals);
290
+ }
291
+ // Non-stable in the browser modal: we don't fetch live prices to keep the
292
+ // drop-in script dependency-free. Cap enforcement for non-stable assets
293
+ // must be done server-side via x402-spending-cap.js.
294
+ return atomic;
295
+ }
296
+
297
+ // Check the configured caps and, if admitted, reserve the spend in
298
+ // localStorage. Returns { abort: boolean, reason?, reservation? }.
299
+ // Reservation has { address, microUsd, buckets } so rollback can undo.
300
+ function browserEnforceCap({ accept, caps, address }) {
301
+ if (!caps || !address) return { abort: false };
302
+ const microUsd = toMicroUsdBrowser(accept.amount, accept);
303
+ const maxPerCall = caps.maxPerCall != null ? BigInt(caps.maxPerCall) : null;
304
+ const maxPerHour = caps.maxPerHour != null ? BigInt(caps.maxPerHour) : null;
305
+ const maxPerDay = caps.maxPerDay != null ? BigInt(caps.maxPerDay) : null;
306
+ if (maxPerCall != null && microUsd > maxPerCall) {
307
+ return {
308
+ abort: true,
309
+ reason: `Per-call cap exceeded (${microUsd} > ${maxPerCall} µUSD)`,
310
+ };
311
+ }
312
+ const buckets = spendBuckets();
313
+ const hourTotal = readSpend(address, 'hr', buckets.hour) + microUsd;
314
+ const dayTotal = readSpend(address, 'day', buckets.day) + microUsd;
315
+ if (maxPerHour != null && hourTotal > maxPerHour) {
316
+ return { abort: true, reason: `Hourly cap exceeded (${hourTotal} > ${maxPerHour} µUSD)` };
317
+ }
318
+ if (maxPerDay != null && dayTotal > maxPerDay) {
319
+ return { abort: true, reason: `Daily cap exceeded (${dayTotal} > ${maxPerDay} µUSD)` };
320
+ }
321
+ writeSpend(address, 'hr', buckets.hour, hourTotal);
322
+ writeSpend(address, 'day', buckets.day, dayTotal);
323
+ return { abort: false, reservation: { address, microUsd, buckets } };
324
+ }
325
+
326
+ function browserRollbackReservation(reservation) {
327
+ if (!reservation) return;
328
+ const { address, microUsd, buckets } = reservation;
329
+ const hourCurrent = readSpend(address, 'hr', buckets.hour);
330
+ const dayCurrent = readSpend(address, 'day', buckets.day);
331
+ const hourNext = hourCurrent - microUsd;
332
+ const dayNext = dayCurrent - microUsd;
333
+ writeSpend(address, 'hr', buckets.hour, hourNext < 0n ? 0n : hourNext);
334
+ writeSpend(address, 'day', buckets.day, dayNext < 0n ? 0n : dayNext);
335
+ }
336
+
337
+ // ──────────────────────────────────────────── ERC-8021 builder-code echo ────
338
+ // The server-side x402-spec.js enforces that any client-echoed builder-code
339
+ // `a` matches what the 402 challenge declared (anti-tamper). Builders/wallets
340
+ // can append their own service code in `s` and set their wallet code `w`
341
+ // — for our own demo modal we self-attribute `w: "3d_agent"` and `s: ["3d_agent_modal"]`.
342
+
343
+ const BUILDER_CODE_KEY = 'builder-code';
344
+ const BUILDER_CODE_PATTERN = /^[a-z0-9_]{1,32}$/;
345
+
346
+ function buildBuilderCodeEcho(challenge) {
347
+ const ext = challenge?.extensions?.[BUILDER_CODE_KEY];
348
+ const declaredA = ext?.info?.a;
349
+ if (!declaredA || !BUILDER_CODE_PATTERN.test(declaredA)) return null;
350
+ const out = { a: declaredA };
351
+ const serviceCode = CONFIG.builderCode.service;
352
+ const walletCode = CONFIG.builderCode.wallet;
353
+ if (serviceCode && BUILDER_CODE_PATTERN.test(serviceCode)) out.s = [serviceCode];
354
+ if (walletCode && BUILDER_CODE_PATTERN.test(walletCode)) out.w = walletCode;
355
+ return out;
356
+ }
357
+
358
+ // ─────────────────────────────────────────────────────────── SIWX helpers ────
359
+
360
+ function extractSiwxExtension(body) {
361
+ const ext = body?.extensions?.[SIWX_EXTENSION_KEY];
362
+ if (!ext || !ext.info || !Array.isArray(ext.supportedChains) || !ext.supportedChains.length) return null;
363
+ return ext;
364
+ }
365
+
366
+ // Returns { chain, kind: 'evm' | 'solana' } or null. `chain` is the matching
367
+ // entry from `ext.supportedChains` whose signature type matches the wallet kind.
368
+ function pickSiwxChain(ext, walletKind) {
369
+ for (const chain of ext.supportedChains) {
370
+ if (walletKind === 'evm' && chain.type === 'eip191') return { chain, kind: 'evm' };
371
+ if (walletKind === 'solana' && chain.type === 'ed25519') return { chain, kind: 'solana' };
372
+ }
373
+ return null;
374
+ }
375
+
376
+ // Build the CAIP-122 message string. The server rebuilds the same string from
377
+ // payload fields when verifying — any line-by-line drift makes the recovered
378
+ // signer mismatch payload.address and the signature is rejected.
379
+ //
380
+ // EVM path mirrors EIP-4361 / siwe library's prepareMessage (chain ref =
381
+ // numeric chainId extracted from "eip155:<n>"). Solana path mirrors SIWS
382
+ // (chain ref = genesis hash extracted from "solana:<ref>"). Optional fields
383
+ // are omitted entirely when absent from server info.
384
+ function buildSiwxMessage(info, chain, address) {
385
+ const isEvm = chain.type === 'eip191';
386
+ const accountHeader = isEvm
387
+ ? `${info.domain} wants you to sign in with your Ethereum account:`
388
+ : `${info.domain} wants you to sign in with your Solana account:`;
389
+ const [, chainTail = ''] = String(chain.chainId).split(':');
390
+ const chainRef = isEvm ? String(parseInt(chainTail, 10)) : chainTail;
391
+
392
+ const lines = [accountHeader, address, ''];
393
+ if (info.statement) {
394
+ lines.push(info.statement, '');
395
+ } else if (isEvm) {
396
+ // siwe's prepareMessage() reserves the statement block even when the
397
+ // statement is absent, emitting an extra blank line (header, address,
398
+ // "", "", URI). SIWS's formatter does not. The server rebuilds the EVM
399
+ // message via siwe before recovering the signer, so omit-statement EVM
400
+ // must carry the same extra blank or the recovered address mismatches.
401
+ lines.push('');
402
+ }
403
+ lines.push(`URI: ${info.uri}`);
404
+ lines.push(`Version: ${info.version || '1'}`);
405
+ lines.push(`Chain ID: ${chainRef}`);
406
+ lines.push(`Nonce: ${info.nonce}`);
407
+ lines.push(`Issued At: ${info.issuedAt}`);
408
+ if (info.expirationTime) lines.push(`Expiration Time: ${info.expirationTime}`);
409
+ if (info.notBefore) lines.push(`Not Before: ${info.notBefore}`);
410
+ if (info.requestId !== undefined && info.requestId !== null) lines.push(`Request ID: ${info.requestId}`);
411
+ if (Array.isArray(info.resources) && info.resources.length) {
412
+ lines.push('Resources:');
413
+ for (const r of info.resources) lines.push(`- ${r}`);
414
+ }
415
+ return lines.join('\n');
416
+ }
417
+
418
+ // Base64-encoded JSON per x402 v2 spec (CHANGELOG-v2.md line 335). CAIP-122
419
+ // fields are all ASCII/Latin-1, so the unescape+encodeURIComponent dance
420
+ // matches what btoa expects without garbling unicode (none is sent anyway).
421
+ function encodeSiwxHeaderValue(payload) {
422
+ const json = JSON.stringify(payload);
423
+ if (typeof Buffer !== 'undefined') return Buffer.from(json, 'utf8').toString('base64');
424
+ return btoa(unescape(encodeURIComponent(json)));
425
+ }
426
+
427
+ // Base58 (Bitcoin alphabet) — Solana's encoding for both addresses and
428
+ // signatures. Inlined here to avoid pulling in a bundler dependency; this
429
+ // matches what `bs58` does on the server side (api/_lib/siws.js).
430
+ const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
431
+ function base58encode(bytes) {
432
+ if (!bytes || bytes.length === 0) return '';
433
+ let leadingZeros = 0;
434
+ while (leadingZeros < bytes.length && bytes[leadingZeros] === 0) leadingZeros++;
435
+ let n = 0n;
436
+ for (let i = 0; i < bytes.length; i++) n = (n << 8n) | BigInt(bytes[i]);
437
+ let out = '';
438
+ while (n > 0n) {
439
+ out = BASE58_ALPHABET[Number(n % 58n)] + out;
440
+ n /= 58n;
441
+ }
442
+ for (let i = 0; i < leadingZeros; i++) out = BASE58_ALPHABET[0] + out;
443
+ return out;
444
+ }
445
+
446
+ // EIP-55 checksum the address before signing. MetaMask returns addresses in
447
+ // lowercase via eth_requestAccounts, but the server rebuilds the SIWE message
448
+ // with a checksummed address (siwe library's prepareMessage always upgrades
449
+ // case via getAddress). If we sign a lowercase-address message and send the
450
+ // lowercase address in the payload, the server's recovered signer (from the
451
+ // checksummed-address message it builds) differs from payload.address and
452
+ // verification fails. So checksum here, then use the same string everywhere.
453
+ //
454
+ // Keccak-256 lives in @noble/hashes which the server already uses
455
+ // (api/_lib/siws.js → @noble/curves). Pulled in dynamically via esm.sh only
456
+ // when SIWX EVM sign-in is actually attempted, mirroring loadSolanaWeb3.
457
+ let _evmChecksum = null;
458
+ async function loadEvmChecksum() {
459
+ if (_evmChecksum) return _evmChecksum;
460
+ const sha3 = await import(/* @vite-ignore */ CONFIG.esm.nobleHashesSha3);
461
+ const keccak = sha3.keccak_256;
462
+ _evmChecksum = (addr) => {
463
+ const a = String(addr).toLowerCase().replace(/^0x/, '');
464
+ if (!/^[0-9a-f]{40}$/.test(a)) throw new Error(`invalid EVM address: ${addr}`);
465
+ const hashBytes = keccak(new TextEncoder().encode(a));
466
+ let hex = '';
467
+ for (let i = 0; i < hashBytes.length; i++) hex += hashBytes[i].toString(16).padStart(2, '0');
468
+ let out = '0x';
469
+ for (let i = 0; i < 40; i++) {
470
+ out += parseInt(hex[i], 16) >= 8 ? a[i].toUpperCase() : a[i];
471
+ }
472
+ return out;
473
+ };
474
+ return _evmChecksum;
475
+ }
476
+
477
+ // ───────────────────────────────────────────────────────────────── styles ────
478
+
479
+ const STYLE_ID = 'x402-styles';
480
+ const STYLES = `
481
+ :root {
482
+ --x402-z: 2147483600;
483
+ }
484
+ .x402-overlay {
485
+ position: fixed; inset: 0;
486
+ background: rgba(8, 10, 18, 0.55);
487
+ backdrop-filter: blur(10px);
488
+ -webkit-backdrop-filter: blur(10px);
489
+ display: flex; align-items: center; justify-content: center;
490
+ z-index: var(--x402-z);
491
+ opacity: 0; transition: opacity 0.16s ease-out;
492
+ font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
493
+ -webkit-font-smoothing: antialiased;
494
+ color: #0f0f0f;
495
+ }
496
+ .x402-overlay.x402-open { opacity: 1; }
497
+ .x402-overlay * { box-sizing: border-box; }
498
+ .x402-modal {
499
+ width: calc(100% - 32px); max-width: 420px;
500
+ background: #ffffff;
501
+ border-radius: 18px;
502
+ box-shadow: 0 24px 80px rgba(8, 10, 18, 0.28), 0 4px 16px rgba(8, 10, 18, 0.12);
503
+ overflow: hidden;
504
+ transform: translateY(8px) scale(0.985);
505
+ transition: transform 0.18s ease-out;
506
+ display: flex; flex-direction: column;
507
+ max-height: calc(100dvh - 32px);
508
+ }
509
+ .x402-overlay.x402-open .x402-modal { transform: translateY(0) scale(1); }
510
+ .x402-head {
511
+ padding: 18px 20px 14px;
512
+ border-bottom: 1px solid #eef0f4;
513
+ display: flex; align-items: center; gap: 12px;
514
+ }
515
+ .x402-head .x402-merchant {
516
+ flex: 1; min-width: 0;
517
+ }
518
+ .x402-merchant .x402-name {
519
+ font-size: 12px; color: #5a6378; font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase;
520
+ margin-bottom: 2px;
521
+ }
522
+ .x402-merchant .x402-action {
523
+ font-size: 17px; font-weight: 700; color: #0f0f0f;
524
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
525
+ letter-spacing: -0.01em;
526
+ }
527
+ .x402-close {
528
+ width: 32px; height: 32px;
529
+ border-radius: 8px; border: none; background: #f3f4f7;
530
+ font-size: 16px; color: #5a6378; cursor: pointer;
531
+ display: flex; align-items: center; justify-content: center;
532
+ transition: background 0.12s;
533
+ }
534
+ .x402-close:hover { background: #e7e9ee; color: #0f0f0f; }
535
+
536
+ .x402-price-row {
537
+ padding: 18px 20px;
538
+ display: flex; align-items: baseline; justify-content: space-between;
539
+ background: linear-gradient(180deg, #fafbfc 0%, #ffffff 100%);
540
+ border-bottom: 1px solid #eef0f4;
541
+ }
542
+ .x402-price {
543
+ font-size: 32px; font-weight: 700; letter-spacing: -0.02em; color: #0f0f0f;
544
+ font-variant-numeric: tabular-nums;
545
+ }
546
+ .x402-price .x402-currency { font-size: 14px; color: #5a6378; font-weight: 600; margin-left: 6px; letter-spacing: 0; }
547
+ .x402-network {
548
+ font-size: 12px; color: #5a6378; font-weight: 500;
549
+ background: #f3f4f7; padding: 5px 10px; border-radius: 99px;
550
+ display: inline-flex; align-items: center; gap: 6px;
551
+ }
552
+ .x402-network::before {
553
+ content: ''; width: 6px; height: 6px; border-radius: 50%;
554
+ background: #22c55e;
555
+ }
556
+
557
+ .x402-body {
558
+ padding: 16px 20px 18px;
559
+ flex: 1 1 auto; overflow-y: auto;
560
+ display: flex; flex-direction: column; gap: 10px;
561
+ }
562
+ .x402-step {
563
+ display: flex; gap: 12px; align-items: flex-start;
564
+ padding: 10px 0;
565
+ }
566
+ .x402-step + .x402-step { border-top: 1px solid #f3f4f7; }
567
+ .x402-step-num {
568
+ width: 22px; height: 22px; flex: 0 0 auto;
569
+ border-radius: 50%; border: 1.5px solid #d0d4dd; background: #fff;
570
+ color: #5a6378;
571
+ font-size: 11px; font-weight: 700;
572
+ display: flex; align-items: center; justify-content: center;
573
+ }
574
+ .x402-step.x402-active .x402-step-num {
575
+ border-color: #0a84ff; background: #0a84ff; color: #fff;
576
+ animation: x402-spin 1.2s linear infinite;
577
+ }
578
+ .x402-step.x402-done .x402-step-num {
579
+ border-color: #22c55e; background: #22c55e; color: #fff;
580
+ }
581
+ .x402-step.x402-error .x402-step-num {
582
+ border-color: #ef4444; background: #ef4444; color: #fff;
583
+ }
584
+ @keyframes x402-spin {
585
+ from { box-shadow: 0 0 0 0 rgba(10, 132, 255, 0.4); }
586
+ to { box-shadow: 0 0 0 8px rgba(10, 132, 255, 0); }
587
+ }
588
+ .x402-step-body { flex: 1; min-width: 0; }
589
+ .x402-step-label { font-size: 14px; font-weight: 600; color: #0f0f0f; line-height: 1.35; }
590
+ .x402-step-meta { font-size: 12px; color: #5a6378; margin-top: 2px; font-feature-settings: 'tnum' 1; }
591
+ .x402-step.x402-error .x402-step-meta { color: #ef4444; }
592
+
593
+ .x402-wallet-buttons {
594
+ display: flex; flex-direction: column; gap: 8px;
595
+ margin-top: 4px;
596
+ }
597
+ .x402-wallet-btn {
598
+ width: 100%; padding: 13px 14px;
599
+ background: #ffffff; border: 1.5px solid #e2e5ec; border-radius: 11px;
600
+ font-size: 14px; font-weight: 600; color: #0f0f0f;
601
+ cursor: pointer; font-family: inherit;
602
+ display: flex; align-items: center; gap: 12px;
603
+ transition: border-color 0.12s, background 0.12s, transform 0.05s;
604
+ }
605
+ .x402-wallet-btn:hover:not(:disabled) { border-color: #0a84ff; background: #f7faff; }
606
+ .x402-wallet-btn:active:not(:disabled) { transform: translateY(1px); }
607
+ .x402-wallet-btn:disabled { opacity: 0.45; cursor: not-allowed; }
608
+ .x402-wallet-icon {
609
+ width: 28px; height: 28px; flex: 0 0 auto;
610
+ border-radius: 7px;
611
+ display: flex; align-items: center; justify-content: center;
612
+ font-size: 16px;
613
+ background: #f3f4f7;
614
+ }
615
+ .x402-wallet-icon.x402-phantom { background: linear-gradient(135deg, #ab9ff2, #534bb1); color: #fff; }
616
+ .x402-wallet-icon.x402-metamask { background: linear-gradient(135deg, #f6851b, #e2761b); color: #fff; }
617
+ .x402-wallet-name { flex: 1; text-align: left; }
618
+ .x402-wallet-meta { font-size: 11px; color: #8a90a8; font-weight: 500; }
619
+
620
+ .x402-token-row {
621
+ display: flex; gap: 8px; margin-bottom: 10px;
622
+ }
623
+ .x402-token-pill {
624
+ flex: 1; min-width: 0; cursor: pointer; font-family: inherit;
625
+ padding: 9px 12px; border-radius: 10px;
626
+ background: #ffffff; border: 1.5px solid #e2e5ec;
627
+ display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
628
+ transition: border-color 0.12s, background 0.12s, transform 0.05s;
629
+ }
630
+ .x402-token-pill:hover { border-color: #c3c9d6; background: #f9fafc; }
631
+ .x402-token-pill:active { transform: translateY(1px); }
632
+ .x402-token-pill.x402-on { border-color: #0f0f0f; background: #f7faff; }
633
+ .x402-token-sym { font-size: 13px; font-weight: 700; color: #0f0f0f; letter-spacing: -0.005em; }
634
+ .x402-token-amt { font-size: 11px; color: #8a90a8; font-weight: 600; font-variant-numeric: tabular-nums; }
635
+
636
+ .x402-pay-btn {
637
+ width: 100%; padding: 14px 16px;
638
+ background: #0f0f0f; color: #fff; border: none;
639
+ border-radius: 12px;
640
+ font-size: 15px; font-weight: 700; font-family: inherit;
641
+ cursor: pointer; letter-spacing: -0.005em;
642
+ transition: background 0.12s, transform 0.05s;
643
+ margin-top: 4px;
644
+ display: flex; align-items: center; justify-content: center; gap: 8px;
645
+ }
646
+ .x402-pay-btn:hover:not(:disabled) { background: #1d1d1d; }
647
+ .x402-pay-btn:active:not(:disabled) { transform: translateY(1px); }
648
+ .x402-pay-btn:disabled { background: #c8ccd4; cursor: not-allowed; }
649
+
650
+ .x402-pay-secondary {
651
+ width: 100%; padding: 12px 14px;
652
+ background: #ffffff; color: #0f0f0f;
653
+ border: 1.5px solid #e2e5ec; border-radius: 11px;
654
+ font-size: 14px; font-weight: 600; font-family: inherit;
655
+ cursor: pointer; letter-spacing: -0.005em;
656
+ margin-top: 6px;
657
+ transition: border-color 0.12s, background 0.12s, transform 0.05s;
658
+ }
659
+ .x402-pay-secondary:hover:not(:disabled) { border-color: #0a84ff; background: #f7faff; }
660
+ .x402-pay-secondary:active:not(:disabled) { transform: translateY(1px); }
661
+
662
+ .x402-siwx-hint {
663
+ font-size: 11px; color: #5a6378; text-align: center;
664
+ margin-top: 8px; line-height: 1.4;
665
+ }
666
+ .x402-siwx-fallback {
667
+ font-size: 12px; color: #b45309; line-height: 1.45;
668
+ padding: 8px 10px; border-radius: 8px;
669
+ background: #fffbeb; border: 1px solid #fde68a;
670
+ margin-bottom: 6px;
671
+ }
672
+
673
+ .x402-error-box {
674
+ padding: 12px 14px; border-radius: 10px;
675
+ background: #fef2f2; border: 1px solid #fecaca; color: #b91c1c;
676
+ font-size: 13px; line-height: 1.45;
677
+ font-family: ui-monospace, "JetBrains Mono", Menlo, monospace;
678
+ word-break: break-word;
679
+ }
680
+ .x402-error-box strong { font-weight: 700; }
681
+
682
+ .x402-receipt {
683
+ padding: 14px 16px; border-radius: 12px;
684
+ background: linear-gradient(180deg, #f0fdf4 0%, #ffffff 100%);
685
+ border: 1px solid #bbf7d0;
686
+ }
687
+ .x402-receipt-title {
688
+ font-size: 11px; font-weight: 700; color: #15803d;
689
+ text-transform: uppercase; letter-spacing: 0.06em;
690
+ margin-bottom: 8px;
691
+ display: flex; align-items: center; gap: 6px;
692
+ }
693
+ .x402-receipt-title::before { content: '✓'; font-size: 14px; }
694
+ .x402-receipt-row {
695
+ display: flex; justify-content: space-between; gap: 12px;
696
+ font-size: 12px; padding: 2px 0;
697
+ font-family: ui-monospace, "JetBrains Mono", Menlo, monospace;
698
+ }
699
+ .x402-receipt-row .x402-k { color: #5a6378; }
700
+ .x402-receipt-row .x402-v { color: #0f0f0f; text-align: right; word-break: break-all; }
701
+ .x402-receipt-row a { color: #0a84ff; text-decoration: none; }
702
+ .x402-receipt-row a:hover { text-decoration: underline; }
703
+
704
+ .x402-result {
705
+ padding: 12px 14px; border-radius: 10px;
706
+ background: #fafbfc; border: 1px solid #e2e5ec;
707
+ max-height: 240px; overflow: auto;
708
+ font-family: ui-monospace, "JetBrains Mono", Menlo, monospace;
709
+ font-size: 12px; line-height: 1.5; color: #0f0f0f;
710
+ white-space: pre-wrap; word-break: break-word;
711
+ }
712
+
713
+ .x402-foot {
714
+ padding: 10px 20px 14px;
715
+ border-top: 1px solid #eef0f4;
716
+ display: flex; align-items: center; justify-content: space-between;
717
+ font-size: 11px; color: #8a90a8;
718
+ }
719
+ .x402-foot a { color: #5a6378; text-decoration: none; font-weight: 600; }
720
+ .x402-foot a:hover { color: #0f0f0f; }
721
+ .x402-foot .x402-secure { display: flex; align-items: center; gap: 5px; }
722
+ .x402-foot .x402-secure::before { content: '🔒'; font-size: 10px; }
723
+
724
+ @media (max-width: 480px) {
725
+ .x402-modal { max-width: none; width: calc(100% - 16px); border-radius: 16px; }
726
+ .x402-price { font-size: 26px; }
727
+ }
728
+
729
+ @media (prefers-color-scheme: dark) {
730
+ .x402-overlay { color: #e6e8f0; }
731
+ .x402-modal { background: #161616; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6); }
732
+ .x402-head, .x402-price-row, .x402-foot { border-color: #272727; }
733
+ .x402-step + .x402-step { border-top-color: #272727; }
734
+ .x402-merchant .x402-name { color: #8a90a8; }
735
+ .x402-merchant .x402-action, .x402-price, .x402-step-label { color: #e6e8f0; }
736
+ .x402-step-meta { color: #8a90a8; }
737
+ .x402-close { background: #222222; color: #8a90a8; }
738
+ .x402-close:hover { background: #2e2e2e; color: #e6e8f0; }
739
+ .x402-price-row { background: linear-gradient(180deg, #1d1d1d 0%, #161616 100%); }
740
+ .x402-network { background: #222222; color: #b0b6cc; }
741
+ .x402-wallet-btn { background: #1d1d1d; border-color: #2e2e2e; color: #e6e8f0; }
742
+ .x402-wallet-btn:hover:not(:disabled) { background: #252525; border-color: #0a84ff; }
743
+ .x402-wallet-icon { background: #2e2e2e; }
744
+ .x402-wallet-meta { color: #6b7088; }
745
+ .x402-pay-btn { background: #ffffff; color: #0f0f0f; }
746
+ .x402-pay-btn:hover:not(:disabled) { background: #e7e9ee; }
747
+ .x402-pay-btn:disabled { background: #2e2e2e; color: #5a6378; }
748
+ .x402-pay-secondary { background: #1d1d1d; border-color: #2e2e2e; color: #e6e8f0; }
749
+ .x402-pay-secondary:hover:not(:disabled) { background: #252525; border-color: #0a84ff; }
750
+ .x402-siwx-hint { color: #8a90a8; }
751
+ .x402-siwx-fallback { background: #2a1d10; border-color: #78350f; color: #fcd34d; }
752
+ .x402-step-num { background: #161616; border-color: #2e2e2e; color: #8a90a8; }
753
+ .x402-result { background: #1d1d1d; border-color: #2e2e2e; color: #e6e8f0; }
754
+ .x402-receipt { background: linear-gradient(180deg, #0b1f17 0%, #161616 100%); border-color: #14532d; }
755
+ .x402-receipt-title { color: #4ade80; }
756
+ .x402-receipt-row .x402-k { color: #8a90a8; }
757
+ .x402-receipt-row .x402-v { color: #e6e8f0; }
758
+ .x402-receipt-row a { color: #60a5fa; }
759
+ .x402-error-box { background: #1f1416; border-color: #7f1d1d; color: #fca5a5; }
760
+ .x402-foot a { color: #b0b6cc; }
761
+ .x402-foot a:hover { color: #ffffff; }
762
+ }
763
+ `;
764
+
765
+ function injectStyles() {
766
+ if (document.getElementById(STYLE_ID)) return;
767
+ const el = document.createElement('style');
768
+ el.id = STYLE_ID;
769
+ el.textContent = STYLES;
770
+ document.head.appendChild(el);
771
+ }
772
+
773
+ // ───────────────────────────────────────────────────────────── modal class ───
774
+
775
+ class CheckoutModal {
776
+ constructor(opts) {
777
+ this.opts = opts;
778
+ this.steps = [
779
+ { id: 'discover', label: 'Confirming price' },
780
+ { id: 'connect', label: 'Connect wallet' },
781
+ { id: 'authorize', label: 'Authorize payment' },
782
+ { id: 'verify', label: 'Verify & complete' },
783
+ ];
784
+ this.activeNetwork = null;
785
+ this.payerAddress = null;
786
+ this.accept = null;
787
+ this.challenge = null;
788
+ this.disposed = false;
789
+ // One-shot guard for opts.autoConnect: we only auto-open the wallet on the
790
+ // first connect render, so an error that drops the user back to this step
791
+ // shows the manual picker instead of re-launching the wallet in a loop.
792
+ this.autoConnectTried = false;
793
+ }
794
+
795
+ mount() {
796
+ injectStyles();
797
+ const overlay = document.createElement('div');
798
+ overlay.className = 'x402-overlay';
799
+ overlay.innerHTML = `
800
+ <div class="x402-modal" role="dialog" aria-modal="true" aria-label="x402 payment">
801
+ <div class="x402-head">
802
+ <div class="x402-merchant">
803
+ <div class="x402-name" data-merchant>${escapeHtml(this.opts.merchant || 'Payment')}</div>
804
+ <div class="x402-action" data-action>${escapeHtml(this.opts.action || 'Pay-per-call')}</div>
805
+ </div>
806
+ <button class="x402-close" data-close aria-label="Close">✕</button>
807
+ </div>
808
+ <div class="x402-price-row">
809
+ <div class="x402-price" data-price>—<span class="x402-currency"> USDC</span></div>
810
+ <div class="x402-network" data-network>resolving…</div>
811
+ </div>
812
+ <div class="x402-body" data-body></div>
813
+ <div class="x402-foot">
814
+ <span class="x402-secure">${escapeHtml(CONFIG.footerNote)}</span>
815
+ ${CONFIG.brand?.name ? `<a href="${escapeHtml(CONFIG.brand.url || '#')}" target="_blank" rel="noopener">Powered by ${escapeHtml(CONFIG.brand.name)}</a>` : ''}
816
+ </div>
817
+ </div>
818
+ `;
819
+ document.body.appendChild(overlay);
820
+ this.overlay = overlay;
821
+ this.bodyEl = overlay.querySelector('[data-body]');
822
+ this.priceEl = overlay.querySelector('[data-price]');
823
+ this.networkEl = overlay.querySelector('[data-network]');
824
+ overlay.querySelector('[data-close]').addEventListener('click', () => this.close('cancelled'));
825
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close('cancelled'); });
826
+ this.onKey = (e) => { if (e.key === 'Escape') this.close('cancelled'); };
827
+ document.addEventListener('keydown', this.onKey);
828
+ requestAnimationFrame(() => overlay.classList.add('x402-open'));
829
+ return new Promise((resolve, reject) => {
830
+ this.resolve = resolve;
831
+ this.reject = reject;
832
+ });
833
+ }
834
+
835
+ close(reason) {
836
+ if (this.disposed) return;
837
+ this.disposed = true;
838
+ document.removeEventListener('keydown', this.onKey);
839
+ this.overlay.classList.remove('x402-open');
840
+ setTimeout(() => this.overlay.remove(), 180);
841
+ if (reason === 'cancelled' && this.reject) {
842
+ const err = new Error('cancelled');
843
+ err.code = 'cancelled';
844
+ this.reject(err);
845
+ }
846
+ }
847
+
848
+ renderSteps(activeId, status = {}) {
849
+ const html = this.steps
850
+ .map((s) => {
851
+ const state = status[s.id] || (s.id === activeId ? 'active' : 'idle');
852
+ const cls = state === 'active' ? 'x402-active' : state === 'done' ? 'x402-done' : state === 'error' ? 'x402-error' : '';
853
+ const meta = status[`${s.id}_meta`] || '';
854
+ const sym = state === 'done' ? '✓' : state === 'error' ? '!' : s.id === activeId && state === 'active' ? ' ' : (this.steps.findIndex((x) => x.id === s.id) + 1);
855
+ return `<div class="x402-step ${cls}">
856
+ <div class="x402-step-num">${sym}</div>
857
+ <div class="x402-step-body">
858
+ <div class="x402-step-label">${s.label}</div>
859
+ ${meta ? `<div class="x402-step-meta">${escapeHtml(meta)}</div>` : ''}
860
+ </div>
861
+ </div>`;
862
+ })
863
+ .join('');
864
+ return html;
865
+ }
866
+
867
+ setPrice(accept) {
868
+ const info = tokenInfo(accept);
869
+ const amount = formatAmount(accept.amount, info.decimals);
870
+ const glyph = info.glyph ? `${info.glyph} ` : '';
871
+ this.priceEl.innerHTML = `${amount}<span class="x402-currency"> ${glyph}${escapeHtml(info.symbol)}</span>`;
872
+ this.networkEl.textContent = networkLabel(accept.network, accept);
873
+ }
874
+
875
+ renderConnect() {
876
+ const phantomDetected = typeof window !== 'undefined' && (window.solana?.isPhantom || window.phantom?.solana);
877
+ const evmDetected = typeof window !== 'undefined' && window.ethereum;
878
+ const solanaList = listSolanaAccepts(this.challenge);
879
+ // Keep a valid selection: honour the buyer's prior pick if it's still on
880
+ // offer, otherwise fall back to the default (USDC-first).
881
+ let solanaAccept = solanaList.find((a) => a === this.solanaAccept) || pickDefaultSolana(solanaList);
882
+ this.solanaAccept = solanaAccept;
883
+ const evmAccept = this.challenge?.accepts.find(isEip3009Accept);
884
+
885
+ // SIWX-first path: when the 402 advertises sign-in-with-x AND we have a
886
+ // compatible wallet, lead with "Sign in with wallet" (primary) and
887
+ // demote pay to a secondary action. payFlowOverride is set true when
888
+ // the user explicitly chooses to pay (either by clicking the secondary
889
+ // button, or after a 401/402 siwx_not_paid retry told us this wallet
890
+ // hasn't actually paid for this resource yet).
891
+ if (this.siwx && !this.payFlowOverride) {
892
+ const siwxSolana = phantomDetected ? pickSiwxChain(this.siwx, 'solana') : null;
893
+ const siwxEvm = evmDetected ? pickSiwxChain(this.siwx, 'evm') : null;
894
+ if (siwxSolana || siwxEvm) {
895
+ this.renderSiwxChoice({ siwxSolana, siwxEvm });
896
+ return;
897
+ }
898
+ }
899
+
900
+ // autoConnect (opt-in via opts.autoConnect): when the caller knows the
901
+ // user is wallet-ready and shouldn't have to pick, skip the picker and go
902
+ // straight to the signature — but only when exactly one supported wallet
903
+ // is actually detected. Zero wallets (must install) or two (must choose)
904
+ // still fall through to the picker, as does the SIWX "you haven't paid"
905
+ // fallback, which needs to explain itself. One-shot via autoConnectTried.
906
+ if (this.opts.autoConnect && !this.autoConnectTried && !this.siwxFallbackNotice) {
907
+ this.autoConnectTried = true;
908
+ const solanaViable = !!(solanaAccept && phantomDetected);
909
+ const evmViable = !!(evmAccept && evmDetected);
910
+ if (solanaViable && !evmViable) { this.runSolana(solanaAccept); return; }
911
+ if (evmViable && !solanaViable) { this.runEvm(evmAccept); return; }
912
+ }
913
+
914
+ // When the merchant offers more than one Solana token (e.g. USDC and
915
+ // THREE), let the buyer choose which to pay in. The selected token drives
916
+ // the headline price and the transaction the Phantom button builds.
917
+ const tokenChooser = solanaList.length > 1
918
+ ? `<div class="x402-token-row" role="group" aria-label="Choose payment token">
919
+ ${solanaList.map((a) => {
920
+ const info = tokenInfo(a);
921
+ const on = a === solanaAccept;
922
+ const amt = formatAmount(a.amount, info.decimals);
923
+ return `<button type="button" class="x402-token-pill${on ? ' x402-on' : ''}" data-token-asset="${escapeHtml(a.asset)}" aria-pressed="${on}"${info.accent && on ? ` style="border-color:${escapeHtml(info.accent)}"` : ''}>
924
+ <span class="x402-token-sym">${info.glyph ? escapeHtml(info.glyph) + ' ' : ''}${escapeHtml(info.symbol)}</span>
925
+ <span class="x402-token-amt">${amt}</span>
926
+ </button>`;
927
+ }).join('')}
928
+ </div>`
929
+ : '';
930
+
931
+ const buttons = [];
932
+ if (solanaAccept) {
933
+ const solInfo = tokenInfo(solanaAccept);
934
+ const solMeta = solanaList.length > 1
935
+ ? `${networkLabel(solanaAccept.network, solanaAccept)} · ${solInfo.symbol}`
936
+ : networkLabel(solanaAccept.network, solanaAccept);
937
+ buttons.push(`
938
+ <button class="x402-wallet-btn" data-wallet="phantom" ${phantomDetected ? '' : 'disabled'}>
939
+ <div class="x402-wallet-icon x402-phantom">P</div>
940
+ <span class="x402-wallet-name">${phantomDetected ? 'Phantom' : 'Phantom (not detected)'}</span>
941
+ <span class="x402-wallet-meta" data-sol-meta>${escapeHtml(solMeta)}</span>
942
+ </button>
943
+ `);
944
+ }
945
+ if (evmAccept) {
946
+ buttons.push(`
947
+ <button class="x402-wallet-btn" data-wallet="evm" ${evmDetected ? '' : 'disabled'}>
948
+ <div class="x402-wallet-icon x402-metamask">M</div>
949
+ <span class="x402-wallet-name">${evmDetected ? 'Browser wallet' : 'No EVM wallet detected'}</span>
950
+ <span class="x402-wallet-meta">${networkLabel(evmAccept.network, evmAccept)}</span>
951
+ </button>
952
+ `);
953
+ }
954
+ const fallbackBox = this.siwxFallbackNotice
955
+ ? `<div class="x402-siwx-fallback">${escapeHtml(this.siwxFallbackNotice)}</div>`
956
+ : '';
957
+ this.bodyEl.innerHTML = `
958
+ ${this.renderSteps('connect', { discover: 'done' })}
959
+ ${fallbackBox}
960
+ ${tokenChooser}
961
+ <div class="x402-wallet-buttons">${buttons.join('')}</div>
962
+ `;
963
+ const onClick = (e) => {
964
+ const btn = e.target.closest('[data-wallet]');
965
+ if (!btn || btn.disabled) return;
966
+ const wallet = btn.dataset.wallet;
967
+ if (wallet === 'phantom') this.runSolana(this.solanaAccept);
968
+ else if (wallet === 'evm') this.runEvm(evmAccept);
969
+ };
970
+ this.bodyEl.querySelectorAll('[data-wallet]').forEach((b) => b.addEventListener('click', onClick));
971
+
972
+ // Token chooser — switch the active Solana token in place: update the
973
+ // headline price and the Phantom button's token label without a re-render
974
+ // flash, and mark the new pill pressed.
975
+ this.bodyEl.querySelectorAll('[data-token-asset]').forEach((pill) => {
976
+ pill.addEventListener('click', () => {
977
+ const next = solanaList.find((a) => a.asset === pill.dataset.tokenAsset);
978
+ if (!next || next === this.solanaAccept) return;
979
+ this.solanaAccept = next;
980
+ this.accept = next;
981
+ this.setPrice(next);
982
+ const info = tokenInfo(next);
983
+ const metaEl = this.bodyEl.querySelector('[data-sol-meta]');
984
+ if (metaEl) metaEl.textContent = `${networkLabel(next.network, next)} · ${info.symbol}`;
985
+ this.bodyEl.querySelectorAll('[data-token-asset]').forEach((p) => {
986
+ const on = p === pill;
987
+ p.classList.toggle('x402-on', on);
988
+ p.setAttribute('aria-pressed', String(on));
989
+ p.style.borderColor = on && info.accent ? info.accent : '';
990
+ });
991
+ });
992
+ });
993
+ }
994
+
995
+ renderSiwxChoice({ siwxSolana, siwxEvm }) {
996
+ const priceInfo = tokenInfo(this.accept);
997
+ const priceText = formatAmount(this.accept.amount, priceInfo.decimals);
998
+ // One primary button — internally we pick the wallet kind that matches
999
+ // the supported SIWX chains AND the detected wallets. Phantom wins ties
1000
+ // to match the existing modal's default preference.
1001
+ const siwxTarget = siwxSolana
1002
+ ? { kind: 'solana', chain: siwxSolana.chain }
1003
+ : { kind: 'evm', chain: siwxEvm.chain };
1004
+ const siwxLabel = siwxTarget.kind === 'solana' ? 'Sign in with Phantom' : 'Sign in with wallet';
1005
+ this.bodyEl.innerHTML = `
1006
+ ${this.renderSteps('connect', { discover: 'done' })}
1007
+ <button class="x402-pay-btn" data-action="siwx">${siwxLabel}</button>
1008
+ <button class="x402-pay-secondary" data-action="pay">Pay ${priceText} ${escapeHtml(priceInfo.symbol)} instead</button>
1009
+ <div class="x402-siwx-hint">Already paid for this once? Sign in to re-enter without paying again.</div>
1010
+ `;
1011
+ const siwxBtn = this.bodyEl.querySelector('[data-action="siwx"]');
1012
+ const payBtn = this.bodyEl.querySelector('[data-action="pay"]');
1013
+ siwxBtn.addEventListener('click', () => {
1014
+ if (siwxTarget.kind === 'solana') this.runSiwxSolana(siwxTarget.chain);
1015
+ else this.runSiwxEvm(siwxTarget.chain);
1016
+ });
1017
+ payBtn.addEventListener('click', () => {
1018
+ this.payFlowOverride = true;
1019
+ this.renderConnect();
1020
+ });
1021
+ // Focus the primary SIWX button for keyboard accessibility.
1022
+ requestAnimationFrame(() => siwxBtn.focus());
1023
+ }
1024
+
1025
+ renderProgress(activeId, meta = {}) {
1026
+ this.bodyEl.innerHTML = this.renderSteps(activeId, {
1027
+ discover: 'done',
1028
+ connect: 'done',
1029
+ ...(activeId === 'verify' ? { authorize: 'done' } : {}),
1030
+ [`${activeId}_meta`]: meta.text || '',
1031
+ ...meta.statuses,
1032
+ });
1033
+ }
1034
+
1035
+ renderError(stepId, message) {
1036
+ this.bodyEl.innerHTML = `
1037
+ ${this.renderSteps(stepId, {
1038
+ ...(stepId !== 'discover' ? { discover: 'done' } : {}),
1039
+ ...(stepId === 'authorize' || stepId === 'verify' ? { connect: 'done' } : {}),
1040
+ ...(stepId === 'verify' ? { authorize: 'done' } : {}),
1041
+ [stepId]: 'error',
1042
+ [`${stepId}_meta`]: 'failed',
1043
+ })}
1044
+ <div class="x402-error-box"><strong>${escapeHtml(stepId)}:</strong> ${escapeHtml(message)}</div>
1045
+ <button class="x402-pay-btn" data-retry>Try again</button>
1046
+ `;
1047
+ this.bodyEl.querySelector('[data-retry]').addEventListener('click', () => this.start());
1048
+ }
1049
+
1050
+ renderDone({ result, payment, siwx }) {
1051
+ const resultStr = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
1052
+ let receiptHtml;
1053
+ if (siwx) {
1054
+ const addrShort = siwx.address ? `${siwx.address.slice(0, 8)}…${siwx.address.slice(-6)}` : '—';
1055
+ receiptHtml = `
1056
+ <div class="x402-receipt">
1057
+ <div class="x402-receipt-title">Welcome back!</div>
1058
+ <div class="x402-receipt-row">
1059
+ <span class="x402-k">network</span>
1060
+ <span class="x402-v">${escapeHtml(networkLabel(siwx.network) || siwx.network || '—')}</span>
1061
+ </div>
1062
+ <div class="x402-receipt-row">
1063
+ <span class="x402-k">wallet</span>
1064
+ <span class="x402-v">${escapeHtml(addrShort)}</span>
1065
+ </div>
1066
+ <div class="x402-receipt-row">
1067
+ <span class="x402-k">paid</span>
1068
+ <span class="x402-v">previously · re-entered free</span>
1069
+ </div>
1070
+ </div>
1071
+ `;
1072
+ } else {
1073
+ const explorer = explorerUrl(payment?.network, payment?.transaction);
1074
+ const txShort = payment?.transaction ? `${payment.transaction.slice(0, 8)}…${payment.transaction.slice(-6)}` : '—';
1075
+ receiptHtml = `
1076
+ <div class="x402-receipt">
1077
+ <div class="x402-receipt-title">Payment confirmed!</div>
1078
+ <div class="x402-receipt-row">
1079
+ <span class="x402-k">network</span>
1080
+ <span class="x402-v">${escapeHtml(networkLabel(payment?.network) || '—')}</span>
1081
+ </div>
1082
+ <div class="x402-receipt-row">
1083
+ <span class="x402-k">payer</span>
1084
+ <span class="x402-v">${escapeHtml(payment?.payer ? `${payment.payer.slice(0, 8)}…${payment.payer.slice(-6)}` : '—')}</span>
1085
+ </div>
1086
+ ${
1087
+ payment?.transaction
1088
+ ? `<div class="x402-receipt-row"><span class="x402-k">tx</span><span class="x402-v">${
1089
+ explorer ? `<a href="${explorer}" target="_blank" rel="noopener">${txShort} ↗</a>` : txShort
1090
+ }</span></div>`
1091
+ : ''
1092
+ }
1093
+ </div>
1094
+ `;
1095
+ }
1096
+ this.bodyEl.innerHTML = `
1097
+ ${receiptHtml}
1098
+ <div class="x402-result">${escapeHtml(resultStr).slice(0, 4000)}</div>
1099
+ <button class="x402-pay-btn" data-done>Done</button>
1100
+ `;
1101
+ this.bodyEl.querySelector('[data-done]').addEventListener('click', () => {
1102
+ this.disposed = true;
1103
+ document.removeEventListener('keydown', this.onKey);
1104
+ this.overlay.classList.remove('x402-open');
1105
+ setTimeout(() => this.overlay.remove(), 180);
1106
+ });
1107
+ }
1108
+
1109
+ async start() {
1110
+ this.bodyEl.innerHTML = this.renderSteps('discover');
1111
+ try {
1112
+ const challenge = await discoverChallenge(this.opts);
1113
+ this.challenge = challenge;
1114
+ this.siwx = extractSiwxExtension(challenge);
1115
+ this.payFlowOverride = false;
1116
+ this.siwxFallbackNotice = null;
1117
+ // Prefer Solana when Phantom is present, else first EIP-3009 EVM
1118
+ // entry (skipping Permit2 siblings the modal can't sign for), else
1119
+ // first accept.
1120
+ const solanaList = listSolanaAccepts(challenge);
1121
+ const solana = pickDefaultSolana(solanaList);
1122
+ this.solanaAccept = solana; // buyer-selected Solana token (USDC/THREE/…)
1123
+ const evm = challenge.accepts.find(isEip3009Accept);
1124
+ const phantomDetected = typeof window !== 'undefined' && (window.solana?.isPhantom || window.phantom?.solana);
1125
+ this.accept = (phantomDetected && solana) || evm || challenge.accepts[0];
1126
+ this.setPrice(this.accept);
1127
+ this.renderConnect();
1128
+ } catch (err) {
1129
+ this.renderError('discover', err.message || String(err));
1130
+ }
1131
+ }
1132
+
1133
+ async runSolana(accept) {
1134
+ this.accept = accept;
1135
+ this.setPrice(accept);
1136
+ this.renderProgress('connect', { text: 'Opening Phantom…' });
1137
+ try {
1138
+ const provider = window.phantom?.solana || window.solana;
1139
+ if (!provider) throw new Error('Phantom wallet not detected');
1140
+ const conn = await provider.connect();
1141
+ const payerAddress = (conn?.publicKey || provider.publicKey)?.toString();
1142
+ if (!payerAddress) throw new Error('Phantom did not return a public key');
1143
+ this.payerAddress = payerAddress;
1144
+ const capCheck = browserEnforceCap({
1145
+ accept,
1146
+ caps: this.opts.caps,
1147
+ address: payerAddress,
1148
+ });
1149
+ if (capCheck.abort) {
1150
+ this.renderError('authorize', capCheck.reason);
1151
+ return;
1152
+ }
1153
+ this.spendReservation = capCheck.reservation || null;
1154
+ this.renderProgress('authorize', { text: `Building Solana payment for ${payerAddress.slice(0, 6)}…${payerAddress.slice(-4)}` });
1155
+
1156
+ const prep = await postJson(`${checkoutBase()}?action=prepare`, {
1157
+ accept,
1158
+ buyer: payerAddress,
1159
+ });
1160
+ this.renderProgress('authorize', { text: 'Confirm in Phantom…' });
1161
+ const txBytes = base64ToUint8Array(prep.tx_base64);
1162
+ // Phantom returns a fully-signed VersionedTransaction with the buyer's
1163
+ // signature added. The facilitator's fee-payer signature is added by
1164
+ // PayAI during /settle.
1165
+ const SolanaWeb3 = await loadSolanaWeb3();
1166
+ const tx = SolanaWeb3.VersionedTransaction.deserialize(txBytes);
1167
+ const signed = await provider.signTransaction(tx);
1168
+ const signedB64 = uint8ArrayToBase64(signed.serialize());
1169
+
1170
+ const builderCodeBlock = buildBuilderCodeEcho(this.challenge);
1171
+ const enc = await postJson(`${checkoutBase()}?action=encode`, {
1172
+ accept,
1173
+ signed_tx_base64: signedB64,
1174
+ resource_url: new URL(this.opts.endpoint, location.href).href,
1175
+ ...(builderCodeBlock ? { builder_code: builderCodeBlock } : {}),
1176
+ });
1177
+
1178
+ await this.executePaid(enc.x_payment);
1179
+ } catch (err) {
1180
+ if (this.spendReservation) {
1181
+ browserRollbackReservation(this.spendReservation);
1182
+ this.spendReservation = null;
1183
+ }
1184
+ this.renderError(this.payerAddress ? 'authorize' : 'connect', friendlyError(err));
1185
+ }
1186
+ }
1187
+
1188
+ async runEvm(accept) {
1189
+ this.accept = accept;
1190
+ this.setPrice(accept);
1191
+ this.renderProgress('connect', { text: 'Opening browser wallet…' });
1192
+ try {
1193
+ const eth = window.ethereum;
1194
+ if (!eth) throw new Error('No EVM wallet detected');
1195
+ const accounts = await eth.request({ method: 'eth_requestAccounts' });
1196
+ const payerAddress = accounts?.[0];
1197
+ if (!payerAddress) throw new Error('Wallet did not return an account');
1198
+ this.payerAddress = payerAddress;
1199
+ const capCheck = browserEnforceCap({
1200
+ accept,
1201
+ caps: this.opts.caps,
1202
+ address: payerAddress,
1203
+ });
1204
+ if (capCheck.abort) {
1205
+ this.renderError('authorize', capCheck.reason);
1206
+ return;
1207
+ }
1208
+ this.spendReservation = capCheck.reservation || null;
1209
+
1210
+ const meta = EVM_NETWORKS[accept.network];
1211
+ if (!meta) throw new Error(`Unknown EVM network ${accept.network}`);
1212
+ // Switch chain if needed.
1213
+ const currentChainHex = await eth.request({ method: 'eth_chainId' });
1214
+ const desiredChainHex = '0x' + meta.chainId.toString(16);
1215
+ if (currentChainHex !== desiredChainHex) {
1216
+ this.renderProgress('connect', { text: `Switch wallet to ${meta.name}…` });
1217
+ try {
1218
+ await eth.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: desiredChainHex }] });
1219
+ } catch (e) {
1220
+ throw new Error(`Wallet is on ${currentChainHex}; please switch to ${meta.name} (${desiredChainHex}) and retry`);
1221
+ }
1222
+ }
1223
+
1224
+ this.renderProgress('authorize', { text: `Authorize ${formatAmount(accept.amount)} USDC…` });
1225
+
1226
+ // EIP-3009 transferWithAuthorization typed-data signature.
1227
+ // validAfter / validBefore use unix seconds; nonce is a random 32-byte hex.
1228
+ const validAfter = 0;
1229
+ const validBefore = Math.floor(Date.now() / 1000) + (accept.maxTimeoutSeconds || 600);
1230
+ const nonce = '0x' + randomHex(32);
1231
+ const domain = {
1232
+ name: accept.extra?.name || 'USD Coin',
1233
+ version: accept.extra?.version || '2',
1234
+ chainId: meta.chainId,
1235
+ verifyingContract: accept.asset,
1236
+ };
1237
+ const types = {
1238
+ EIP712Domain: [
1239
+ { name: 'name', type: 'string' },
1240
+ { name: 'version', type: 'string' },
1241
+ { name: 'chainId', type: 'uint256' },
1242
+ { name: 'verifyingContract', type: 'address' },
1243
+ ],
1244
+ TransferWithAuthorization: [
1245
+ { name: 'from', type: 'address' },
1246
+ { name: 'to', type: 'address' },
1247
+ { name: 'value', type: 'uint256' },
1248
+ { name: 'validAfter', type: 'uint256' },
1249
+ { name: 'validBefore', type: 'uint256' },
1250
+ { name: 'nonce', type: 'bytes32' },
1251
+ ],
1252
+ };
1253
+ const message = {
1254
+ from: payerAddress,
1255
+ to: accept.payTo,
1256
+ value: accept.amount,
1257
+ validAfter,
1258
+ validBefore,
1259
+ nonce,
1260
+ };
1261
+ const typedData = {
1262
+ primaryType: 'TransferWithAuthorization',
1263
+ types,
1264
+ domain,
1265
+ message,
1266
+ };
1267
+ const signature = await eth.request({
1268
+ method: 'eth_signTypedData_v4',
1269
+ params: [payerAddress, JSON.stringify(typedData)],
1270
+ });
1271
+
1272
+ const paymentPayload = {
1273
+ x402Version: 2,
1274
+ scheme: 'exact',
1275
+ network: accept.network,
1276
+ resource: { url: this.opts.endpoint, mimeType: 'application/json' },
1277
+ accepted: accept,
1278
+ payload: {
1279
+ signature,
1280
+ // CDP facilitator /verify requires the EIP-3009 time bounds as
1281
+ // decimal strings, not JSON numbers — a numeric validAfter/
1282
+ // validBefore is rejected with "'paymentPayload' is invalid".
1283
+ // The signature is unaffected: uint256 0 and "0" encode identically.
1284
+ authorization: { from: payerAddress, to: accept.payTo, value: accept.amount, validAfter: String(validAfter), validBefore: String(validBefore), nonce },
1285
+ },
1286
+ };
1287
+ const builderCodeBlock = buildBuilderCodeEcho(this.challenge);
1288
+ if (builderCodeBlock) {
1289
+ paymentPayload.extensions = { 'builder-code': builderCodeBlock };
1290
+ }
1291
+ const xPayment = b64encode(paymentPayload);
1292
+ await this.executePaid(xPayment);
1293
+ } catch (err) {
1294
+ if (this.spendReservation) {
1295
+ browserRollbackReservation(this.spendReservation);
1296
+ this.spendReservation = null;
1297
+ }
1298
+ this.renderError(this.payerAddress ? 'authorize' : 'connect', friendlyError(err));
1299
+ }
1300
+ }
1301
+
1302
+ async executePaid(xPayment, attempt = 0) {
1303
+ this.renderProgress('verify', {
1304
+ text: attempt ? 'Retrying after upstream throttle…' : 'Calling merchant endpoint…',
1305
+ });
1306
+ try {
1307
+ const res = await fetch(this.opts.endpoint, {
1308
+ method: this.opts.method || 'GET',
1309
+ headers: {
1310
+ ...(this.opts.headers || {}),
1311
+ ...(this.opts.body && !this.opts.headers?.['content-type'] ? { 'content-type': 'application/json' } : {}),
1312
+ 'X-PAYMENT': xPayment,
1313
+ },
1314
+ body: this.opts.body ? (typeof this.opts.body === 'string' ? this.opts.body : JSON.stringify(this.opts.body)) : undefined,
1315
+ });
1316
+ const ct = res.headers.get('content-type') || '';
1317
+ const text = await res.text();
1318
+ let result;
1319
+ if (ct.includes('json')) {
1320
+ try {
1321
+ result = JSON.parse(text);
1322
+ } catch {
1323
+ result = text;
1324
+ }
1325
+ } else {
1326
+ result = text;
1327
+ }
1328
+ if (!res.ok) {
1329
+ // A 429 here is a transient upstream throttle (e.g. the generator's
1330
+ // create-prediction rate limit). The payment is signed but NOT yet
1331
+ // settled — the merchant runs the work before settling — so the same
1332
+ // X-PAYMENT can be safely re-sent once the window resets, with no risk
1333
+ // of a double charge. Auto-retry a couple of times, respecting the
1334
+ // server's Retry-After, before surfacing the manual "Try again".
1335
+ if (res.status === 429 && attempt < MAX_THROTTLE_RETRIES) {
1336
+ await this.waitForThrottle(retryAfterSeconds(res, result));
1337
+ return this.executePaid(xPayment, attempt + 1);
1338
+ }
1339
+ const msg = (result && typeof result === 'object' && (result.error_description || result.error)) || `HTTP ${res.status}`;
1340
+ throw new Error(msg);
1341
+ }
1342
+ const settleHeader = res.headers.get('x-payment-response');
1343
+ const payment = b64decode(settleHeader) || {};
1344
+ this.spendReservation = null;
1345
+ this.renderDone({ result, payment });
1346
+ this.resolve?.({ ok: true, result, payment, response: { status: res.status, headers: headersToObject(res.headers) } });
1347
+ } catch (err) {
1348
+ if (this.spendReservation) {
1349
+ browserRollbackReservation(this.spendReservation);
1350
+ this.spendReservation = null;
1351
+ }
1352
+ this.renderError('verify', friendlyError(err));
1353
+ }
1354
+ }
1355
+
1356
+ // Hold the verify step on a live countdown while an upstream throttle resets,
1357
+ // then return so the caller re-sends the same signed payment. The reservation
1358
+ // is deliberately left intact — this is the same payment, not a new one — so
1359
+ // no rollback runs between attempts.
1360
+ async waitForThrottle(seconds) {
1361
+ const total = Math.max(1, Math.min(30, Math.round(seconds) || 6));
1362
+ for (let left = total; left > 0; left--) {
1363
+ this.renderProgress('verify', { text: `Generator is busy — retrying in ${left}s…` });
1364
+ await new Promise((r) => setTimeout(r, 1000));
1365
+ }
1366
+ this.renderProgress('verify', { text: 'Retrying…' });
1367
+ }
1368
+
1369
+ async runSiwxEvm(chain) {
1370
+ this.renderProgress('connect', { text: 'Opening browser wallet…' });
1371
+ try {
1372
+ const eth = window.ethereum;
1373
+ if (!eth) throw new Error('No EVM wallet detected');
1374
+ const accounts = await eth.request({ method: 'eth_requestAccounts' });
1375
+ const rawAddress = accounts?.[0];
1376
+ if (!rawAddress) throw new Error('Wallet did not return an account');
1377
+ const checksum = await loadEvmChecksum();
1378
+ const address = checksum(rawAddress);
1379
+ this.payerAddress = address;
1380
+ this.renderProgress('authorize', { text: `Sign sign-in message as ${address.slice(0, 6)}…${address.slice(-4)}` });
1381
+
1382
+ const message = buildSiwxMessage(this.siwx.info, chain, address);
1383
+ const signature = await eth.request({
1384
+ method: 'personal_sign',
1385
+ params: [message, address],
1386
+ });
1387
+
1388
+ const info = this.siwx.info;
1389
+ const payload = {
1390
+ domain: info.domain,
1391
+ address,
1392
+ ...(info.statement ? { statement: info.statement } : {}),
1393
+ uri: info.uri,
1394
+ version: info.version || '1',
1395
+ chainId: chain.chainId,
1396
+ type: 'eip191',
1397
+ nonce: info.nonce,
1398
+ issuedAt: info.issuedAt,
1399
+ ...(info.expirationTime ? { expirationTime: info.expirationTime } : {}),
1400
+ ...(info.notBefore ? { notBefore: info.notBefore } : {}),
1401
+ ...(info.requestId !== undefined && info.requestId !== null ? { requestId: info.requestId } : {}),
1402
+ ...(Array.isArray(info.resources) ? { resources: info.resources } : {}),
1403
+ signatureScheme: 'eip191',
1404
+ signature,
1405
+ };
1406
+ await this.executeSiwx(payload, chain.chainId);
1407
+ } catch (err) {
1408
+ this.renderError(this.payerAddress ? 'authorize' : 'connect', friendlyError(err));
1409
+ }
1410
+ }
1411
+
1412
+ async runSiwxSolana(chain) {
1413
+ this.renderProgress('connect', { text: 'Opening Phantom…' });
1414
+ try {
1415
+ const provider = window.phantom?.solana || window.solana;
1416
+ if (!provider) throw new Error('Phantom wallet not detected');
1417
+ const conn = await provider.connect();
1418
+ const pubkey = conn?.publicKey || provider.publicKey;
1419
+ const address = pubkey?.toString();
1420
+ if (!address) throw new Error('Phantom did not return a public key');
1421
+ this.payerAddress = address;
1422
+ this.renderProgress('authorize', { text: `Sign sign-in message as ${address.slice(0, 6)}…${address.slice(-4)}` });
1423
+
1424
+ const message = buildSiwxMessage(this.siwx.info, chain, address);
1425
+ const encoded = new TextEncoder().encode(message);
1426
+ const signed = await provider.signMessage(encoded, 'utf8');
1427
+ const sigBytes = signed?.signature instanceof Uint8Array ? signed.signature : new Uint8Array(signed?.signature || signed);
1428
+ if (!sigBytes || !sigBytes.length) throw new Error('Phantom did not return a signature');
1429
+ const signature = base58encode(sigBytes);
1430
+
1431
+ const info = this.siwx.info;
1432
+ const payload = {
1433
+ domain: info.domain,
1434
+ address,
1435
+ ...(info.statement ? { statement: info.statement } : {}),
1436
+ uri: info.uri,
1437
+ version: info.version || '1',
1438
+ chainId: chain.chainId,
1439
+ type: 'ed25519',
1440
+ nonce: info.nonce,
1441
+ issuedAt: info.issuedAt,
1442
+ ...(info.expirationTime ? { expirationTime: info.expirationTime } : {}),
1443
+ ...(info.notBefore ? { notBefore: info.notBefore } : {}),
1444
+ ...(info.requestId !== undefined && info.requestId !== null ? { requestId: info.requestId } : {}),
1445
+ ...(Array.isArray(info.resources) ? { resources: info.resources } : {}),
1446
+ signatureScheme: 'siws',
1447
+ signature,
1448
+ };
1449
+ await this.executeSiwx(payload, chain.chainId);
1450
+ } catch (err) {
1451
+ this.renderError(this.payerAddress ? 'authorize' : 'connect', friendlyError(err));
1452
+ }
1453
+ }
1454
+
1455
+ async executeSiwx(payload, chainId) {
1456
+ this.renderProgress('verify', { text: 'Verifying sign-in…' });
1457
+ const headerValue = encodeSiwxHeaderValue(payload);
1458
+ let res;
1459
+ try {
1460
+ res = await fetch(this.opts.endpoint, {
1461
+ method: this.opts.method || 'GET',
1462
+ headers: {
1463
+ ...(this.opts.headers || {}),
1464
+ ...(this.opts.body && !this.opts.headers?.['content-type'] ? { 'content-type': 'application/json' } : {}),
1465
+ [SIWX_HEADER]: headerValue,
1466
+ },
1467
+ body: this.opts.body ? (typeof this.opts.body === 'string' ? this.opts.body : JSON.stringify(this.opts.body)) : undefined,
1468
+ });
1469
+ } catch (err) {
1470
+ this.renderError('verify', friendlyError(err));
1471
+ return;
1472
+ }
1473
+
1474
+ if (res.status === 200) {
1475
+ const ct = res.headers.get('content-type') || '';
1476
+ const text = await res.text();
1477
+ let result;
1478
+ if (ct.includes('json')) {
1479
+ try { result = JSON.parse(text); } catch { result = text; }
1480
+ } else {
1481
+ result = text;
1482
+ }
1483
+ const siwx = { address: payload.address, network: chainId };
1484
+ this.renderDone({ result, siwx });
1485
+ this.resolve?.({
1486
+ ok: true,
1487
+ result,
1488
+ siwx,
1489
+ response: { status: res.status, headers: headersToObject(res.headers) },
1490
+ });
1491
+ return;
1492
+ }
1493
+
1494
+ if (res.status === 401 || res.status === 402) {
1495
+ // Most likely: signature verified but this wallet hasn't actually
1496
+ // paid for the resource yet. Drop the SIWX offering and fall back
1497
+ // to the normal payment flow with a one-line notice.
1498
+ let parsed = null;
1499
+ try { parsed = await res.clone().json(); } catch (_) {}
1500
+ const code = parsed?.code || parsed?.error;
1501
+ this.siwx = null;
1502
+ this.payerAddress = null;
1503
+ this.payFlowOverride = false;
1504
+ this.siwxFallbackNotice = code === 'siwx_not_paid' || res.status === 402
1505
+ ? "You haven't paid for this yet — pay now to unlock re-entry."
1506
+ : 'Sign-in not accepted — please pay to continue.';
1507
+ // Re-render the wallet picker. If we had collected the 402 challenge
1508
+ // already, this just re-runs renderConnect; otherwise we re-discover.
1509
+ if (!this.challenge || !Array.isArray(this.challenge.accepts) || !this.challenge.accepts.length) {
1510
+ this.start();
1511
+ } else {
1512
+ this.renderConnect();
1513
+ }
1514
+ return;
1515
+ }
1516
+
1517
+ const text = await res.text().catch(() => '');
1518
+ this.renderError('verify', `SIWX retry failed: HTTP ${res.status}${text ? ` · ${text.slice(0, 120)}` : ''}`);
1519
+ }
1520
+ }
1521
+
1522
+ // ───────────────────────────────────────────────────────── helpers ──────────
1523
+
1524
+ function escapeHtml(s) {
1525
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
1526
+ }
1527
+
1528
+ function headersToObject(headers) {
1529
+ const out = {};
1530
+ headers.forEach((v, k) => (out[k] = v));
1531
+ return out;
1532
+ }
1533
+
1534
+ // How many times executePaid silently re-sends a signed payment after a 429
1535
+ // throttle before falling back to the manual "Try again". The payment isn't
1536
+ // settled until the merchant call succeeds, so re-sending can't double-charge.
1537
+ const MAX_THROTTLE_RETRIES = 2;
1538
+
1539
+ // Seconds to wait before re-sending after a 429. Prefers the standard
1540
+ // Retry-After header, then the body's `retry_after` hint, then a sane default.
1541
+ function retryAfterSeconds(res, result, fallback = 6) {
1542
+ const header = Number.parseInt(res.headers.get('retry-after') || '', 10);
1543
+ if (Number.isFinite(header) && header > 0) return header;
1544
+ const body = result && typeof result === 'object' ? Number(result.retry_after) : NaN;
1545
+ if (Number.isFinite(body) && body > 0) return body;
1546
+ return fallback;
1547
+ }
1548
+
1549
+ function friendlyError(err) {
1550
+ const msg = err?.shortMessage || err?.message || String(err);
1551
+ // Trim ethers/viem long stacks, Phantom's RPC-error verbosity.
1552
+ if (/user rejected|user denied|reject/i.test(msg)) return 'cancelled in wallet';
1553
+ // Upstream throttles (e.g. a generator's create-prediction rate limit) often
1554
+ // arrive as raw provider text that names the merchant's internal billing or
1555
+ // credit state. Never relay that to the buyer: the payment isn't settled until
1556
+ // the merchant call succeeds, so a clean, retryable message is both safer and
1557
+ // more accurate than echoing the upstream's account internals.
1558
+ if (/throttl|rate.?limit|too many requests|less than \$|in credit|\b429\b/i.test(msg)) {
1559
+ return 'The service is briefly busy and your payment was not taken — retry in a few seconds.';
1560
+ }
1561
+ // The Solana and EVM-sign-in paths dynamic-import a library from esm.sh. A strict
1562
+ // host Content-Security-Policy (or esm.sh being unreachable) blocks that import and
1563
+ // the raw "Failed to fetch dynamically imported module" is opaque. The Base/EIP-3009
1564
+ // payment path has no such dependency, so steer the buyer there.
1565
+ if (/dynamically imported module|esm\.sh|module script failed/i.test(msg)) {
1566
+ return 'A component this wallet path needs (loaded from esm.sh) was blocked — often by a strict host security policy. Pay with MetaMask on Base instead; it needs no third-party code.';
1567
+ }
1568
+ return msg.slice(0, 240);
1569
+ }
1570
+
1571
+ function base64ToUint8Array(b64) {
1572
+ if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(b64, 'base64'));
1573
+ const bin = atob(b64);
1574
+ const arr = new Uint8Array(bin.length);
1575
+ for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
1576
+ return arr;
1577
+ }
1578
+ function uint8ArrayToBase64(arr) {
1579
+ if (typeof Buffer !== 'undefined') return Buffer.from(arr).toString('base64');
1580
+ let bin = '';
1581
+ for (let i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]);
1582
+ return btoa(bin);
1583
+ }
1584
+ function randomHex(bytes) {
1585
+ const arr = new Uint8Array(bytes);
1586
+ crypto.getRandomValues(arr);
1587
+ return Array.from(arr).map((b) => b.toString(16).padStart(2, '0')).join('');
1588
+ }
1589
+
1590
+ let _solanaWeb3 = null;
1591
+ async function loadSolanaWeb3() {
1592
+ if (_solanaWeb3) return _solanaWeb3;
1593
+ // Dynamic import from esm.sh keeps the drop-in script tiny — Solana web3.js
1594
+ // is only fetched when a Solana payment is actually attempted.
1595
+ _solanaWeb3 = await import(/* @vite-ignore */ CONFIG.esm.solanaWeb3);
1596
+ return _solanaWeb3;
1597
+ }
1598
+
1599
+ async function postJson(url, body) {
1600
+ const res = await fetch(url, {
1601
+ method: 'POST',
1602
+ headers: { 'content-type': 'application/json' },
1603
+ body: JSON.stringify(body),
1604
+ });
1605
+ const text = await res.text();
1606
+ let data;
1607
+ try {
1608
+ data = JSON.parse(text);
1609
+ } catch {
1610
+ data = { error: 'parse_error', error_description: text.slice(0, 200) };
1611
+ }
1612
+ if (!res.ok) {
1613
+ const err = new Error(data.error_description || data.error || `HTTP ${res.status}`);
1614
+ err.status = res.status;
1615
+ err.data = data;
1616
+ throw err;
1617
+ }
1618
+ return data;
1619
+ }
1620
+
1621
+ // Probe the merchant endpoint with a benign request to extract the 402 challenge.
1622
+ // Accepts HTTP 402 (standard x402) or HTTP 401 with a `payment-required` header
1623
+ // (MCP 2025-06-18 spec, which uses 401 for resource-server authorization challenges).
1624
+ async function discoverChallenge(opts) {
1625
+ const headers = { ...(opts.headers || {}) };
1626
+ const init = {
1627
+ method: opts.method || 'GET',
1628
+ headers,
1629
+ body: opts.body ? (typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body)) : undefined,
1630
+ };
1631
+ if (init.body && !headers['content-type']) headers['content-type'] = 'application/json';
1632
+ const res = await fetch(opts.endpoint, init);
1633
+
1634
+ // MCP 2025-06-18 endpoints return 401 with the full x402 challenge in the
1635
+ // `payment-required` header (base64-JSON). Accept that alongside standard 402.
1636
+ const prHeader = res.headers.get('payment-required');
1637
+ const is401WithChallenge = res.status === 401 && !!prHeader;
1638
+
1639
+ if (res.status !== 402 && !is401WithChallenge) {
1640
+ // Endpoint isn't paid (200) or isn't an x402 endpoint at all. In either
1641
+ // case, surface a clear error — accidentally pointing the modal at a
1642
+ // free endpoint should not silently succeed.
1643
+ const txt = await res.text();
1644
+ throw new Error(`Endpoint did not return 402 (got ${res.status}). Body: ${txt.slice(0, 120)}`);
1645
+ }
1646
+
1647
+ // For 401+header, decode directly — the full envelope is in the header.
1648
+ // For 402, read body first and fall back to header if body is minimal.
1649
+ let body = is401WithChallenge ? b64decode(prHeader) : await res.json().catch(() => null);
1650
+ if (!body || !Array.isArray(body.accepts) || !body.accepts.length) {
1651
+ // send402 (api/_lib/x402-spec.js) only emits `{error}` in the body and
1652
+ // puts the full v2 PaymentRequired envelope (accepts + extensions) in
1653
+ // the base64-JSON PAYMENT-REQUIRED header. b64decode returns already-
1654
+ // parsed JSON, so use its result directly.
1655
+ const decoded = b64decode(prHeader);
1656
+ if (decoded && Array.isArray(decoded.accepts) && decoded.accepts.length) {
1657
+ body = decoded;
1658
+ }
1659
+ }
1660
+ if (!body || !Array.isArray(body.accepts) || !body.accepts.length) {
1661
+ throw new Error('Endpoint returned 402 but no `accepts` array could be found in body or header');
1662
+ }
1663
+ return body;
1664
+ }
1665
+
1666
+ // ───────────────────────────────────────────────────────── public api ───────
1667
+
1668
+ export async function pay(opts) {
1669
+ if (!opts?.endpoint) throw new Error('X402.pay: endpoint is required');
1670
+ const modal = new CheckoutModal(opts);
1671
+ const result = modal.mount();
1672
+ // kick off the discovery on next tick so the modal animates in first.
1673
+ queueMicrotask(() => modal.start());
1674
+ return result;
1675
+ }
1676
+
1677
+ function bindElement(el) {
1678
+ if (el.dataset.x402Bound === '1') return;
1679
+ el.dataset.x402Bound = '1';
1680
+ el.addEventListener('click', async (e) => {
1681
+ e.preventDefault();
1682
+ const opts = readOptsFrom(el);
1683
+ try {
1684
+ const out = await pay(opts);
1685
+ if (out?.siwx) {
1686
+ el.dispatchEvent(new CustomEvent('x402:siwx-signed', { detail: out.siwx, bubbles: true }));
1687
+ }
1688
+ el.dispatchEvent(new CustomEvent('x402:result', { detail: out, bubbles: true }));
1689
+ } catch (err) {
1690
+ if (err?.code === 'cancelled') return;
1691
+ el.dispatchEvent(new CustomEvent('x402:error', { detail: { error: err?.message || String(err) }, bubbles: true }));
1692
+ }
1693
+ });
1694
+ }
1695
+
1696
+ function readOptsFrom(el) {
1697
+ const ds = el.dataset;
1698
+ let body = ds.x402Body;
1699
+ if (body) {
1700
+ try { body = JSON.parse(body); } catch { /* keep as string */ }
1701
+ }
1702
+ let headers = ds.x402Headers;
1703
+ if (headers) {
1704
+ try { headers = JSON.parse(headers); } catch { headers = undefined; }
1705
+ }
1706
+ return {
1707
+ endpoint: ds.x402Endpoint,
1708
+ method: ds.x402Method || (body ? 'POST' : 'GET'),
1709
+ body,
1710
+ headers,
1711
+ merchant: ds.x402Merchant,
1712
+ action: ds.x402Action || el.textContent?.trim().slice(0, 60),
1713
+ };
1714
+ }
1715
+
1716
+ export function init() {
1717
+ document.querySelectorAll('[data-x402-endpoint]').forEach(bindElement);
1718
+ }
1719
+
1720
+ // Read host config from `data-*` attributes on the <script> tag that loaded
1721
+ // this module, so script-tag users can configure without an inline module:
1722
+ //
1723
+ // <script type="module" src=".../x402.js"
1724
+ // data-x402-checkout-origin="https://pay.acme.com"
1725
+ // data-x402-brand-name="Acme" data-x402-brand-url="https://acme.com"
1726
+ // data-x402-footer-note="Acme Pay"
1727
+ // data-x402-builder-wallet="acme" data-x402-builder-service="acme_checkout"></script>
1728
+ function readScriptConfig() {
1729
+ try {
1730
+ const script =
1731
+ document.currentScript ||
1732
+ document.querySelector('script[src*="x402"][type="module"]') ||
1733
+ document.querySelector('script[data-x402-checkout-origin]');
1734
+ const ds = script?.dataset;
1735
+ if (!ds) return;
1736
+ const next = {};
1737
+ if (ds.x402CheckoutOrigin) next.checkoutOrigin = ds.x402CheckoutOrigin;
1738
+ if (ds.x402CheckoutPath) next.checkoutPath = ds.x402CheckoutPath;
1739
+ if (ds.x402FooterNote) next.footerNote = ds.x402FooterNote;
1740
+ if (ds.x402BrandName || ds.x402BrandUrl) {
1741
+ next.brand = {};
1742
+ if (ds.x402BrandName) next.brand.name = ds.x402BrandName;
1743
+ if (ds.x402BrandUrl) next.brand.url = ds.x402BrandUrl;
1744
+ }
1745
+ if (ds.x402BuilderWallet || ds.x402BuilderService) {
1746
+ next.builderCode = {};
1747
+ if (ds.x402BuilderWallet) next.builderCode.wallet = ds.x402BuilderWallet;
1748
+ if (ds.x402BuilderService) next.builderCode.service = ds.x402BuilderService;
1749
+ }
1750
+ if (Object.keys(next).length) configure(next);
1751
+ } catch (_) {
1752
+ // A missing/odd script tag is non-fatal — defaults still work.
1753
+ }
1754
+ }
1755
+
1756
+ // Auto-init on DOMContentLoaded, plus on demand.
1757
+ if (typeof document !== 'undefined') {
1758
+ readScriptConfig();
1759
+ if (document.readyState === 'loading') {
1760
+ document.addEventListener('DOMContentLoaded', init, { once: true });
1761
+ } else {
1762
+ init();
1763
+ }
1764
+ // Re-scan when merchants dynamically inject buttons.
1765
+ const mo = new MutationObserver(() => init());
1766
+ mo.observe(document.documentElement, { childList: true, subtree: true });
1767
+ }
1768
+
1769
+ // Expose to merchants' inline scripts.
1770
+ if (typeof window !== 'undefined') {
1771
+ window.X402 = Object.freeze({
1772
+ pay, init, configure, version: VERSION,
1773
+ // Well-known Solana settlement assets, so merchants building a 402
1774
+ // challenge inline can reference them without hardcoding base58 mints.
1775
+ tokens: Object.freeze({ USDC_MINT_SOLANA, THREE_MINT, KNOWN_SOLANA_TOKENS }),
1776
+ });
1777
+ }