@three-ws/x402-modal 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1446 @@
1
+ // @three-ws/x402-modal — a drop-in payment modal for any x402 paid endpoint.
2
+ //
3
+ // This is the canonical, side-effect-free core. It exports the public API
4
+ // (`pay`, `init`, `configure`, `getConfig`, `version`, `CheckoutModal`, and the
5
+ // declarative helpers `bindElement` / `readOptsFrom`) but does NOT touch
6
+ // `window` or auto-bind anything on import — that lives in `global.js`, which is
7
+ // what the CDN <script> build ships.
8
+ //
9
+ // Bundler / npm usage:
10
+ //
11
+ // import { pay, configure } from '@three-ws/x402-modal';
12
+ // const out = await pay({ endpoint: '/api/paid/summarize', body: { text: 'hi' } });
13
+ //
14
+ // Drop-in <script> usage (the global build auto-binds `data-x402-endpoint`):
15
+ //
16
+ // <script type="module" src="https://unpkg.com/@three-ws/x402-modal/global"></script>
17
+ // <button data-x402-endpoint="/api/paid/summarize" data-x402-method="POST">Pay & run</button>
18
+ //
19
+ // The modal drives the full 402 → connect wallet → sign → retry → settle flow,
20
+ // renders price/network/steps/receipt, and resolves with { ok, result, payment,
21
+ // response }. Vanilla JS; the only network deps (Solana web3.js, a keccak for
22
+ // EVM SIWX) are dynamic-imported from a CDN, and only when that path runs.
23
+
24
+ import {
25
+ EVM_NETWORKS,
26
+ normalizeAccept,
27
+ isSolanaNetwork,
28
+ isEip3009Accept,
29
+ networkLabel,
30
+ explorerUrl,
31
+ formatAmount,
32
+ b64encode,
33
+ b64decode,
34
+ base58encode,
35
+ toMicroUsd,
36
+ spendBuckets,
37
+ buildSiwxMessage,
38
+ } from './util.js';
39
+
40
+ const VERSION = '0.2.0';
41
+
42
+ // ─────────────────────────────────────────────────────────── configuration ───
43
+ // Everything the host wants to brand or repoint lives here. Defaults reproduce
44
+ // three.ws's hosted behaviour exactly, so the drop-in script is unchanged; a
45
+ // standalone deployment overrides them with `configure()` (global) or per-call
46
+ // `pay({ ... })` options (which always win over the global config).
47
+
48
+ const DEFAULTS = {
49
+ // Origin that serves the Solana `prepare` / `encode` checkout helpers
50
+ // (POST {origin}/api/x402-checkout?action=prepare|encode). Only the Solana
51
+ // payment path uses these — the EVM/EIP-3009 path is fully client-side and
52
+ // needs no backend. `null` ⇒ resolve from the script's own origin at runtime.
53
+ apiOrigin: null,
54
+ // Footer attribution shown at the bottom of the modal.
55
+ brand: { label: 'Powered by three.ws', href: 'https://three.ws' },
56
+ // ERC-8021 builder-code self-attribution echoed back when the 402 challenge
57
+ // declares a builder code. `wallet` = your wallet code, `service` = your
58
+ // integration code. Set to null to disable the echo entirely.
59
+ builderCode: { wallet: '3d_agent', service: '3d_agent_modal' },
60
+ // CDN modules dynamic-imported on demand. Override to self-host / satisfy a
61
+ // strict Content-Security-Policy.
62
+ solanaWeb3Url: 'https://esm.sh/@solana/web3.js@1.95.3?bundle',
63
+ nobleHashesUrl: 'https://esm.sh/@noble/hashes@1.4.0/sha3?bundle',
64
+ };
65
+
66
+ const config = {
67
+ apiOrigin: DEFAULTS.apiOrigin,
68
+ brand: { ...DEFAULTS.brand },
69
+ builderCode: DEFAULTS.builderCode ? { ...DEFAULTS.builderCode } : null,
70
+ solanaWeb3Url: DEFAULTS.solanaWeb3Url,
71
+ nobleHashesUrl: DEFAULTS.nobleHashesUrl,
72
+ };
73
+
74
+ // Resolve the origin that hosts this script — used as the default API origin for
75
+ // the Solana prepare/encode helpers. Falls back to the page origin.
76
+ function resolveScriptOrigin() {
77
+ try {
78
+ if (typeof document !== 'undefined') {
79
+ const current = document.currentScript;
80
+ if (current?.src) return new URL(current.src).origin;
81
+ const found = document.querySelector('script[src*="x402"]');
82
+ if (found?.src) return new URL(found.src).origin;
83
+ }
84
+ } catch (_) {}
85
+ return typeof location !== 'undefined' ? location.origin : '';
86
+ }
87
+
88
+ // Merge user config in. `apiOrigin: ''` is honoured (same-origin); only
89
+ // `undefined` keeps the default. Returns the resolved snapshot for inspection.
90
+ export function configure(opts = {}) {
91
+ if (!opts || typeof opts !== 'object') return getConfig();
92
+ if (opts.apiOrigin !== undefined) config.apiOrigin = opts.apiOrigin;
93
+ if (opts.brand) config.brand = { ...config.brand, ...opts.brand };
94
+ if (opts.builderCode === null) config.builderCode = null;
95
+ else if (opts.builderCode) config.builderCode = { ...(config.builderCode || {}), ...opts.builderCode };
96
+ if (opts.solanaWeb3Url) config.solanaWeb3Url = opts.solanaWeb3Url;
97
+ if (opts.nobleHashesUrl) config.nobleHashesUrl = opts.nobleHashesUrl;
98
+ return getConfig();
99
+ }
100
+
101
+ export function getConfig() {
102
+ return {
103
+ apiOrigin: config.apiOrigin,
104
+ brand: { ...config.brand },
105
+ builderCode: config.builderCode ? { ...config.builderCode } : null,
106
+ solanaWeb3Url: config.solanaWeb3Url,
107
+ nobleHashesUrl: config.nobleHashesUrl,
108
+ };
109
+ }
110
+
111
+ // The effective API origin for a given pay() call: explicit per-call > global
112
+ // config > lazily-resolved script origin (cached back into config).
113
+ function apiOriginFor(opts) {
114
+ if (opts && opts.apiOrigin !== undefined && opts.apiOrigin !== null) return opts.apiOrigin;
115
+ if (config.apiOrigin !== null && config.apiOrigin !== undefined) return config.apiOrigin;
116
+ config.apiOrigin = resolveScriptOrigin();
117
+ return config.apiOrigin;
118
+ }
119
+
120
+ // SIWX ("Sign-In-With-X" / CAIP-122) lets a wallet that has already paid for
121
+ // an endpoint re-enter it by signing a challenge instead of paying again. The
122
+ // server advertises support by including `extensions['sign-in-with-x']` in the
123
+ // 402 body; clients submit signed proofs via the `SIGN-IN-WITH-X` header.
124
+ const SIWX_HEADER = 'SIGN-IN-WITH-X';
125
+ const SIWX_EXTENSION_KEY = 'sign-in-with-x';
126
+
127
+ // ──────────────────────────────────────────────────────── Spending caps ─────
128
+ // Persists per-wallet spend in localStorage so reload-survivable caps work in a
129
+ // pure-browser context. Keys are bucketed by UTC hour and UTC day so the
130
+ // sliding windows reset cleanly at midnight UTC for the daily case. Amounts are
131
+ // stored as base-10 BigInt strings of micro-USD; stablecoin payments flow
132
+ // through as-is since their atomics are already 6-decimal USD-pegged.
133
+
134
+ const SPEND_LS_PREFIX = 'x402.spend.';
135
+
136
+ function spendKey(address, kind, bucket) {
137
+ return `${SPEND_LS_PREFIX}${kind}.${address.toLowerCase()}.${bucket}`;
138
+ }
139
+
140
+ function readSpend(address, kind, bucket) {
141
+ try {
142
+ const raw = localStorage.getItem(spendKey(address, kind, bucket));
143
+ if (!raw) return 0n;
144
+ return BigInt(raw);
145
+ } catch {
146
+ return 0n;
147
+ }
148
+ }
149
+
150
+ function writeSpend(address, kind, bucket, value) {
151
+ try {
152
+ localStorage.setItem(spendKey(address, kind, bucket), value.toString());
153
+ } catch {
154
+ // localStorage full / disabled — caps degrade to per-call only.
155
+ }
156
+ }
157
+
158
+ // Check the configured caps and, if admitted, reserve the spend in localStorage.
159
+ // Returns { abort, reason?, reservation? }. Reservation carries { address,
160
+ // microUsd, buckets } so a failed payment can roll the reservation back.
161
+ function browserEnforceCap({ accept, caps, address }) {
162
+ if (!caps || !address) return { abort: false };
163
+ const microUsd = toMicroUsd(accept.amount, accept);
164
+ const maxPerCall = caps.maxPerCall != null ? BigInt(caps.maxPerCall) : null;
165
+ const maxPerHour = caps.maxPerHour != null ? BigInt(caps.maxPerHour) : null;
166
+ const maxPerDay = caps.maxPerDay != null ? BigInt(caps.maxPerDay) : null;
167
+ if (maxPerCall != null && microUsd > maxPerCall) {
168
+ return { abort: true, reason: `Per-call cap exceeded (${microUsd} > ${maxPerCall} µUSD)` };
169
+ }
170
+ const buckets = spendBuckets();
171
+ const hourTotal = readSpend(address, 'hr', buckets.hour) + microUsd;
172
+ const dayTotal = readSpend(address, 'day', buckets.day) + microUsd;
173
+ if (maxPerHour != null && hourTotal > maxPerHour) {
174
+ return { abort: true, reason: `Hourly cap exceeded (${hourTotal} > ${maxPerHour} µUSD)` };
175
+ }
176
+ if (maxPerDay != null && dayTotal > maxPerDay) {
177
+ return { abort: true, reason: `Daily cap exceeded (${dayTotal} > ${maxPerDay} µUSD)` };
178
+ }
179
+ writeSpend(address, 'hr', buckets.hour, hourTotal);
180
+ writeSpend(address, 'day', buckets.day, dayTotal);
181
+ return { abort: false, reservation: { address, microUsd, buckets } };
182
+ }
183
+
184
+ function browserRollbackReservation(reservation) {
185
+ if (!reservation) return;
186
+ const { address, microUsd, buckets } = reservation;
187
+ const hourCurrent = readSpend(address, 'hr', buckets.hour);
188
+ const dayCurrent = readSpend(address, 'day', buckets.day);
189
+ const hourNext = hourCurrent - microUsd;
190
+ const dayNext = dayCurrent - microUsd;
191
+ writeSpend(address, 'hr', buckets.hour, hourNext < 0n ? 0n : hourNext);
192
+ writeSpend(address, 'day', buckets.day, dayNext < 0n ? 0n : dayNext);
193
+ }
194
+
195
+ // ──────────────────────────────────────────── ERC-8021 builder-code echo ────
196
+ // The server enforces that any client-echoed builder-code `a` matches what the
197
+ // 402 challenge declared (anti-tamper). We self-attribute `w` (wallet) and `s`
198
+ // (service) from config; both are validated against the strict code pattern.
199
+
200
+ const BUILDER_CODE_KEY = 'builder-code';
201
+ const BUILDER_CODE_PATTERN = /^[a-z0-9_]{1,32}$/;
202
+
203
+ function buildBuilderCodeEcho(challenge) {
204
+ const codes = config.builderCode;
205
+ if (!codes) return null;
206
+ const ext = challenge?.extensions?.[BUILDER_CODE_KEY];
207
+ const declaredA = ext?.info?.a;
208
+ if (!declaredA || !BUILDER_CODE_PATTERN.test(declaredA)) return null;
209
+ const out = { a: declaredA };
210
+ if (codes.service && BUILDER_CODE_PATTERN.test(codes.service)) out.s = [codes.service];
211
+ if (codes.wallet && BUILDER_CODE_PATTERN.test(codes.wallet)) out.w = codes.wallet;
212
+ return out;
213
+ }
214
+
215
+ // ─────────────────────────────────────────────────────────── SIWX helpers ────
216
+
217
+ function extractSiwxExtension(body) {
218
+ const ext = body?.extensions?.[SIWX_EXTENSION_KEY];
219
+ if (!ext || !ext.info || !Array.isArray(ext.supportedChains) || !ext.supportedChains.length) return null;
220
+ return ext;
221
+ }
222
+
223
+ // Returns { chain, kind: 'evm' | 'solana' } or null. `chain` is the matching
224
+ // entry from `ext.supportedChains` whose signature type matches the wallet kind.
225
+ function pickSiwxChain(ext, walletKind) {
226
+ for (const chain of ext.supportedChains) {
227
+ if (walletKind === 'evm' && chain.type === 'eip191') return { chain, kind: 'evm' };
228
+ if (walletKind === 'solana' && chain.type === 'ed25519') return { chain, kind: 'solana' };
229
+ }
230
+ return null;
231
+ }
232
+
233
+ // Base64-encoded JSON per x402 v2 spec. CAIP-122 fields are all ASCII/Latin-1,
234
+ // so the unescape+encodeURIComponent dance matches what btoa expects.
235
+ function encodeSiwxHeaderValue(payload) {
236
+ const json = JSON.stringify(payload);
237
+ if (typeof Buffer !== 'undefined') return Buffer.from(json, 'utf8').toString('base64');
238
+ return btoa(unescape(encodeURIComponent(json)));
239
+ }
240
+
241
+ // EIP-55 checksum the address before signing. MetaMask returns lowercase
242
+ // addresses, but the server rebuilds the SIWE message with a checksummed
243
+ // address. If we sign a lowercase-address message and send the lowercase
244
+ // address in the payload, the recovered signer differs and verification fails.
245
+ // Keccak-256 is dynamic-imported from a CDN only when SIWX EVM sign-in runs.
246
+ let _evmChecksum = null;
247
+ async function loadEvmChecksum() {
248
+ if (_evmChecksum) return _evmChecksum;
249
+ const sha3 = await import(/* @vite-ignore */ config.nobleHashesUrl);
250
+ const keccak = sha3.keccak_256;
251
+ _evmChecksum = (addr) => {
252
+ const a = String(addr).toLowerCase().replace(/^0x/, '');
253
+ if (!/^[0-9a-f]{40}$/.test(a)) throw new Error(`invalid EVM address: ${addr}`);
254
+ const hashBytes = keccak(new TextEncoder().encode(a));
255
+ let hex = '';
256
+ for (let i = 0; i < hashBytes.length; i++) hex += hashBytes[i].toString(16).padStart(2, '0');
257
+ let out = '0x';
258
+ for (let i = 0; i < 40; i++) {
259
+ out += parseInt(hex[i], 16) >= 8 ? a[i].toUpperCase() : a[i];
260
+ }
261
+ return out;
262
+ };
263
+ return _evmChecksum;
264
+ }
265
+
266
+ // ───────────────────────────────────────────────────────────────── styles ────
267
+
268
+ const STYLE_ID = 'x402-styles';
269
+ const STYLES = `
270
+ :root {
271
+ --x402-z: 2147483600;
272
+ }
273
+ .x402-overlay {
274
+ position: fixed; inset: 0;
275
+ background: rgba(8, 10, 18, 0.55);
276
+ backdrop-filter: blur(10px);
277
+ -webkit-backdrop-filter: blur(10px);
278
+ display: flex; align-items: center; justify-content: center;
279
+ z-index: var(--x402-z);
280
+ opacity: 0; transition: opacity 0.16s ease-out;
281
+ font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
282
+ -webkit-font-smoothing: antialiased;
283
+ color: #0f0f0f;
284
+ }
285
+ .x402-overlay.x402-open { opacity: 1; }
286
+ .x402-overlay * { box-sizing: border-box; }
287
+ .x402-modal {
288
+ width: calc(100% - 32px); max-width: 420px;
289
+ background: #ffffff;
290
+ border-radius: 18px;
291
+ box-shadow: 0 24px 80px rgba(8, 10, 18, 0.28), 0 4px 16px rgba(8, 10, 18, 0.12);
292
+ overflow: hidden;
293
+ transform: translateY(8px) scale(0.985);
294
+ transition: transform 0.18s ease-out;
295
+ display: flex; flex-direction: column;
296
+ max-height: calc(100dvh - 32px);
297
+ }
298
+ .x402-overlay.x402-open .x402-modal { transform: translateY(0) scale(1); }
299
+ .x402-head {
300
+ padding: 18px 20px 14px;
301
+ border-bottom: 1px solid #eef0f4;
302
+ display: flex; align-items: center; gap: 12px;
303
+ }
304
+ .x402-head .x402-merchant {
305
+ flex: 1; min-width: 0;
306
+ }
307
+ .x402-merchant .x402-name {
308
+ font-size: 12px; color: #5a6378; font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase;
309
+ margin-bottom: 2px;
310
+ }
311
+ .x402-merchant .x402-action {
312
+ font-size: 17px; font-weight: 700; color: #0f0f0f;
313
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
314
+ letter-spacing: -0.01em;
315
+ }
316
+ .x402-close {
317
+ width: 32px; height: 32px;
318
+ border-radius: 8px; border: none; background: #f3f4f7;
319
+ font-size: 16px; color: #5a6378; cursor: pointer;
320
+ display: flex; align-items: center; justify-content: center;
321
+ transition: background 0.12s;
322
+ }
323
+ .x402-close:hover { background: #e7e9ee; color: #0f0f0f; }
324
+
325
+ .x402-price-row {
326
+ padding: 18px 20px;
327
+ display: flex; align-items: baseline; justify-content: space-between;
328
+ background: linear-gradient(180deg, #fafbfc 0%, #ffffff 100%);
329
+ border-bottom: 1px solid #eef0f4;
330
+ }
331
+ .x402-price {
332
+ font-size: 32px; font-weight: 700; letter-spacing: -0.02em; color: #0f0f0f;
333
+ font-variant-numeric: tabular-nums;
334
+ }
335
+ .x402-price .x402-currency { font-size: 14px; color: #5a6378; font-weight: 600; margin-left: 6px; letter-spacing: 0; }
336
+ .x402-network {
337
+ font-size: 12px; color: #5a6378; font-weight: 500;
338
+ background: #f3f4f7; padding: 5px 10px; border-radius: 99px;
339
+ display: inline-flex; align-items: center; gap: 6px;
340
+ }
341
+ .x402-network::before {
342
+ content: ''; width: 6px; height: 6px; border-radius: 50%;
343
+ background: #22c55e;
344
+ }
345
+
346
+ .x402-body {
347
+ padding: 16px 20px 18px;
348
+ flex: 1 1 auto; overflow-y: auto;
349
+ display: flex; flex-direction: column; gap: 10px;
350
+ }
351
+ .x402-step {
352
+ display: flex; gap: 12px; align-items: flex-start;
353
+ padding: 10px 0;
354
+ }
355
+ .x402-step + .x402-step { border-top: 1px solid #f3f4f7; }
356
+ .x402-step-num {
357
+ width: 22px; height: 22px; flex: 0 0 auto;
358
+ border-radius: 50%; border: 1.5px solid #d0d4dd; background: #fff;
359
+ color: #5a6378;
360
+ font-size: 11px; font-weight: 700;
361
+ display: flex; align-items: center; justify-content: center;
362
+ }
363
+ .x402-step.x402-active .x402-step-num {
364
+ border-color: #0a84ff; background: #0a84ff; color: #fff;
365
+ animation: x402-spin 1.2s linear infinite;
366
+ }
367
+ .x402-step.x402-done .x402-step-num {
368
+ border-color: #22c55e; background: #22c55e; color: #fff;
369
+ }
370
+ .x402-step.x402-error .x402-step-num {
371
+ border-color: #ef4444; background: #ef4444; color: #fff;
372
+ }
373
+ @keyframes x402-spin {
374
+ from { box-shadow: 0 0 0 0 rgba(10, 132, 255, 0.4); }
375
+ to { box-shadow: 0 0 0 8px rgba(10, 132, 255, 0); }
376
+ }
377
+ .x402-step-body { flex: 1; min-width: 0; }
378
+ .x402-step-label { font-size: 14px; font-weight: 600; color: #0f0f0f; line-height: 1.35; }
379
+ .x402-step-meta { font-size: 12px; color: #5a6378; margin-top: 2px; font-feature-settings: 'tnum' 1; }
380
+ .x402-step.x402-error .x402-step-meta { color: #ef4444; }
381
+
382
+ .x402-wallet-buttons {
383
+ display: flex; flex-direction: column; gap: 8px;
384
+ margin-top: 4px;
385
+ }
386
+ .x402-wallet-btn {
387
+ width: 100%; padding: 13px 14px;
388
+ background: #ffffff; border: 1.5px solid #e2e5ec; border-radius: 11px;
389
+ font-size: 14px; font-weight: 600; color: #0f0f0f;
390
+ cursor: pointer; font-family: inherit;
391
+ display: flex; align-items: center; gap: 12px;
392
+ transition: border-color 0.12s, background 0.12s, transform 0.05s;
393
+ }
394
+ .x402-wallet-btn:hover:not(:disabled) { border-color: #0a84ff; background: #f7faff; }
395
+ .x402-wallet-btn:active:not(:disabled) { transform: translateY(1px); }
396
+ .x402-wallet-btn:disabled { opacity: 0.45; cursor: not-allowed; }
397
+ .x402-wallet-icon {
398
+ width: 28px; height: 28px; flex: 0 0 auto;
399
+ border-radius: 7px;
400
+ display: flex; align-items: center; justify-content: center;
401
+ font-size: 16px;
402
+ background: #f3f4f7;
403
+ }
404
+ .x402-wallet-icon.x402-phantom { background: linear-gradient(135deg, #ab9ff2, #534bb1); color: #fff; }
405
+ .x402-wallet-icon.x402-metamask { background: linear-gradient(135deg, #f6851b, #e2761b); color: #fff; }
406
+ .x402-wallet-name { flex: 1; text-align: left; }
407
+ .x402-wallet-meta { font-size: 11px; color: #8a90a8; font-weight: 500; }
408
+
409
+ .x402-pay-btn {
410
+ width: 100%; padding: 14px 16px;
411
+ background: #0f0f0f; color: #fff; border: none;
412
+ border-radius: 12px;
413
+ font-size: 15px; font-weight: 700; font-family: inherit;
414
+ cursor: pointer; letter-spacing: -0.005em;
415
+ transition: background 0.12s, transform 0.05s;
416
+ margin-top: 4px;
417
+ display: flex; align-items: center; justify-content: center; gap: 8px;
418
+ }
419
+ .x402-pay-btn:hover:not(:disabled) { background: #1d1d1d; }
420
+ .x402-pay-btn:active:not(:disabled) { transform: translateY(1px); }
421
+ .x402-pay-btn:disabled { background: #c8ccd4; cursor: not-allowed; }
422
+
423
+ .x402-pay-secondary {
424
+ width: 100%; padding: 12px 14px;
425
+ background: #ffffff; color: #0f0f0f;
426
+ border: 1.5px solid #e2e5ec; border-radius: 11px;
427
+ font-size: 14px; font-weight: 600; font-family: inherit;
428
+ cursor: pointer; letter-spacing: -0.005em;
429
+ margin-top: 6px;
430
+ transition: border-color 0.12s, background 0.12s, transform 0.05s;
431
+ }
432
+ .x402-pay-secondary:hover:not(:disabled) { border-color: #0a84ff; background: #f7faff; }
433
+ .x402-pay-secondary:active:not(:disabled) { transform: translateY(1px); }
434
+
435
+ .x402-siwx-hint {
436
+ font-size: 11px; color: #5a6378; text-align: center;
437
+ margin-top: 8px; line-height: 1.4;
438
+ }
439
+ .x402-siwx-fallback {
440
+ font-size: 12px; color: #b45309; line-height: 1.45;
441
+ padding: 8px 10px; border-radius: 8px;
442
+ background: #fffbeb; border: 1px solid #fde68a;
443
+ margin-bottom: 6px;
444
+ }
445
+
446
+ .x402-error-box {
447
+ padding: 12px 14px; border-radius: 10px;
448
+ background: #fef2f2; border: 1px solid #fecaca; color: #b91c1c;
449
+ font-size: 13px; line-height: 1.45;
450
+ font-family: ui-monospace, "JetBrains Mono", Menlo, monospace;
451
+ word-break: break-word;
452
+ }
453
+ .x402-error-box strong { font-weight: 700; }
454
+
455
+ .x402-receipt {
456
+ padding: 14px 16px; border-radius: 12px;
457
+ background: linear-gradient(180deg, #f0fdf4 0%, #ffffff 100%);
458
+ border: 1px solid #bbf7d0;
459
+ }
460
+ .x402-receipt-title {
461
+ font-size: 11px; font-weight: 700; color: #15803d;
462
+ text-transform: uppercase; letter-spacing: 0.06em;
463
+ margin-bottom: 8px;
464
+ display: flex; align-items: center; gap: 6px;
465
+ }
466
+ .x402-receipt-title::before { content: '✓'; font-size: 14px; }
467
+ .x402-receipt-row {
468
+ display: flex; justify-content: space-between; gap: 12px;
469
+ font-size: 12px; padding: 2px 0;
470
+ font-family: ui-monospace, "JetBrains Mono", Menlo, monospace;
471
+ }
472
+ .x402-receipt-row .x402-k { color: #5a6378; }
473
+ .x402-receipt-row .x402-v { color: #0f0f0f; text-align: right; word-break: break-all; }
474
+ .x402-receipt-row a { color: #0a84ff; text-decoration: none; }
475
+ .x402-receipt-row a:hover { text-decoration: underline; }
476
+
477
+ .x402-result {
478
+ padding: 12px 14px; border-radius: 10px;
479
+ background: #fafbfc; border: 1px solid #e2e5ec;
480
+ max-height: 240px; overflow: auto;
481
+ font-family: ui-monospace, "JetBrains Mono", Menlo, monospace;
482
+ font-size: 12px; line-height: 1.5; color: #0f0f0f;
483
+ white-space: pre-wrap; word-break: break-word;
484
+ }
485
+
486
+ .x402-foot {
487
+ padding: 10px 20px 14px;
488
+ border-top: 1px solid #eef0f4;
489
+ display: flex; align-items: center; justify-content: space-between;
490
+ font-size: 11px; color: #8a90a8;
491
+ }
492
+ .x402-foot a { color: #5a6378; text-decoration: none; font-weight: 600; }
493
+ .x402-foot a:hover { color: #0f0f0f; }
494
+ .x402-foot .x402-secure { display: flex; align-items: center; gap: 5px; }
495
+ .x402-foot .x402-secure::before { content: '🔒'; font-size: 10px; }
496
+
497
+ @media (max-width: 480px) {
498
+ .x402-modal { max-width: none; width: calc(100% - 16px); border-radius: 16px; }
499
+ .x402-price { font-size: 26px; }
500
+ }
501
+
502
+ @media (prefers-color-scheme: dark) {
503
+ .x402-overlay { color: #e6e8f0; }
504
+ .x402-modal { background: #161616; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6); }
505
+ .x402-head, .x402-price-row, .x402-foot { border-color: #272727; }
506
+ .x402-step + .x402-step { border-top-color: #272727; }
507
+ .x402-merchant .x402-name { color: #8a90a8; }
508
+ .x402-merchant .x402-action, .x402-price, .x402-step-label { color: #e6e8f0; }
509
+ .x402-step-meta { color: #8a90a8; }
510
+ .x402-close { background: #222222; color: #8a90a8; }
511
+ .x402-close:hover { background: #2e2e2e; color: #e6e8f0; }
512
+ .x402-price-row { background: linear-gradient(180deg, #1d1d1d 0%, #161616 100%); }
513
+ .x402-network { background: #222222; color: #b0b6cc; }
514
+ .x402-wallet-btn { background: #1d1d1d; border-color: #2e2e2e; color: #e6e8f0; }
515
+ .x402-wallet-btn:hover:not(:disabled) { background: #252525; border-color: #0a84ff; }
516
+ .x402-wallet-icon { background: #2e2e2e; }
517
+ .x402-wallet-meta { color: #6b7088; }
518
+ .x402-pay-btn { background: #ffffff; color: #0f0f0f; }
519
+ .x402-pay-btn:hover:not(:disabled) { background: #e7e9ee; }
520
+ .x402-pay-btn:disabled { background: #2e2e2e; color: #5a6378; }
521
+ .x402-pay-secondary { background: #1d1d1d; border-color: #2e2e2e; color: #e6e8f0; }
522
+ .x402-pay-secondary:hover:not(:disabled) { background: #252525; border-color: #0a84ff; }
523
+ .x402-siwx-hint { color: #8a90a8; }
524
+ .x402-siwx-fallback { background: #2a1d10; border-color: #78350f; color: #fcd34d; }
525
+ .x402-step-num { background: #161616; border-color: #2e2e2e; color: #8a90a8; }
526
+ .x402-result { background: #1d1d1d; border-color: #2e2e2e; color: #e6e8f0; }
527
+ .x402-receipt { background: linear-gradient(180deg, #0b1f17 0%, #161616 100%); border-color: #14532d; }
528
+ .x402-receipt-title { color: #4ade80; }
529
+ .x402-receipt-row .x402-k { color: #8a90a8; }
530
+ .x402-receipt-row .x402-v { color: #e6e8f0; }
531
+ .x402-receipt-row a { color: #60a5fa; }
532
+ .x402-error-box { background: #1f1416; border-color: #7f1d1d; color: #fca5a5; }
533
+ .x402-foot a { color: #b0b6cc; }
534
+ .x402-foot a:hover { color: #ffffff; }
535
+ }
536
+ `;
537
+
538
+ function injectStyles() {
539
+ if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return;
540
+ const el = document.createElement('style');
541
+ el.id = STYLE_ID;
542
+ el.textContent = STYLES;
543
+ document.head.appendChild(el);
544
+ }
545
+
546
+ // ───────────────────────────────────────────────────────────── modal class ───
547
+
548
+ export class CheckoutModal {
549
+ constructor(opts) {
550
+ this.opts = opts;
551
+ this.steps = [
552
+ { id: 'discover', label: 'Confirming price' },
553
+ { id: 'connect', label: 'Connect wallet' },
554
+ { id: 'authorize', label: 'Authorize payment' },
555
+ { id: 'verify', label: 'Verify & complete' },
556
+ ];
557
+ this.activeNetwork = null;
558
+ this.payerAddress = null;
559
+ this.accept = null;
560
+ this.challenge = null;
561
+ this.disposed = false;
562
+ // One-shot guard for opts.autoConnect: we only auto-open the wallet on the
563
+ // first connect render, so an error that drops the user back to this step
564
+ // shows the manual picker instead of re-launching the wallet in a loop.
565
+ this.autoConnectTried = false;
566
+ }
567
+
568
+ _apiOrigin() {
569
+ return apiOriginFor(this.opts);
570
+ }
571
+
572
+ mount() {
573
+ injectStyles();
574
+ const brand = this.opts.brand || config.brand || {};
575
+ const overlay = document.createElement('div');
576
+ overlay.className = 'x402-overlay';
577
+ const brandHtml = brand.href
578
+ ? `<a href="${escapeHtml(brand.href)}" target="_blank" rel="noopener">${escapeHtml(brand.label || brand.href)}</a>`
579
+ : brand.label
580
+ ? `<span>${escapeHtml(brand.label)}</span>`
581
+ : '';
582
+ overlay.innerHTML = `
583
+ <div class="x402-modal" role="dialog" aria-modal="true" aria-label="x402 payment">
584
+ <div class="x402-head">
585
+ <div class="x402-merchant">
586
+ <div class="x402-name" data-merchant>${escapeHtml(this.opts.merchant || 'Payment')}</div>
587
+ <div class="x402-action" data-action>${escapeHtml(this.opts.action || 'Pay-per-call')}</div>
588
+ </div>
589
+ <button class="x402-close" data-close aria-label="Close">✕</button>
590
+ </div>
591
+ <div class="x402-price-row">
592
+ <div class="x402-price" data-price>—<span class="x402-currency"> USDC</span></div>
593
+ <div class="x402-network" data-network>resolving…</div>
594
+ </div>
595
+ <div class="x402-body" data-body></div>
596
+ <div class="x402-foot">
597
+ <span class="x402-secure">x402 · onchain settled</span>
598
+ ${brandHtml}
599
+ </div>
600
+ </div>
601
+ `;
602
+ document.body.appendChild(overlay);
603
+ this.overlay = overlay;
604
+ this.bodyEl = overlay.querySelector('[data-body]');
605
+ this.priceEl = overlay.querySelector('[data-price]');
606
+ this.networkEl = overlay.querySelector('[data-network]');
607
+ overlay.querySelector('[data-close]').addEventListener('click', () => this.close('cancelled'));
608
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close('cancelled'); });
609
+ this.onKey = (e) => { if (e.key === 'Escape') this.close('cancelled'); };
610
+ document.addEventListener('keydown', this.onKey);
611
+ requestAnimationFrame(() => overlay.classList.add('x402-open'));
612
+ return new Promise((resolve, reject) => {
613
+ this.resolve = resolve;
614
+ this.reject = reject;
615
+ });
616
+ }
617
+
618
+ close(reason) {
619
+ if (this.disposed) return;
620
+ this.disposed = true;
621
+ document.removeEventListener('keydown', this.onKey);
622
+ this.overlay.classList.remove('x402-open');
623
+ setTimeout(() => this.overlay.remove(), 180);
624
+ if (reason === 'cancelled' && this.reject) {
625
+ const err = new Error('cancelled');
626
+ err.code = 'cancelled';
627
+ this.reject(err);
628
+ }
629
+ }
630
+
631
+ renderSteps(activeId, status = {}) {
632
+ const html = this.steps
633
+ .map((s) => {
634
+ const state = status[s.id] || (s.id === activeId ? 'active' : 'idle');
635
+ const cls = state === 'active' ? 'x402-active' : state === 'done' ? 'x402-done' : state === 'error' ? 'x402-error' : '';
636
+ const meta = status[`${s.id}_meta`] || '';
637
+ const sym = state === 'done' ? '✓' : state === 'error' ? '!' : s.id === activeId && state === 'active' ? ' ' : (this.steps.findIndex((x) => x.id === s.id) + 1);
638
+ return `<div class="x402-step ${cls}">
639
+ <div class="x402-step-num">${sym}</div>
640
+ <div class="x402-step-body">
641
+ <div class="x402-step-label">${s.label}</div>
642
+ ${meta ? `<div class="x402-step-meta">${escapeHtml(meta)}</div>` : ''}
643
+ </div>
644
+ </div>`;
645
+ })
646
+ .join('');
647
+ return html;
648
+ }
649
+
650
+ setPrice(accept) {
651
+ const decimals = accept.extra?.decimals ?? 6;
652
+ const amount = formatAmount(accept.amount, decimals);
653
+ const sym = (accept.extra?.name || 'USDC').replace(/^USD Coin$/, 'USDC');
654
+ this.priceEl.innerHTML = `${amount}<span class="x402-currency"> ${sym}</span>`;
655
+ this.networkEl.textContent = networkLabel(accept.network, accept);
656
+ }
657
+
658
+ renderConnect() {
659
+ const phantomDetected = typeof window !== 'undefined' && (window.solana?.isPhantom || window.phantom?.solana);
660
+ const evmDetected = typeof window !== 'undefined' && window.ethereum;
661
+ const solanaAccept = this.challenge?.accepts.find((a) => isSolanaNetwork(a.network));
662
+ const evmAccept = this.challenge?.accepts.find(isEip3009Accept);
663
+
664
+ // SIWX-first path: when the 402 advertises sign-in-with-x AND we have a
665
+ // compatible wallet, lead with "Sign in with wallet" (primary) and demote
666
+ // pay to a secondary action. payFlowOverride is set true when the user
667
+ // explicitly chooses to pay.
668
+ if (this.siwx && !this.payFlowOverride) {
669
+ const siwxSolana = phantomDetected ? pickSiwxChain(this.siwx, 'solana') : null;
670
+ const siwxEvm = evmDetected ? pickSiwxChain(this.siwx, 'evm') : null;
671
+ if (siwxSolana || siwxEvm) {
672
+ this.renderSiwxChoice({ siwxSolana, siwxEvm });
673
+ return;
674
+ }
675
+ }
676
+
677
+ // autoConnect (opt-in via opts.autoConnect): when the caller knows the user
678
+ // is wallet-ready and shouldn't have to pick, skip the picker and go
679
+ // straight to the signature — but only when exactly one supported wallet is
680
+ // actually detected. One-shot via autoConnectTried.
681
+ if (this.opts.autoConnect && !this.autoConnectTried && !this.siwxFallbackNotice) {
682
+ this.autoConnectTried = true;
683
+ const solanaViable = !!(solanaAccept && phantomDetected);
684
+ const evmViable = !!(evmAccept && evmDetected);
685
+ if (solanaViable && !evmViable) { this.runSolana(solanaAccept); return; }
686
+ if (evmViable && !solanaViable) { this.runEvm(evmAccept); return; }
687
+ }
688
+
689
+ const buttons = [];
690
+ if (solanaAccept) {
691
+ buttons.push(`
692
+ <button class="x402-wallet-btn" data-wallet="phantom" ${phantomDetected ? '' : 'disabled'}>
693
+ <div class="x402-wallet-icon x402-phantom">P</div>
694
+ <span class="x402-wallet-name">${phantomDetected ? 'Phantom' : 'Phantom (not detected)'}</span>
695
+ <span class="x402-wallet-meta">${networkLabel(solanaAccept.network, solanaAccept)}</span>
696
+ </button>
697
+ `);
698
+ }
699
+ if (evmAccept) {
700
+ buttons.push(`
701
+ <button class="x402-wallet-btn" data-wallet="evm" ${evmDetected ? '' : 'disabled'}>
702
+ <div class="x402-wallet-icon x402-metamask">M</div>
703
+ <span class="x402-wallet-name">${evmDetected ? 'Browser wallet' : 'No EVM wallet detected'}</span>
704
+ <span class="x402-wallet-meta">${networkLabel(evmAccept.network, evmAccept)}</span>
705
+ </button>
706
+ `);
707
+ }
708
+ const fallbackBox = this.siwxFallbackNotice
709
+ ? `<div class="x402-siwx-fallback">${escapeHtml(this.siwxFallbackNotice)}</div>`
710
+ : '';
711
+ this.bodyEl.innerHTML = `
712
+ ${this.renderSteps('connect', { discover: 'done' })}
713
+ ${fallbackBox}
714
+ <div class="x402-wallet-buttons">${buttons.join('')}</div>
715
+ `;
716
+ const onClick = (e) => {
717
+ const btn = e.target.closest('[data-wallet]');
718
+ if (!btn || btn.disabled) return;
719
+ const wallet = btn.dataset.wallet;
720
+ if (wallet === 'phantom') this.runSolana(solanaAccept);
721
+ else if (wallet === 'evm') this.runEvm(evmAccept);
722
+ };
723
+ this.bodyEl.querySelectorAll('[data-wallet]').forEach((b) => b.addEventListener('click', onClick));
724
+ }
725
+
726
+ renderSiwxChoice({ siwxSolana, siwxEvm }) {
727
+ const priceText = formatAmount(this.accept.amount, this.accept.extra?.decimals ?? 6);
728
+ const siwxTarget = siwxSolana
729
+ ? { kind: 'solana', chain: siwxSolana.chain }
730
+ : { kind: 'evm', chain: siwxEvm.chain };
731
+ const siwxLabel = siwxTarget.kind === 'solana' ? 'Sign in with Phantom' : 'Sign in with wallet';
732
+ this.bodyEl.innerHTML = `
733
+ ${this.renderSteps('connect', { discover: 'done' })}
734
+ <button class="x402-pay-btn" data-action="siwx">${siwxLabel}</button>
735
+ <button class="x402-pay-secondary" data-action="pay">Pay ${priceText} USDC instead</button>
736
+ <div class="x402-siwx-hint">Already paid for this once? Sign in to re-enter without paying again.</div>
737
+ `;
738
+ const siwxBtn = this.bodyEl.querySelector('[data-action="siwx"]');
739
+ const payBtn = this.bodyEl.querySelector('[data-action="pay"]');
740
+ siwxBtn.addEventListener('click', () => {
741
+ if (siwxTarget.kind === 'solana') this.runSiwxSolana(siwxTarget.chain);
742
+ else this.runSiwxEvm(siwxTarget.chain);
743
+ });
744
+ payBtn.addEventListener('click', () => {
745
+ this.payFlowOverride = true;
746
+ this.renderConnect();
747
+ });
748
+ requestAnimationFrame(() => siwxBtn.focus());
749
+ }
750
+
751
+ renderProgress(activeId, meta = {}) {
752
+ this.bodyEl.innerHTML = this.renderSteps(activeId, {
753
+ discover: 'done',
754
+ connect: 'done',
755
+ ...(activeId === 'verify' ? { authorize: 'done' } : {}),
756
+ [`${activeId}_meta`]: meta.text || '',
757
+ ...meta.statuses,
758
+ });
759
+ }
760
+
761
+ renderError(stepId, message) {
762
+ this.bodyEl.innerHTML = `
763
+ ${this.renderSteps(stepId, {
764
+ ...(stepId !== 'discover' ? { discover: 'done' } : {}),
765
+ ...(stepId === 'authorize' || stepId === 'verify' ? { connect: 'done' } : {}),
766
+ ...(stepId === 'verify' ? { authorize: 'done' } : {}),
767
+ [stepId]: 'error',
768
+ [`${stepId}_meta`]: 'failed',
769
+ })}
770
+ <div class="x402-error-box"><strong>${escapeHtml(stepId)}:</strong> ${escapeHtml(message)}</div>
771
+ <button class="x402-pay-btn" data-retry>Try again</button>
772
+ `;
773
+ this.bodyEl.querySelector('[data-retry]').addEventListener('click', () => this.start());
774
+ }
775
+
776
+ renderDone({ result, payment, siwx }) {
777
+ const resultStr = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
778
+ let receiptHtml;
779
+ if (siwx) {
780
+ const addrShort = siwx.address ? `${siwx.address.slice(0, 8)}…${siwx.address.slice(-6)}` : '—';
781
+ receiptHtml = `
782
+ <div class="x402-receipt">
783
+ <div class="x402-receipt-title">Welcome back!</div>
784
+ <div class="x402-receipt-row">
785
+ <span class="x402-k">network</span>
786
+ <span class="x402-v">${escapeHtml(networkLabel(siwx.network) || siwx.network || '—')}</span>
787
+ </div>
788
+ <div class="x402-receipt-row">
789
+ <span class="x402-k">wallet</span>
790
+ <span class="x402-v">${escapeHtml(addrShort)}</span>
791
+ </div>
792
+ <div class="x402-receipt-row">
793
+ <span class="x402-k">paid</span>
794
+ <span class="x402-v">previously · re-entered free</span>
795
+ </div>
796
+ </div>
797
+ `;
798
+ } else {
799
+ const explorer = explorerUrl(payment?.network, payment?.transaction);
800
+ const txShort = payment?.transaction ? `${payment.transaction.slice(0, 8)}…${payment.transaction.slice(-6)}` : '—';
801
+ receiptHtml = `
802
+ <div class="x402-receipt">
803
+ <div class="x402-receipt-title">Payment confirmed!</div>
804
+ <div class="x402-receipt-row">
805
+ <span class="x402-k">network</span>
806
+ <span class="x402-v">${escapeHtml(networkLabel(payment?.network) || '—')}</span>
807
+ </div>
808
+ <div class="x402-receipt-row">
809
+ <span class="x402-k">payer</span>
810
+ <span class="x402-v">${escapeHtml(payment?.payer ? `${payment.payer.slice(0, 8)}…${payment.payer.slice(-6)}` : '—')}</span>
811
+ </div>
812
+ ${
813
+ payment?.transaction
814
+ ? `<div class="x402-receipt-row"><span class="x402-k">tx</span><span class="x402-v">${
815
+ explorer ? `<a href="${explorer}" target="_blank" rel="noopener">${txShort} ↗</a>` : txShort
816
+ }</span></div>`
817
+ : ''
818
+ }
819
+ </div>
820
+ `;
821
+ }
822
+ this.bodyEl.innerHTML = `
823
+ ${receiptHtml}
824
+ <div class="x402-result">${escapeHtml(resultStr).slice(0, 4000)}</div>
825
+ <button class="x402-pay-btn" data-done>Done</button>
826
+ `;
827
+ this.bodyEl.querySelector('[data-done]').addEventListener('click', () => {
828
+ this.disposed = true;
829
+ document.removeEventListener('keydown', this.onKey);
830
+ this.overlay.classList.remove('x402-open');
831
+ setTimeout(() => this.overlay.remove(), 180);
832
+ });
833
+ }
834
+
835
+ async start() {
836
+ this.bodyEl.innerHTML = this.renderSteps('discover');
837
+ try {
838
+ const challenge = await discoverChallenge(this.opts);
839
+ this.challenge = challenge;
840
+ this.siwx = extractSiwxExtension(challenge);
841
+ this.payFlowOverride = false;
842
+ this.siwxFallbackNotice = null;
843
+ // Prefer Solana when Phantom is present, else first EIP-3009 EVM entry
844
+ // (skipping Permit2 siblings the modal can't sign for), else first accept.
845
+ const solana = challenge.accepts.find((a) => isSolanaNetwork(a.network));
846
+ const evm = challenge.accepts.find(isEip3009Accept);
847
+ const phantomDetected = typeof window !== 'undefined' && (window.solana?.isPhantom || window.phantom?.solana);
848
+ this.accept = (phantomDetected && solana) || evm || challenge.accepts[0];
849
+ this.setPrice(this.accept);
850
+ this.renderConnect();
851
+ } catch (err) {
852
+ this.renderError('discover', err.message || String(err));
853
+ }
854
+ }
855
+
856
+ async runSolana(accept) {
857
+ this.accept = accept;
858
+ this.setPrice(accept);
859
+ this.renderProgress('connect', { text: 'Opening Phantom…' });
860
+ try {
861
+ const provider = window.phantom?.solana || window.solana;
862
+ if (!provider) throw new Error('Phantom wallet not detected');
863
+ const conn = await provider.connect();
864
+ const payerAddress = (conn?.publicKey || provider.publicKey)?.toString();
865
+ if (!payerAddress) throw new Error('Phantom did not return a public key');
866
+ this.payerAddress = payerAddress;
867
+ const capCheck = browserEnforceCap({
868
+ accept,
869
+ caps: this.opts.caps,
870
+ address: payerAddress,
871
+ });
872
+ if (capCheck.abort) {
873
+ this.renderError('authorize', capCheck.reason);
874
+ return;
875
+ }
876
+ this.spendReservation = capCheck.reservation || null;
877
+ this.renderProgress('authorize', { text: `Building Solana payment for ${payerAddress.slice(0, 6)}…${payerAddress.slice(-4)}` });
878
+
879
+ const origin = this._apiOrigin();
880
+ const prep = await postJson(`${origin}/api/x402-checkout?action=prepare`, {
881
+ accept,
882
+ buyer: payerAddress,
883
+ });
884
+ this.renderProgress('authorize', { text: 'Confirm in Phantom…' });
885
+ const txBytes = base64ToUint8Array(prep.tx_base64);
886
+ // Phantom returns a fully-signed VersionedTransaction with the buyer's
887
+ // signature added. The facilitator's fee-payer signature is added during
888
+ // /settle.
889
+ const SolanaWeb3 = await loadSolanaWeb3();
890
+ const tx = SolanaWeb3.VersionedTransaction.deserialize(txBytes);
891
+ const signed = await provider.signTransaction(tx);
892
+ const signedB64 = uint8ArrayToBase64(signed.serialize());
893
+
894
+ const builderCodeBlock = buildBuilderCodeEcho(this.challenge);
895
+ const enc = await postJson(`${origin}/api/x402-checkout?action=encode`, {
896
+ accept,
897
+ signed_tx_base64: signedB64,
898
+ resource_url: new URL(this.opts.endpoint, location.href).href,
899
+ ...(builderCodeBlock ? { builder_code: builderCodeBlock } : {}),
900
+ });
901
+
902
+ await this.executePaid(enc.x_payment);
903
+ } catch (err) {
904
+ if (this.spendReservation) {
905
+ browserRollbackReservation(this.spendReservation);
906
+ this.spendReservation = null;
907
+ }
908
+ this.renderError(this.payerAddress ? 'authorize' : 'connect', friendlyError(err));
909
+ }
910
+ }
911
+
912
+ async runEvm(accept) {
913
+ this.accept = accept;
914
+ this.setPrice(accept);
915
+ this.renderProgress('connect', { text: 'Opening browser wallet…' });
916
+ try {
917
+ const eth = window.ethereum;
918
+ if (!eth) throw new Error('No EVM wallet detected');
919
+ const accounts = await eth.request({ method: 'eth_requestAccounts' });
920
+ const payerAddress = accounts?.[0];
921
+ if (!payerAddress) throw new Error('Wallet did not return an account');
922
+ this.payerAddress = payerAddress;
923
+ const capCheck = browserEnforceCap({
924
+ accept,
925
+ caps: this.opts.caps,
926
+ address: payerAddress,
927
+ });
928
+ if (capCheck.abort) {
929
+ this.renderError('authorize', capCheck.reason);
930
+ return;
931
+ }
932
+ this.spendReservation = capCheck.reservation || null;
933
+
934
+ const meta = EVM_NETWORKS[accept.network];
935
+ if (!meta) throw new Error(`Unknown EVM network ${accept.network}`);
936
+ // Switch chain if needed.
937
+ const currentChainHex = await eth.request({ method: 'eth_chainId' });
938
+ const desiredChainHex = '0x' + meta.chainId.toString(16);
939
+ if (currentChainHex !== desiredChainHex) {
940
+ this.renderProgress('connect', { text: `Switch wallet to ${meta.name}…` });
941
+ try {
942
+ await eth.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: desiredChainHex }] });
943
+ } catch (e) {
944
+ throw new Error(`Wallet is on ${currentChainHex}; please switch to ${meta.name} (${desiredChainHex}) and retry`);
945
+ }
946
+ }
947
+
948
+ this.renderProgress('authorize', { text: `Authorize ${formatAmount(accept.amount)} USDC…` });
949
+
950
+ // EIP-3009 transferWithAuthorization typed-data signature.
951
+ const validAfter = 0;
952
+ const validBefore = Math.floor(Date.now() / 1000) + (accept.maxTimeoutSeconds || 600);
953
+ const nonce = '0x' + randomHex(32);
954
+ const domain = {
955
+ name: accept.extra?.name || 'USD Coin',
956
+ version: accept.extra?.version || '2',
957
+ chainId: meta.chainId,
958
+ verifyingContract: accept.asset,
959
+ };
960
+ const types = {
961
+ EIP712Domain: [
962
+ { name: 'name', type: 'string' },
963
+ { name: 'version', type: 'string' },
964
+ { name: 'chainId', type: 'uint256' },
965
+ { name: 'verifyingContract', type: 'address' },
966
+ ],
967
+ TransferWithAuthorization: [
968
+ { name: 'from', type: 'address' },
969
+ { name: 'to', type: 'address' },
970
+ { name: 'value', type: 'uint256' },
971
+ { name: 'validAfter', type: 'uint256' },
972
+ { name: 'validBefore', type: 'uint256' },
973
+ { name: 'nonce', type: 'bytes32' },
974
+ ],
975
+ };
976
+ const message = {
977
+ from: payerAddress,
978
+ to: accept.payTo,
979
+ value: accept.amount,
980
+ validAfter,
981
+ validBefore,
982
+ nonce,
983
+ };
984
+ const typedData = {
985
+ primaryType: 'TransferWithAuthorization',
986
+ types,
987
+ domain,
988
+ message,
989
+ };
990
+ const signature = await eth.request({
991
+ method: 'eth_signTypedData_v4',
992
+ params: [payerAddress, JSON.stringify(typedData)],
993
+ });
994
+
995
+ const paymentPayload = {
996
+ x402Version: 2,
997
+ scheme: 'exact',
998
+ network: accept.network,
999
+ resource: { url: this.opts.endpoint, mimeType: 'application/json' },
1000
+ accepted: accept,
1001
+ payload: {
1002
+ signature,
1003
+ // The facilitator /verify requires the EIP-3009 time bounds as
1004
+ // decimal strings, not JSON numbers. The signature is unaffected:
1005
+ // uint256 0 and "0" encode identically.
1006
+ authorization: { from: payerAddress, to: accept.payTo, value: accept.amount, validAfter: String(validAfter), validBefore: String(validBefore), nonce },
1007
+ },
1008
+ };
1009
+ const builderCodeBlock = buildBuilderCodeEcho(this.challenge);
1010
+ if (builderCodeBlock) {
1011
+ paymentPayload.extensions = { 'builder-code': builderCodeBlock };
1012
+ }
1013
+ const xPayment = b64encode(paymentPayload);
1014
+ await this.executePaid(xPayment);
1015
+ } catch (err) {
1016
+ if (this.spendReservation) {
1017
+ browserRollbackReservation(this.spendReservation);
1018
+ this.spendReservation = null;
1019
+ }
1020
+ this.renderError(this.payerAddress ? 'authorize' : 'connect', friendlyError(err));
1021
+ }
1022
+ }
1023
+
1024
+ async executePaid(xPayment, attempt = 0) {
1025
+ this.renderProgress('verify', {
1026
+ text: attempt ? 'Retrying after upstream throttle…' : 'Calling merchant endpoint…',
1027
+ });
1028
+ try {
1029
+ const res = await fetch(this.opts.endpoint, {
1030
+ method: this.opts.method || 'GET',
1031
+ headers: {
1032
+ ...(this.opts.headers || {}),
1033
+ ...(this.opts.body && !this.opts.headers?.['content-type'] ? { 'content-type': 'application/json' } : {}),
1034
+ 'X-PAYMENT': xPayment,
1035
+ },
1036
+ body: this.opts.body ? (typeof this.opts.body === 'string' ? this.opts.body : JSON.stringify(this.opts.body)) : undefined,
1037
+ });
1038
+ const ct = res.headers.get('content-type') || '';
1039
+ const text = await res.text();
1040
+ let result;
1041
+ if (ct.includes('json')) {
1042
+ try {
1043
+ result = JSON.parse(text);
1044
+ } catch {
1045
+ result = text;
1046
+ }
1047
+ } else {
1048
+ result = text;
1049
+ }
1050
+ if (!res.ok) {
1051
+ // A 429 here is a transient upstream throttle. The payment is signed
1052
+ // but NOT yet settled — the merchant runs the work before settling —
1053
+ // so the same X-PAYMENT can be safely re-sent once the window resets,
1054
+ // with no risk of a double charge.
1055
+ if (res.status === 429 && attempt < MAX_THROTTLE_RETRIES) {
1056
+ await this.waitForThrottle(retryAfterSeconds(res, result));
1057
+ return this.executePaid(xPayment, attempt + 1);
1058
+ }
1059
+ const msg = (result && typeof result === 'object' && (result.error_description || result.error)) || `HTTP ${res.status}`;
1060
+ throw new Error(msg);
1061
+ }
1062
+ const settleHeader = res.headers.get('x-payment-response');
1063
+ const payment = b64decode(settleHeader) || {};
1064
+ this.spendReservation = null;
1065
+ this.renderDone({ result, payment });
1066
+ this.resolve?.({ ok: true, result, payment, response: { status: res.status, headers: headersToObject(res.headers) } });
1067
+ } catch (err) {
1068
+ if (this.spendReservation) {
1069
+ browserRollbackReservation(this.spendReservation);
1070
+ this.spendReservation = null;
1071
+ }
1072
+ this.renderError('verify', friendlyError(err));
1073
+ }
1074
+ }
1075
+
1076
+ // Hold the verify step on a live countdown while an upstream throttle resets,
1077
+ // then return so the caller re-sends the same signed payment. The reservation
1078
+ // is deliberately left intact — this is the same payment, not a new one.
1079
+ async waitForThrottle(seconds) {
1080
+ const total = Math.max(1, Math.min(30, Math.round(seconds) || 6));
1081
+ for (let left = total; left > 0; left--) {
1082
+ this.renderProgress('verify', { text: `Service is busy — retrying in ${left}s…` });
1083
+ await new Promise((r) => setTimeout(r, 1000));
1084
+ }
1085
+ this.renderProgress('verify', { text: 'Retrying…' });
1086
+ }
1087
+
1088
+ async runSiwxEvm(chain) {
1089
+ this.renderProgress('connect', { text: 'Opening browser wallet…' });
1090
+ try {
1091
+ const eth = window.ethereum;
1092
+ if (!eth) throw new Error('No EVM wallet detected');
1093
+ const accounts = await eth.request({ method: 'eth_requestAccounts' });
1094
+ const rawAddress = accounts?.[0];
1095
+ if (!rawAddress) throw new Error('Wallet did not return an account');
1096
+ const checksum = await loadEvmChecksum();
1097
+ const address = checksum(rawAddress);
1098
+ this.payerAddress = address;
1099
+ this.renderProgress('authorize', { text: `Sign sign-in message as ${address.slice(0, 6)}…${address.slice(-4)}` });
1100
+
1101
+ const message = buildSiwxMessage(this.siwx.info, chain, address);
1102
+ const signature = await eth.request({
1103
+ method: 'personal_sign',
1104
+ params: [message, address],
1105
+ });
1106
+
1107
+ const info = this.siwx.info;
1108
+ const payload = {
1109
+ domain: info.domain,
1110
+ address,
1111
+ ...(info.statement ? { statement: info.statement } : {}),
1112
+ uri: info.uri,
1113
+ version: info.version || '1',
1114
+ chainId: chain.chainId,
1115
+ type: 'eip191',
1116
+ nonce: info.nonce,
1117
+ issuedAt: info.issuedAt,
1118
+ ...(info.expirationTime ? { expirationTime: info.expirationTime } : {}),
1119
+ ...(info.notBefore ? { notBefore: info.notBefore } : {}),
1120
+ ...(info.requestId !== undefined && info.requestId !== null ? { requestId: info.requestId } : {}),
1121
+ ...(Array.isArray(info.resources) ? { resources: info.resources } : {}),
1122
+ signatureScheme: 'eip191',
1123
+ signature,
1124
+ };
1125
+ await this.executeSiwx(payload, chain.chainId);
1126
+ } catch (err) {
1127
+ this.renderError(this.payerAddress ? 'authorize' : 'connect', friendlyError(err));
1128
+ }
1129
+ }
1130
+
1131
+ async runSiwxSolana(chain) {
1132
+ this.renderProgress('connect', { text: 'Opening Phantom…' });
1133
+ try {
1134
+ const provider = window.phantom?.solana || window.solana;
1135
+ if (!provider) throw new Error('Phantom wallet not detected');
1136
+ const conn = await provider.connect();
1137
+ const pubkey = conn?.publicKey || provider.publicKey;
1138
+ const address = pubkey?.toString();
1139
+ if (!address) throw new Error('Phantom did not return a public key');
1140
+ this.payerAddress = address;
1141
+ this.renderProgress('authorize', { text: `Sign sign-in message as ${address.slice(0, 6)}…${address.slice(-4)}` });
1142
+
1143
+ const message = buildSiwxMessage(this.siwx.info, chain, address);
1144
+ const encoded = new TextEncoder().encode(message);
1145
+ const signed = await provider.signMessage(encoded, 'utf8');
1146
+ const sigBytes = signed?.signature instanceof Uint8Array ? signed.signature : new Uint8Array(signed?.signature || signed);
1147
+ if (!sigBytes || !sigBytes.length) throw new Error('Phantom did not return a signature');
1148
+ const signature = base58encode(sigBytes);
1149
+
1150
+ const info = this.siwx.info;
1151
+ const payload = {
1152
+ domain: info.domain,
1153
+ address,
1154
+ ...(info.statement ? { statement: info.statement } : {}),
1155
+ uri: info.uri,
1156
+ version: info.version || '1',
1157
+ chainId: chain.chainId,
1158
+ type: 'ed25519',
1159
+ nonce: info.nonce,
1160
+ issuedAt: info.issuedAt,
1161
+ ...(info.expirationTime ? { expirationTime: info.expirationTime } : {}),
1162
+ ...(info.notBefore ? { notBefore: info.notBefore } : {}),
1163
+ ...(info.requestId !== undefined && info.requestId !== null ? { requestId: info.requestId } : {}),
1164
+ ...(Array.isArray(info.resources) ? { resources: info.resources } : {}),
1165
+ signatureScheme: 'siws',
1166
+ signature,
1167
+ };
1168
+ await this.executeSiwx(payload, chain.chainId);
1169
+ } catch (err) {
1170
+ this.renderError(this.payerAddress ? 'authorize' : 'connect', friendlyError(err));
1171
+ }
1172
+ }
1173
+
1174
+ async executeSiwx(payload, chainId) {
1175
+ this.renderProgress('verify', { text: 'Verifying sign-in…' });
1176
+ const headerValue = encodeSiwxHeaderValue(payload);
1177
+ let res;
1178
+ try {
1179
+ res = await fetch(this.opts.endpoint, {
1180
+ method: this.opts.method || 'GET',
1181
+ headers: {
1182
+ ...(this.opts.headers || {}),
1183
+ ...(this.opts.body && !this.opts.headers?.['content-type'] ? { 'content-type': 'application/json' } : {}),
1184
+ [SIWX_HEADER]: headerValue,
1185
+ },
1186
+ body: this.opts.body ? (typeof this.opts.body === 'string' ? this.opts.body : JSON.stringify(this.opts.body)) : undefined,
1187
+ });
1188
+ } catch (err) {
1189
+ this.renderError('verify', friendlyError(err));
1190
+ return;
1191
+ }
1192
+
1193
+ if (res.status === 200) {
1194
+ const ct = res.headers.get('content-type') || '';
1195
+ const text = await res.text();
1196
+ let result;
1197
+ if (ct.includes('json')) {
1198
+ try { result = JSON.parse(text); } catch { result = text; }
1199
+ } else {
1200
+ result = text;
1201
+ }
1202
+ const siwx = { address: payload.address, network: chainId };
1203
+ this.renderDone({ result, siwx });
1204
+ this.resolve?.({
1205
+ ok: true,
1206
+ result,
1207
+ siwx,
1208
+ response: { status: res.status, headers: headersToObject(res.headers) },
1209
+ });
1210
+ return;
1211
+ }
1212
+
1213
+ if (res.status === 401 || res.status === 402) {
1214
+ // Most likely: signature verified but this wallet hasn't actually paid
1215
+ // for the resource yet. Drop the SIWX offering and fall back to the
1216
+ // normal payment flow with a one-line notice.
1217
+ let parsed = null;
1218
+ try { parsed = await res.clone().json(); } catch (_) {}
1219
+ const code = parsed?.code || parsed?.error;
1220
+ this.siwx = null;
1221
+ this.payerAddress = null;
1222
+ this.payFlowOverride = false;
1223
+ this.siwxFallbackNotice = code === 'siwx_not_paid' || res.status === 402
1224
+ ? "You haven't paid for this yet — pay now to unlock re-entry."
1225
+ : 'Sign-in not accepted — please pay to continue.';
1226
+ if (!this.challenge || !Array.isArray(this.challenge.accepts) || !this.challenge.accepts.length) {
1227
+ this.start();
1228
+ } else {
1229
+ this.renderConnect();
1230
+ }
1231
+ return;
1232
+ }
1233
+
1234
+ const text = await res.text().catch(() => '');
1235
+ this.renderError('verify', `SIWX retry failed: HTTP ${res.status}${text ? ` · ${text.slice(0, 120)}` : ''}`);
1236
+ }
1237
+ }
1238
+
1239
+ // ───────────────────────────────────────────────────────── helpers ──────────
1240
+
1241
+ function escapeHtml(s) {
1242
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
1243
+ }
1244
+
1245
+ function headersToObject(headers) {
1246
+ const out = {};
1247
+ headers.forEach((v, k) => (out[k] = v));
1248
+ return out;
1249
+ }
1250
+
1251
+ // How many times executePaid silently re-sends a signed payment after a 429
1252
+ // throttle before falling back to the manual "Try again". The payment isn't
1253
+ // settled until the merchant call succeeds, so re-sending can't double-charge.
1254
+ const MAX_THROTTLE_RETRIES = 2;
1255
+
1256
+ // Seconds to wait before re-sending after a 429. Prefers the standard
1257
+ // Retry-After header, then the body's `retry_after` hint, then a sane default.
1258
+ function retryAfterSeconds(res, result, fallback = 6) {
1259
+ const header = Number.parseInt(res.headers.get('retry-after') || '', 10);
1260
+ if (Number.isFinite(header) && header > 0) return header;
1261
+ const body = result && typeof result === 'object' ? Number(result.retry_after) : NaN;
1262
+ if (Number.isFinite(body) && body > 0) return body;
1263
+ return fallback;
1264
+ }
1265
+
1266
+ function friendlyError(err) {
1267
+ const msg = err?.shortMessage || err?.message || String(err);
1268
+ if (/user rejected|user denied|reject/i.test(msg)) return 'cancelled in wallet';
1269
+ // Upstream throttles often arrive as raw provider text that names the
1270
+ // merchant's internal billing or credit state. Never relay that to the buyer:
1271
+ // the payment isn't settled until the merchant call succeeds.
1272
+ if (/throttl|rate.?limit|too many requests|less than \$|in credit|\b429\b/i.test(msg)) {
1273
+ return 'The service is briefly busy and your payment was not taken — retry in a few seconds.';
1274
+ }
1275
+ // The Solana and EVM-sign-in paths dynamic-import a library from a CDN. A
1276
+ // strict host Content-Security-Policy (or the CDN being unreachable) blocks
1277
+ // that import. The Base/EIP-3009 payment path has no such dependency.
1278
+ if (/dynamically imported module|esm\.sh|module script failed/i.test(msg)) {
1279
+ return 'A component this wallet path needs (loaded from a CDN) was blocked — often by a strict host security policy. Pay with MetaMask on Base instead; it needs no third-party code.';
1280
+ }
1281
+ return msg.slice(0, 240);
1282
+ }
1283
+
1284
+ function base64ToUint8Array(b64) {
1285
+ if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(b64, 'base64'));
1286
+ const bin = atob(b64);
1287
+ const arr = new Uint8Array(bin.length);
1288
+ for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
1289
+ return arr;
1290
+ }
1291
+ function uint8ArrayToBase64(arr) {
1292
+ if (typeof Buffer !== 'undefined') return Buffer.from(arr).toString('base64');
1293
+ let bin = '';
1294
+ for (let i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]);
1295
+ return btoa(bin);
1296
+ }
1297
+ function randomHex(bytes) {
1298
+ const arr = new Uint8Array(bytes);
1299
+ crypto.getRandomValues(arr);
1300
+ return Array.from(arr).map((b) => b.toString(16).padStart(2, '0')).join('');
1301
+ }
1302
+
1303
+ let _solanaWeb3 = null;
1304
+ async function loadSolanaWeb3() {
1305
+ if (_solanaWeb3) return _solanaWeb3;
1306
+ // Dynamic import from a CDN keeps the drop-in script tiny — Solana web3.js is
1307
+ // only fetched when a Solana payment is actually attempted.
1308
+ _solanaWeb3 = await import(/* @vite-ignore */ config.solanaWeb3Url);
1309
+ return _solanaWeb3;
1310
+ }
1311
+
1312
+ async function postJson(url, body) {
1313
+ const res = await fetch(url, {
1314
+ method: 'POST',
1315
+ headers: { 'content-type': 'application/json' },
1316
+ body: JSON.stringify(body),
1317
+ });
1318
+ const text = await res.text();
1319
+ let data;
1320
+ try {
1321
+ data = JSON.parse(text);
1322
+ } catch {
1323
+ data = { error: 'parse_error', error_description: text.slice(0, 200) };
1324
+ }
1325
+ if (!res.ok) {
1326
+ const err = new Error(data.error_description || data.error || `HTTP ${res.status}`);
1327
+ err.status = res.status;
1328
+ err.data = data;
1329
+ throw err;
1330
+ }
1331
+ return data;
1332
+ }
1333
+
1334
+ // Probe the merchant endpoint with a benign request to extract the 402
1335
+ // challenge. Accepts HTTP 402 (standard x402) or HTTP 401 with a
1336
+ // `payment-required` header (MCP 2025-06-18 spec).
1337
+ async function discoverChallenge(opts) {
1338
+ const headers = { ...(opts.headers || {}) };
1339
+ const init = {
1340
+ method: opts.method || 'GET',
1341
+ headers,
1342
+ body: opts.body ? (typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body)) : undefined,
1343
+ };
1344
+ if (init.body && !headers['content-type']) headers['content-type'] = 'application/json';
1345
+ const res = await fetch(opts.endpoint, init);
1346
+
1347
+ // MCP 2025-06-18 endpoints return 401 with the full x402 challenge in the
1348
+ // `payment-required` header (base64-JSON). Accept that alongside standard 402.
1349
+ const prHeader = res.headers.get('payment-required');
1350
+ const is401WithChallenge = res.status === 401 && !!prHeader;
1351
+
1352
+ if (res.status !== 402 && !is401WithChallenge) {
1353
+ // Endpoint isn't paid (200) or isn't an x402 endpoint at all. Surface a
1354
+ // clear error — pointing the modal at a free endpoint should not silently
1355
+ // succeed.
1356
+ const txt = await res.text();
1357
+ throw new Error(`Endpoint did not return 402 (got ${res.status}). Body: ${txt.slice(0, 120)}`);
1358
+ }
1359
+
1360
+ let body = is401WithChallenge ? b64decode(prHeader) : await res.json().catch(() => null);
1361
+ if (!body || !Array.isArray(body.accepts) || !body.accepts.length) {
1362
+ // Some servers only emit `{error}` in the body and put the full v2
1363
+ // PaymentRequired envelope in the base64-JSON PAYMENT-REQUIRED header.
1364
+ const decoded = b64decode(prHeader);
1365
+ if (decoded && Array.isArray(decoded.accepts) && decoded.accepts.length) {
1366
+ body = decoded;
1367
+ }
1368
+ }
1369
+ if (!body || !Array.isArray(body.accepts) || !body.accepts.length) {
1370
+ throw new Error('Endpoint returned 402 but no `accepts` array could be found in body or header');
1371
+ }
1372
+ // Coerce spec-canonical `maxAmountRequired` → `amount` so downstream price /
1373
+ // caps / signing read one field.
1374
+ body.accepts = body.accepts.map(normalizeAccept);
1375
+ return body;
1376
+ }
1377
+
1378
+ // ───────────────────────────────────────────────────────── public api ───────
1379
+
1380
+ /**
1381
+ * Open the payment modal for an x402 endpoint and resolve when the call
1382
+ * succeeds (after settlement) or reject if the user cancels.
1383
+ * @param {import('../types/index.js').PayOptions} opts
1384
+ * @returns {Promise<import('../types/index.js').PayResult>}
1385
+ */
1386
+ export async function pay(opts) {
1387
+ if (!opts?.endpoint) throw new Error('X402.pay: endpoint is required');
1388
+ const modal = new CheckoutModal(opts);
1389
+ const result = modal.mount();
1390
+ // Kick off discovery on the next tick so the modal animates in first.
1391
+ queueMicrotask(() => modal.start());
1392
+ return result;
1393
+ }
1394
+
1395
+ function readOptsFrom(el) {
1396
+ const ds = el.dataset;
1397
+ let body = ds.x402Body;
1398
+ if (body) {
1399
+ try { body = JSON.parse(body); } catch { /* keep as string */ }
1400
+ }
1401
+ let headers = ds.x402Headers;
1402
+ if (headers) {
1403
+ try { headers = JSON.parse(headers); } catch { headers = undefined; }
1404
+ }
1405
+ let caps = ds.x402Caps;
1406
+ if (caps) {
1407
+ try { caps = JSON.parse(caps); } catch { caps = undefined; }
1408
+ }
1409
+ return {
1410
+ endpoint: ds.x402Endpoint,
1411
+ method: ds.x402Method || (body ? 'POST' : 'GET'),
1412
+ body,
1413
+ headers,
1414
+ caps,
1415
+ apiOrigin: ds.x402ApiOrigin,
1416
+ merchant: ds.x402Merchant,
1417
+ action: ds.x402Action || el.textContent?.trim().slice(0, 60),
1418
+ };
1419
+ }
1420
+
1421
+ function bindElement(el) {
1422
+ if (el.dataset.x402Bound === '1') return;
1423
+ el.dataset.x402Bound = '1';
1424
+ el.addEventListener('click', async (e) => {
1425
+ e.preventDefault();
1426
+ const opts = readOptsFrom(el);
1427
+ try {
1428
+ const out = await pay(opts);
1429
+ if (out?.siwx) {
1430
+ el.dispatchEvent(new CustomEvent('x402:siwx-signed', { detail: out.siwx, bubbles: true }));
1431
+ }
1432
+ el.dispatchEvent(new CustomEvent('x402:result', { detail: out, bubbles: true }));
1433
+ } catch (err) {
1434
+ if (err?.code === 'cancelled') return;
1435
+ el.dispatchEvent(new CustomEvent('x402:error', { detail: { error: err?.message || String(err) }, bubbles: true }));
1436
+ }
1437
+ });
1438
+ }
1439
+
1440
+ /** Scan the document and bind every `[data-x402-endpoint]` element. Idempotent. */
1441
+ export function init() {
1442
+ if (typeof document === 'undefined') return;
1443
+ document.querySelectorAll('[data-x402-endpoint]').forEach(bindElement);
1444
+ }
1445
+
1446
+ export { VERSION as version, bindElement, readOptsFrom };