@three-ws/x402-payment-modal 1.1.0 → 1.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.
package/dist/x402.js CHANGED
@@ -1,4 +1,4 @@
1
- /*! @three-ws/x402-payment-modal v1.1.0 — Apache-2.0 — https://three.ws */
1
+ /*! @three-ws/x402-payment-modal v1.2.0 — UNLICENSED — https://github.com/nirholas/x402-payment-modal#readme */
2
2
  // @three-ws/x402-payment-modal — drop-in payment modal for any x402 endpoint.
3
3
  //
4
4
  // A single, dependency-free ES module. Drop it on any page and any element with
@@ -27,8 +27,9 @@
27
27
  // action: 'Summarize',
28
28
  // });
29
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
30
+ // The modal handles wallet connect (Phantom/Solflare/Backpack/… for Solana,
31
+ // EIP-6963 browser wallets for Base/EVM USDC via EIP-3009), drives the
32
+ // 402 → sign → retry flow, optional
32
33
  // SIWX (Sign-In-With-X) re-entry, client-side spending caps, and shows the
33
34
  // result. Vanilla JS, no bundler required.
34
35
  //
@@ -37,12 +38,13 @@
37
38
  // is configurable via `configure({...})` or `data-*` attributes on the script
38
39
  // tag — see CONFIG / configure() below and docs/api-reference.md.
39
40
 
40
- const VERSION = '1.1.0';
41
+ const VERSION = '1.2.0';
41
42
 
42
43
  // ─────────────────────────────────────────────────────────── configuration ───
43
44
  // 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).
45
+ // The defaults below settle a 402 from ANY origin with zero config; override with
46
+ // configure() or via `data-*` attributes on the <script> tag (read once at load
47
+ // by readScriptConfig).
46
48
  const CONFIG = {
47
49
  // Origin that serves the Solana checkout endpoints
48
50
  // (`/api/x402-checkout?action=prepare|encode`). `null` → resolve from this
@@ -52,22 +54,47 @@ const CONFIG = {
52
54
  // Path of the checkout endpoint on `checkoutOrigin`. Override if you mount
53
55
  // the server handler somewhere other than /api/x402-checkout.
54
56
  checkoutPath: '/api/x402-checkout',
55
- // Footer attribution shown in the modal.
56
- brand: { name: 'three.ws', url: 'https://three.ws' },
57
+ // Footer attribution shown in the modal. Both fields default to null so no
58
+ // "Powered by" link renders until a host opts in via
59
+ // configure({ brand: { name, url } }).
60
+ brand: { name: null, url: null },
57
61
  // Small text on the left of the footer.
58
62
  footerNote: 'x402 · onchain settled',
59
63
  // 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.
64
+ // declares a builder-code extension. Empty by default no self-attribution
65
+ // unless a host opts in via configure({ builderCode: { wallet, service } }).
66
+ builderCode: { wallet: '', service: '' },
67
+ // Color scheme: 'auto' follows the OS; 'light'/'dark' force it.
68
+ theme: 'auto',
69
+ // Optional flat map of --x402-* design tokens for brand-matching at runtime.
70
+ cssVars: null,
71
+ // Primary CDN URLs for the crypto helpers loaded on demand (only when a Solana
72
+ // or EVM sign-in payment is actually attempted). Each is tried first, then the
73
+ // loader falls back across additional independent CDNs (see ESM_FALLBACKS) so a
74
+ // single CDN outage during a traffic spike can't break payments. Repoint these
75
+ // at a self-hosted mirror to satisfy a strict Content-Security-Policy — your URL
76
+ // is always tried first.
65
77
  esm: {
66
78
  solanaWeb3: 'https://esm.sh/@solana/web3.js@1.95.3?bundle',
67
79
  nobleHashesSha3: 'https://esm.sh/@noble/hashes@1.4.0/sha3?bundle',
68
80
  },
69
81
  };
70
82
 
83
+ // Independent CDN fallbacks tried (in order, after the configured primary) if the
84
+ // primary is slow or down. Three uncorrelated providers ≈ no single point of
85
+ // failure for the dynamic crypto-helper import that the Solana path depends on.
86
+ const ESM_FALLBACKS = {
87
+ solanaWeb3: [
88
+ 'https://cdn.jsdelivr.net/npm/@solana/web3.js@1.95.3/+esm',
89
+ 'https://cdn.skypack.dev/@solana/web3.js@1.95.3',
90
+ ],
91
+ nobleHashesSha3: [
92
+ 'https://cdn.jsdelivr.net/npm/@noble/hashes@1.4.0/sha3/+esm',
93
+ 'https://cdn.skypack.dev/@noble/hashes@1.4.0/sha3',
94
+ ],
95
+ };
96
+ const ESM_IMPORT_TIMEOUT_MS = 12_000;
97
+
71
98
  /**
72
99
  * Merge caller overrides into CONFIG. Shallow-merges the nested `brand`,
73
100
  * `builderCode`, and `esm` objects so you can override a single field. Call
@@ -77,12 +104,19 @@ const CONFIG = {
77
104
  */
78
105
  export function configure(opts = {}) {
79
106
  if (!opts || typeof opts !== 'object') return CONFIG;
80
- for (const key of ['checkoutOrigin', 'checkoutPath', 'footerNote']) {
107
+ // theme: 'light' | 'dark' | 'auto' — force the modal's color scheme regardless
108
+ // of the OS preference (auto = follow the OS, the default).
109
+ for (const key of ['checkoutOrigin', 'checkoutPath', 'footerNote', 'theme']) {
81
110
  if (opts[key] !== undefined) CONFIG[key] = opts[key];
82
111
  }
83
112
  if (opts.brand && typeof opts.brand === 'object') Object.assign(CONFIG.brand, opts.brand);
84
113
  if (opts.builderCode && typeof opts.builderCode === 'object') Object.assign(CONFIG.builderCode, opts.builderCode);
85
114
  if (opts.esm && typeof opts.esm === 'object') Object.assign(CONFIG.esm, opts.esm);
115
+ // cssVars: a flat map of x402 design tokens to brand-match without CSS files,
116
+ // e.g. configure({ cssVars: { '--x402-accent': '#ff5c00', '--x402-radius': '8px' } }).
117
+ if (opts.cssVars && typeof opts.cssVars === 'object') {
118
+ CONFIG.cssVars = { ...(CONFIG.cssVars || {}), ...opts.cssVars };
119
+ }
86
120
  return CONFIG;
87
121
  }
88
122
 
@@ -138,12 +172,11 @@ function normalizeAccept(accept) {
138
172
  // ─────────────────────────────────────────────── Well-known Solana tokens ────
139
173
  // Mints the modal recognizes on sight, so a 402 `accept` can omit
140
174
  // `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).
175
+ // decimals, and branding. USDC is the always-on default settlement asset on
176
+ // Solana — the universal dollar-stable rail. THREE is an optional opt-in SPL
177
+ // token a merchant can accept alongside USDC: add a Solana `accept` whose
178
+ // `asset` is THREE_MINT and the server checkout transfers it like any other
179
+ // mint (no extra wiring). The modal works fully without ever touching THREE.
147
180
  export const USDC_MINT_SOLANA = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
148
181
  export const THREE_MINT = 'FeMbDoX7R1Psc4GEcvJdsbNbZA3bfztcyDCatJVJpump';
149
182
 
@@ -335,10 +368,11 @@ function browserRollbackReservation(reservation) {
335
368
  }
336
369
 
337
370
  // ──────────────────────────────────────────── ERC-8021 builder-code echo ────
338
- // The server-side x402-spec.js enforces that any client-echoed builder-code
371
+ // A server that supports ERC-8021 enforces that any client-echoed builder-code
339
372
  // `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"]`.
373
+ // can append their own service code in `s` and set their wallet code `w`. Both
374
+ // are empty by default, so the modal echoes nothing extra; a host opts into
375
+ // self-attribution via configure({ builderCode: { wallet, service } }).
342
376
 
343
377
  const BUILDER_CODE_KEY = 'builder-code';
344
378
  const BUILDER_CODE_PATTERN = /^[a-z0-9_]{1,32}$/;
@@ -457,7 +491,7 @@ function base58encode(bytes) {
457
491
  let _evmChecksum = null;
458
492
  async function loadEvmChecksum() {
459
493
  if (_evmChecksum) return _evmChecksum;
460
- const sha3 = await import(/* @vite-ignore */ CONFIG.esm.nobleHashesSha3);
494
+ const sha3 = await importWithFallback(CONFIG.esm.nobleHashesSha3, ESM_FALLBACKS.nobleHashesSha3, '@noble/hashes/sha3');
461
495
  const keccak = sha3.keccak_256;
462
496
  _evmChecksum = (addr) => {
463
497
  const a = String(addr).toLowerCase().replace(/^0x/, '');
@@ -477,118 +511,218 @@ async function loadEvmChecksum() {
477
511
  // ───────────────────────────────────────────────────────────────── styles ────
478
512
 
479
513
  const STYLE_ID = 'x402-styles';
480
- const STYLES = `
481
- :root {
514
+ // Full design-token surface. Hosts brand-match by setting any of these on
515
+ // `.x402-overlay` (via configure({ cssVars }) or their own CSS) — no !important,
516
+ // no specificity battles. Dark values are applied both by the OS preference and
517
+ // by an explicit `.x402-theme-dark` class (configure({ theme })).
518
+ const TOKENS_LIGHT = `
482
519
  --x402-z: 2147483600;
520
+ --x402-font: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
521
+ --x402-mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
522
+ --x402-scrim: rgba(8, 10, 18, 0.55);
523
+ --x402-surface: #ffffff;
524
+ --x402-surface-2: #f7f8fa;
525
+ --x402-fg: #0f1116;
526
+ --x402-muted: #646b7a;
527
+ --x402-faint: #9aa1ad;
528
+ --x402-border: #ecedf1;
529
+ --x402-border-strong: #d8dce4;
530
+ --x402-accent: #635bff;
531
+ --x402-accent-hover: #524bdb;
532
+ --x402-accent-weak: #f2f1ff;
533
+ --x402-accent-fg: #ffffff;
534
+ --x402-success: #16a34a;
535
+ --x402-success-fg: #15803d;
536
+ --x402-success-bg: #f0fdf4;
537
+ --x402-success-border: #bbf7d0;
538
+ --x402-error: #dc2626;
539
+ --x402-error-fg: #b91c1c;
540
+ --x402-error-bg: #fef2f2;
541
+ --x402-error-border: #fecaca;
542
+ --x402-warn-fg: #b45309;
543
+ --x402-warn-bg: #fffbeb;
544
+ --x402-warn-border: #fde68a;
545
+ --x402-link: #524bdb;
546
+ --x402-radius: 18px;
547
+ --x402-radius-md: 12px;
548
+ --x402-radius-sm: 9px;
549
+ --x402-shadow: 0 24px 80px rgba(8, 10, 18, 0.28), 0 4px 16px rgba(8, 10, 18, 0.12);
550
+ --x402-btn-bg: #0f1116;
551
+ --x402-btn-fg: #ffffff;
552
+ --x402-btn-hover: #242833;
553
+ --x402-btn-shadow: 0 1px 2px rgba(8, 10, 18, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.12);
554
+ `;
555
+ const TOKENS_DARK = `
556
+ --x402-scrim: rgba(0, 0, 0, 0.62);
557
+ --x402-surface: #161616;
558
+ --x402-surface-2: #1d1d1d;
559
+ --x402-fg: #e6e8f0;
560
+ --x402-muted: #9aa1b4;
561
+ --x402-faint: #6b7088;
562
+ --x402-border: #2a2a2a;
563
+ --x402-border-strong: #3a3a40;
564
+ --x402-accent: #8b85ff;
565
+ --x402-accent-hover: #a29cff;
566
+ --x402-accent-weak: #1e1b3a;
567
+ --x402-accent-fg: #ffffff;
568
+ --x402-success: #22c55e;
569
+ --x402-success-fg: #4ade80;
570
+ --x402-success-bg: #0b1f17;
571
+ --x402-success-border: #14532d;
572
+ --x402-error: #ef4444;
573
+ --x402-error-fg: #fca5a5;
574
+ --x402-error-bg: #1f1416;
575
+ --x402-error-border: #7f1d1d;
576
+ --x402-warn-fg: #fcd34d;
577
+ --x402-warn-bg: #2a1d10;
578
+ --x402-warn-border: #78350f;
579
+ --x402-link: #a29cff;
580
+ --x402-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
581
+ --x402-btn-bg: #ffffff;
582
+ --x402-btn-fg: #0f1116;
583
+ --x402-btn-hover: #e7e9ee;
584
+ --x402-btn-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.5);
585
+ `;
586
+
587
+ const STYLES = `
588
+ .x402-overlay { ${TOKENS_LIGHT} }
589
+ .x402-overlay.x402-theme-dark { ${TOKENS_DARK} }
590
+ @media (prefers-color-scheme: dark) {
591
+ .x402-overlay:not(.x402-theme-light) { ${TOKENS_DARK} }
483
592
  }
484
593
  .x402-overlay {
485
594
  position: fixed; inset: 0;
486
- background: rgba(8, 10, 18, 0.55);
595
+ background: var(--x402-scrim);
487
596
  backdrop-filter: blur(10px);
488
597
  -webkit-backdrop-filter: blur(10px);
489
598
  display: flex; align-items: center; justify-content: center;
490
599
  z-index: var(--x402-z);
491
600
  opacity: 0; transition: opacity 0.16s ease-out;
492
- font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
601
+ font-family: var(--x402-font);
493
602
  -webkit-font-smoothing: antialiased;
494
- color: #0f0f0f;
603
+ color: var(--x402-fg);
495
604
  }
496
605
  .x402-overlay.x402-open { opacity: 1; }
497
606
  .x402-overlay * { box-sizing: border-box; }
607
+ .x402-overlay :focus-visible {
608
+ outline: 2px solid var(--x402-accent);
609
+ outline-offset: 2px;
610
+ }
498
611
  .x402-modal {
499
612
  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);
613
+ background: var(--x402-surface);
614
+ border-radius: var(--x402-radius);
615
+ box-shadow: var(--x402-shadow);
503
616
  overflow: hidden;
504
617
  transform: translateY(8px) scale(0.985);
505
- transition: transform 0.18s ease-out;
618
+ transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
506
619
  display: flex; flex-direction: column;
507
620
  max-height: calc(100dvh - 32px);
508
621
  }
509
622
  .x402-overlay.x402-open .x402-modal { transform: translateY(0) scale(1); }
510
623
  .x402-head {
511
624
  padding: 18px 20px 14px;
512
- border-bottom: 1px solid #eef0f4;
625
+ border-bottom: 1px solid var(--x402-border);
513
626
  display: flex; align-items: center; gap: 12px;
514
627
  }
515
- .x402-head .x402-merchant {
516
- flex: 1; min-width: 0;
517
- }
628
+ .x402-head .x402-merchant { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; }
629
+ .x402-head .x402-merchant-text { min-width: 0; }
630
+ .x402-brand-logo { width: 30px; height: 30px; flex: 0 0 auto; border-radius: 8px; object-fit: cover; }
518
631
  .x402-merchant .x402-name {
519
- font-size: 12px; color: #5a6378; font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase;
632
+ font-size: 12px; color: var(--x402-muted); font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase;
520
633
  margin-bottom: 2px;
521
634
  }
522
635
  .x402-merchant .x402-action {
523
- font-size: 17px; font-weight: 700; color: #0f0f0f;
636
+ font-size: 17px; font-weight: 700; color: var(--x402-fg);
524
637
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
525
638
  letter-spacing: -0.01em;
526
639
  }
527
640
  .x402-close {
528
- width: 32px; height: 32px;
529
- border-radius: 8px; border: none; background: #f3f4f7;
530
- font-size: 16px; color: #5a6378; cursor: pointer;
641
+ width: 32px; height: 32px; flex: 0 0 auto;
642
+ border-radius: 8px; border: none; background: var(--x402-surface-2);
643
+ color: var(--x402-muted); cursor: pointer;
531
644
  display: flex; align-items: center; justify-content: center;
532
- transition: background 0.12s;
645
+ transition: background 0.12s, color 0.12s;
533
646
  }
534
- .x402-close:hover { background: #e7e9ee; color: #0f0f0f; }
647
+ .x402-close:hover { background: var(--x402-border-strong); color: var(--x402-fg); }
648
+ .x402-close svg { width: 14px; height: 14px; }
535
649
 
536
650
  .x402-price-row {
537
651
  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;
652
+ display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
653
+ background: var(--x402-surface-2);
654
+ border-bottom: 1px solid var(--x402-border);
541
655
  }
656
+ .x402-price-main { min-width: 0; }
542
657
  .x402-price {
543
- font-size: 32px; font-weight: 700; letter-spacing: -0.02em; color: #0f0f0f;
658
+ font-size: 32px; font-weight: 700; letter-spacing: -0.02em; color: var(--x402-fg);
544
659
  font-variant-numeric: tabular-nums;
545
660
  }
546
- .x402-price .x402-currency { font-size: 14px; color: #5a6378; font-weight: 600; margin-left: 6px; letter-spacing: 0; }
661
+ .x402-price .x402-currency { font-size: 14px; color: var(--x402-muted); font-weight: 600; margin-left: 6px; letter-spacing: 0; }
662
+ .x402-price-sub { font-size: 11px; color: var(--x402-muted); margin-top: 3px; font-weight: 500; }
547
663
  .x402-network {
548
- font-size: 12px; color: #5a6378; font-weight: 500;
549
- background: #f3f4f7; padding: 5px 10px; border-radius: 99px;
664
+ font-size: 12px; color: var(--x402-muted); font-weight: 500; flex: 0 0 auto;
665
+ background: var(--x402-surface); border: 1px solid var(--x402-border);
666
+ padding: 5px 10px; border-radius: 99px;
550
667
  display: inline-flex; align-items: center; gap: 6px;
551
668
  }
552
669
  .x402-network::before {
553
670
  content: ''; width: 6px; height: 6px; border-radius: 50%;
554
- background: #22c55e;
671
+ background: var(--x402-success);
672
+ box-shadow: 0 0 0 0 var(--x402-success);
673
+ animation: x402-pulse 2s ease-out infinite;
674
+ }
675
+ @keyframes x402-pulse {
676
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--x402-success) 55%, transparent); }
677
+ 70% { box-shadow: 0 0 0 5px transparent; }
678
+ 100% { box-shadow: 0 0 0 0 transparent; }
555
679
  }
556
680
 
557
681
  .x402-body {
558
- padding: 16px 20px 18px;
682
+ padding: 16px 20px max(18px, env(safe-area-inset-bottom));
559
683
  flex: 1 1 auto; overflow-y: auto;
560
684
  display: flex; flex-direction: column; gap: 10px;
561
685
  }
686
+ .x402-body > * { animation: x402-fade 0.16s ease-out both; }
687
+ @keyframes x402-fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
562
688
  .x402-step {
563
689
  display: flex; gap: 12px; align-items: flex-start;
564
690
  padding: 10px 0;
565
691
  }
566
- .x402-step + .x402-step { border-top: 1px solid #f3f4f7; }
692
+ .x402-step + .x402-step { border-top: 1px solid var(--x402-border); }
567
693
  .x402-step-num {
568
694
  width: 22px; height: 22px; flex: 0 0 auto;
569
- border-radius: 50%; border: 1.5px solid #d0d4dd; background: #fff;
570
- color: #5a6378;
695
+ border-radius: 50%; border: 1.5px solid var(--x402-border-strong); background: var(--x402-surface);
696
+ color: var(--x402-muted);
571
697
  font-size: 11px; font-weight: 700;
572
698
  display: flex; align-items: center; justify-content: center;
573
699
  }
574
700
  .x402-step.x402-active .x402-step-num {
575
- border-color: #0a84ff; background: #0a84ff; color: #fff;
701
+ border-color: var(--x402-accent); background: var(--x402-accent); color: var(--x402-accent-fg);
576
702
  animation: x402-spin 1.2s linear infinite;
577
703
  }
578
704
  .x402-step.x402-done .x402-step-num {
579
- border-color: #22c55e; background: #22c55e; color: #fff;
705
+ border-color: var(--x402-success); background: var(--x402-success); color: #fff;
580
706
  }
581
707
  .x402-step.x402-error .x402-step-num {
582
- border-color: #ef4444; background: #ef4444; color: #fff;
708
+ border-color: var(--x402-error); background: var(--x402-error); color: #fff;
583
709
  }
584
710
  @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); }
711
+ from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--x402-accent) 40%, transparent); }
712
+ to { box-shadow: 0 0 0 8px transparent; }
587
713
  }
588
714
  .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; }
715
+ .x402-step-label { font-size: 14px; font-weight: 600; color: var(--x402-fg); line-height: 1.35; }
716
+ .x402-step-meta { font-size: 12px; color: var(--x402-muted); margin-top: 2px; font-feature-settings: 'tnum' 1; }
717
+ .x402-step.x402-error .x402-step-meta { color: var(--x402-error-fg); }
718
+
719
+ .x402-skeleton {
720
+ border-radius: 6px;
721
+ background: linear-gradient(100deg, var(--x402-surface-2) 30%, var(--x402-border) 50%, var(--x402-surface-2) 70%);
722
+ background-size: 200% 100%;
723
+ animation: x402-shimmer 1.3s ease-in-out infinite;
724
+ }
725
+ @keyframes x402-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
592
726
 
593
727
  .x402-wallet-buttons {
594
728
  display: flex; flex-direction: column; gap: 8px;
@@ -596,26 +730,30 @@ const STYLES = `
596
730
  }
597
731
  .x402-wallet-btn {
598
732
  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;
733
+ background: var(--x402-surface); border: 1.5px solid var(--x402-border-strong); border-radius: 11px;
734
+ font-size: 14px; font-weight: 600; color: var(--x402-fg);
601
735
  cursor: pointer; font-family: inherit;
602
736
  display: flex; align-items: center; gap: 12px;
603
737
  transition: border-color 0.12s, background 0.12s, transform 0.05s;
604
738
  }
605
- .x402-wallet-btn:hover:not(:disabled) { border-color: #0a84ff; background: #f7faff; }
739
+ .x402-wallet-btn:hover:not(:disabled) { border-color: var(--x402-accent); background: var(--x402-accent-weak); }
606
740
  .x402-wallet-btn:active:not(:disabled) { transform: translateY(1px); }
607
741
  .x402-wallet-btn:disabled { opacity: 0.45; cursor: not-allowed; }
608
742
  .x402-wallet-icon {
609
743
  width: 28px; height: 28px; flex: 0 0 auto;
610
744
  border-radius: 7px;
611
745
  display: flex; align-items: center; justify-content: center;
612
- font-size: 16px;
613
- background: #f3f4f7;
746
+ background: var(--x402-surface-2); overflow: hidden;
614
747
  }
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; }
748
+ .x402-wallet-icon svg, .x402-wallet-icon img { width: 18px; height: 18px; }
617
749
  .x402-wallet-name { flex: 1; text-align: left; }
618
- .x402-wallet-meta { font-size: 11px; color: #8a90a8; font-weight: 500; }
750
+ .x402-wallet-meta { font-size: 11px; color: var(--x402-muted); font-weight: 500; }
751
+ .x402-wallet-install {
752
+ font-size: 12px; color: var(--x402-muted); line-height: 1.5; text-align: center;
753
+ padding: 4px 0 2px;
754
+ }
755
+ .x402-wallet-install a { color: var(--x402-link); text-decoration: none; font-weight: 600; }
756
+ .x402-wallet-install a:hover { text-decoration: underline; }
619
757
 
620
758
  .x402-token-row {
621
759
  display: flex; gap: 8px; margin-bottom: 10px;
@@ -623,145 +761,230 @@ const STYLES = `
623
761
  .x402-token-pill {
624
762
  flex: 1; min-width: 0; cursor: pointer; font-family: inherit;
625
763
  padding: 9px 12px; border-radius: 10px;
626
- background: #ffffff; border: 1.5px solid #e2e5ec;
764
+ background: var(--x402-surface); border: 1.5px solid var(--x402-border-strong);
627
765
  display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
628
- transition: border-color 0.12s, background 0.12s, transform 0.05s;
766
+ transition: border-color 0.12s, background 0.12s, transform 0.05s, box-shadow 0.12s;
629
767
  }
630
- .x402-token-pill:hover { border-color: #c3c9d6; background: #f9fafc; }
768
+ .x402-token-pill:hover { border-color: var(--x402-accent); background: var(--x402-accent-weak); }
631
769
  .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; }
770
+ .x402-token-pill.x402-on {
771
+ border-color: var(--x402-accent); background: var(--x402-accent-weak);
772
+ box-shadow: inset 0 0 0 1px var(--x402-accent);
773
+ }
774
+ .x402-token-sym { font-size: 13px; font-weight: 700; color: var(--x402-fg); letter-spacing: -0.005em; }
775
+ .x402-token-amt { font-size: 11px; color: var(--x402-muted); font-weight: 600; font-variant-numeric: tabular-nums; }
635
776
 
636
777
  .x402-pay-btn {
637
778
  width: 100%; padding: 14px 16px;
638
- background: #0f0f0f; color: #fff; border: none;
639
- border-radius: 12px;
779
+ background: var(--x402-btn-bg); color: var(--x402-btn-fg); border: none;
780
+ border-radius: var(--x402-radius-md);
640
781
  font-size: 15px; font-weight: 700; font-family: inherit;
641
782
  cursor: pointer; letter-spacing: -0.005em;
642
- transition: background 0.12s, transform 0.05s;
783
+ box-shadow: var(--x402-btn-shadow);
784
+ transition: background 0.12s, transform 0.1s, box-shadow 0.12s;
643
785
  margin-top: 4px;
644
786
  display: flex; align-items: center; justify-content: center; gap: 8px;
645
787
  }
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; }
788
+ .x402-pay-btn:hover:not(:disabled) { background: var(--x402-btn-hover); transform: translateY(-1px); }
789
+ .x402-pay-btn:active:not(:disabled) { transform: translateY(0); }
790
+ .x402-pay-btn:disabled { opacity: 0.5; cursor: not-allowed; box-shadow: none; }
649
791
 
650
792
  .x402-pay-secondary {
651
793
  width: 100%; padding: 12px 14px;
652
- background: #ffffff; color: #0f0f0f;
653
- border: 1.5px solid #e2e5ec; border-radius: 11px;
794
+ background: var(--x402-surface); color: var(--x402-fg);
795
+ border: 1.5px solid var(--x402-border-strong); border-radius: 11px;
654
796
  font-size: 14px; font-weight: 600; font-family: inherit;
655
797
  cursor: pointer; letter-spacing: -0.005em;
656
798
  margin-top: 6px;
657
799
  transition: border-color 0.12s, background 0.12s, transform 0.05s;
658
800
  }
659
- .x402-pay-secondary:hover:not(:disabled) { border-color: #0a84ff; background: #f7faff; }
801
+ .x402-pay-secondary:hover:not(:disabled) { border-color: var(--x402-accent); background: var(--x402-accent-weak); }
660
802
  .x402-pay-secondary:active:not(:disabled) { transform: translateY(1px); }
661
803
 
804
+ .x402-trust {
805
+ font-size: 11.5px; color: var(--x402-muted); text-align: center;
806
+ margin-top: 10px; line-height: 1.45;
807
+ display: flex; align-items: center; justify-content: center; gap: 6px;
808
+ }
809
+ .x402-trust svg { width: 12px; height: 12px; flex: 0 0 auto; color: var(--x402-success); }
810
+
662
811
  .x402-siwx-hint {
663
- font-size: 11px; color: #5a6378; text-align: center;
812
+ font-size: 11px; color: var(--x402-muted); text-align: center;
664
813
  margin-top: 8px; line-height: 1.4;
665
814
  }
666
815
  .x402-siwx-fallback {
667
- font-size: 12px; color: #b45309; line-height: 1.45;
816
+ font-size: 12px; color: var(--x402-warn-fg); line-height: 1.45;
668
817
  padding: 8px 10px; border-radius: 8px;
669
- background: #fffbeb; border: 1px solid #fde68a;
818
+ background: var(--x402-warn-bg); border: 1px solid var(--x402-warn-border);
670
819
  margin-bottom: 6px;
671
820
  }
672
821
 
673
822
  .x402-error-box {
674
823
  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;
824
+ background: var(--x402-error-bg); border: 1px solid var(--x402-error-border); color: var(--x402-error-fg);
825
+ font-size: 13.5px; line-height: 1.5;
678
826
  word-break: break-word;
679
827
  }
680
- .x402-error-box strong { font-weight: 700; }
828
+ .x402-error-box .x402-error-title { font-weight: 700; margin-bottom: 2px; }
829
+ .x402-error-box .x402-error-detail { opacity: 0.85; font-size: 12.5px; }
681
830
 
682
831
  .x402-receipt {
683
- padding: 14px 16px; border-radius: 12px;
684
- background: linear-gradient(180deg, #f0fdf4 0%, #ffffff 100%);
685
- border: 1px solid #bbf7d0;
832
+ padding: 16px; border-radius: var(--x402-radius-md);
833
+ background: var(--x402-success-bg);
834
+ border: 1px solid var(--x402-success-border);
835
+ animation: x402-pop 0.34s cubic-bezier(0.16, 1, 0.3, 1) both;
836
+ }
837
+ @keyframes x402-pop { from { opacity: 0; transform: scale(0.94); } to { opacity: 1; transform: scale(1); } }
838
+ .x402-receipt-check {
839
+ width: 40px; height: 40px; margin: 2px auto 10px;
840
+ border-radius: 50%; background: var(--x402-success);
841
+ display: flex; align-items: center; justify-content: center;
842
+ animation: x402-check-pop 0.4s cubic-bezier(0.16, 1, 0.3, 1) 0.08s both;
686
843
  }
844
+ .x402-receipt-check svg { width: 22px; height: 22px; color: #fff; }
845
+ .x402-receipt-check svg path { stroke-dasharray: 24; stroke-dashoffset: 24; animation: x402-draw 0.4s ease-out 0.22s forwards; }
846
+ @keyframes x402-check-pop { 0% { transform: scale(0); } 60% { transform: scale(1.12); } 100% { transform: scale(1); } }
847
+ @keyframes x402-draw { to { stroke-dashoffset: 0; } }
687
848
  .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;
849
+ font-size: 15px; font-weight: 700; color: var(--x402-fg);
850
+ text-align: center; margin-bottom: 10px; letter-spacing: -0.01em;
692
851
  }
693
- .x402-receipt-title::before { content: '✓'; font-size: 14px; }
694
852
  .x402-receipt-row {
695
853
  display: flex; justify-content: space-between; gap: 12px;
696
- font-size: 12px; padding: 2px 0;
697
- font-family: ui-monospace, "JetBrains Mono", Menlo, monospace;
854
+ font-size: 12px; padding: 3px 0;
855
+ font-family: var(--x402-mono);
698
856
  }
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; }
857
+ .x402-receipt-row .x402-k { color: var(--x402-muted); }
858
+ .x402-receipt-row .x402-v { color: var(--x402-fg); text-align: right; word-break: break-all; }
859
+ .x402-receipt-row a { color: var(--x402-link); text-decoration: none; }
702
860
  .x402-receipt-row a:hover { text-decoration: underline; }
703
861
 
704
862
  .x402-result {
705
863
  padding: 12px 14px; border-radius: 10px;
706
- background: #fafbfc; border: 1px solid #e2e5ec;
864
+ background: var(--x402-surface-2); border: 1px solid var(--x402-border);
707
865
  max-height: 240px; overflow: auto;
708
- font-family: ui-monospace, "JetBrains Mono", Menlo, monospace;
709
- font-size: 12px; line-height: 1.5; color: #0f0f0f;
866
+ font-family: var(--x402-mono);
867
+ font-size: 12px; line-height: 1.5; color: var(--x402-fg);
710
868
  white-space: pre-wrap; word-break: break-word;
711
869
  }
870
+ .x402-result.x402-prose { font-family: var(--x402-font); font-size: 13.5px; line-height: 1.55; }
712
871
 
713
872
  .x402-foot {
714
- padding: 10px 20px 14px;
715
- border-top: 1px solid #eef0f4;
873
+ padding: 10px 20px max(14px, env(safe-area-inset-bottom));
874
+ border-top: 1px solid var(--x402-border);
716
875
  display: flex; align-items: center; justify-content: space-between;
717
- font-size: 11px; color: #8a90a8;
876
+ font-size: 11px; color: var(--x402-muted);
718
877
  }
719
- .x402-foot a { color: #5a6378; text-decoration: none; font-weight: 600; }
720
- .x402-foot a:hover { color: #0f0f0f; }
878
+ .x402-foot a { color: var(--x402-muted); text-decoration: none; font-weight: 600; }
879
+ .x402-foot a:hover { color: var(--x402-fg); }
721
880
  .x402-foot .x402-secure { display: flex; align-items: center; gap: 5px; }
722
- .x402-foot .x402-secure::before { content: '🔒'; font-size: 10px; }
881
+ .x402-foot .x402-secure svg { width: 11px; height: 11px; }
723
882
 
724
- @media (max-width: 480px) {
725
- .x402-modal { max-width: none; width: calc(100% - 16px); border-radius: 16px; }
726
- .x402-price { font-size: 26px; }
883
+ .x402-sr-only {
884
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
885
+ overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
727
886
  }
728
887
 
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; }
888
+ @media (max-width: 520px) {
889
+ .x402-overlay { align-items: flex-end; }
890
+ .x402-modal {
891
+ max-width: none; width: 100%;
892
+ border-radius: var(--x402-radius) var(--x402-radius) 0 0;
893
+ max-height: 92dvh;
894
+ transform: translateY(100%);
895
+ }
896
+ .x402-overlay.x402-open .x402-modal { transform: translateY(0); }
897
+ .x402-price { font-size: 28px; }
898
+ }
899
+
900
+ @media (prefers-reduced-motion: reduce) {
901
+ .x402-overlay, .x402-modal, .x402-body > *,
902
+ .x402-receipt, .x402-receipt-check, .x402-receipt-check svg path { animation: none !important; transition: none !important; }
903
+ .x402-step.x402-active .x402-step-num { animation: none; }
904
+ .x402-network::before { animation: none; }
905
+ .x402-receipt-check svg path { stroke-dashoffset: 0; }
762
906
  }
763
907
  `;
764
908
 
909
+ // Crisp inline SVG iconography — emoji/letter placeholders render differently
910
+ // per-OS and read as cheap; these inherit currentColor and stay sharp at any DPI.
911
+ const ICONS = {
912
+ close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>',
913
+ lock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>',
914
+ check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 13l4 4L19 7"/></svg>',
915
+ shield: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l7 3v6c0 4.5-3 7.4-7 9-4-1.6-7-4.5-7-9V6z"/><path d="M9 12l2 2 4-4"/></svg>',
916
+ phantom: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect width="24" height="24" rx="6" fill="#ab9ff2"/><path fill="#fff" d="M19 12.4c0 3.8-3.1 6.8-7 6.8-.6 0-1-.6-.7-1.1.2-.4 0-.9-.4-1.1-.4-.2-1 0-1.3.4-.5.7-1.3 1.1-2.2 1.1H6.7c-1.2 0-2.2-1-2.2-2.2v-1.7c0-4.1 3.4-7.4 7.5-7.4h-.1c3.9 0 7.1 2.6 7.1 5.9z"/><circle cx="9.4" cy="12.4" r="1" fill="#534bb1"/><circle cx="12.6" cy="12.4" r="1" fill="#534bb1"/></svg>',
917
+ wallet: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="6" width="18" height="13" rx="2.5"/><path d="M3 10h18"/><circle cx="16.5" cy="14" r="1.3" fill="currentColor" stroke="none"/></svg>',
918
+ };
919
+
920
+ // Human-readable headings for the failure step shown in the error box. Never
921
+ // surface the internal step id ("authorize", "verify") to a buyer.
922
+ const STEP_LABELS = {
923
+ discover: "Couldn't confirm the price",
924
+ connect: "Couldn't connect your wallet",
925
+ authorize: "Payment couldn't be authorized",
926
+ verify: "Payment couldn't be completed",
927
+ };
928
+
929
+ // ──────────────────────────────────────────────────── wallet discovery ────
930
+ // Detect every injected Solana wallet, not just Phantom — Solflare/Backpack/Glow
931
+ // users were dead-ended before. They share Phantom's connect/signTransaction/
932
+ // signMessage shape, so each drops straight into runSolana. Returns
933
+ // [{ kind:'solana', name, provider, icon }] in preference order.
934
+ function discoverSolanaWallets() {
935
+ if (typeof window === 'undefined') return [];
936
+ const out = [];
937
+ const seen = new Set();
938
+ const add = (name, provider, icon) => {
939
+ if (!provider || typeof provider.signTransaction !== 'function' || seen.has(provider)) return;
940
+ seen.add(provider);
941
+ out.push({ kind: 'solana', name, provider, icon: icon || ICONS.wallet });
942
+ };
943
+ add('Phantom', window.phantom?.solana || (window.solana?.isPhantom ? window.solana : null), ICONS.phantom);
944
+ add('Solflare', window.solflare?.isSolflare ? window.solflare : null, ICONS.wallet);
945
+ add('Backpack', window.backpack?.isBackpack ? window.backpack : null, ICONS.wallet);
946
+ add('Glow', window.glowSolana || window.glow || null, ICONS.wallet);
947
+ add('Coinbase Wallet', window.coinbaseSolana || null, ICONS.wallet);
948
+ // A standards-compliant injected provider that isn't one of the above.
949
+ if (window.solana && !window.solana.isPhantom) add(window.solana.isSolflare ? 'Solflare' : 'Solana wallet', window.solana, ICONS.wallet);
950
+ return out;
951
+ }
952
+
953
+ // EIP-6963 multi-provider discovery so a user with several EVM wallets isn't
954
+ // stuck with whoever won the window.ethereum race. Wallets that implement 6963
955
+ // re-announce synchronously on request; we fall back to legacy window.ethereum
956
+ // (and its .providers[] array) when none do.
957
+ function discoverEvmWallets() {
958
+ if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') return [];
959
+ const out = [];
960
+ const seen = new Set();
961
+ const add = (name, provider, icon) => {
962
+ if (!provider || seen.has(provider)) return;
963
+ seen.add(provider);
964
+ out.push({ kind: 'evm', name: name || 'Browser wallet', provider, icon: icon || ICONS.wallet });
965
+ };
966
+ const announced = [];
967
+ const onAnnounce = (e) => { if (e?.detail) announced.push(e.detail); };
968
+ window.addEventListener('eip6963:announceProvider', onAnnounce);
969
+ try { window.dispatchEvent(new Event('eip6963:requestProvider')); } catch { /* ignore */ }
970
+ window.removeEventListener('eip6963:announceProvider', onAnnounce);
971
+ for (const d of announced) add(d.info?.name, d.provider, d.info?.icon);
972
+ if (!out.length) {
973
+ const eth = window.ethereum;
974
+ const label = (p) => (p?.isMetaMask ? 'MetaMask' : p?.isCoinbaseWallet ? 'Coinbase Wallet' : 'Browser wallet');
975
+ if (Array.isArray(eth?.providers)) for (const p of eth.providers) add(label(p), p);
976
+ else if (eth) add(label(eth), eth);
977
+ }
978
+ return out;
979
+ }
980
+
981
+ // A wallet's icon is either an inline SVG string (our ICONS) or a data:/https
982
+ // URL (EIP-6963 / Wallet-Standard supply these) — render accordingly.
983
+ function walletIconHtml(icon) {
984
+ if (icon && /^(data:|https?:)/.test(icon)) return `<img src="${escapeHtml(icon)}" alt="" />`;
985
+ return icon || ICONS.wallet;
986
+ }
987
+
765
988
  function injectStyles() {
766
989
  if (document.getElementById(STYLE_ID)) return;
767
990
  const el = document.createElement('style');
@@ -796,22 +1019,40 @@ class CheckoutModal {
796
1019
  injectStyles();
797
1020
  const overlay = document.createElement('div');
798
1021
  overlay.className = 'x402-overlay';
1022
+ // Forced color scheme (configure({ theme })) overrides the OS preference.
1023
+ if (CONFIG.theme === 'dark') overlay.classList.add('x402-theme-dark');
1024
+ else if (CONFIG.theme === 'light') overlay.classList.add('x402-theme-light');
1025
+ // Brand design-token overrides applied inline so they win without !important.
1026
+ if (CONFIG.cssVars && typeof CONFIG.cssVars === 'object') {
1027
+ for (const [k, v] of Object.entries(CONFIG.cssVars)) {
1028
+ if (/^--x402-[a-z0-9-]+$/i.test(k)) overlay.style.setProperty(k, String(v));
1029
+ }
1030
+ }
1031
+ const logo = CONFIG.brand?.logo
1032
+ ? `<img class="x402-brand-logo" src="${escapeHtml(CONFIG.brand.logo)}" alt="" />`
1033
+ : '';
799
1034
  overlay.innerHTML = `
800
- <div class="x402-modal" role="dialog" aria-modal="true" aria-label="x402 payment">
1035
+ <div class="x402-modal" role="dialog" aria-modal="true" aria-labelledby="x402-act" aria-describedby="x402-price">
801
1036
  <div class="x402-head">
802
1037
  <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>
1038
+ ${logo}
1039
+ <div class="x402-merchant-text">
1040
+ <div class="x402-name" data-merchant>${escapeHtml(this.opts.merchant || 'Payment')}</div>
1041
+ <div class="x402-action" id="x402-act" data-action>${escapeHtml(this.opts.action || 'Pay-per-call')}</div>
1042
+ </div>
805
1043
  </div>
806
- <button class="x402-close" data-close aria-label="Close">✕</button>
1044
+ <button class="x402-close" data-close aria-label="Close payment">${ICONS.close}</button>
807
1045
  </div>
808
1046
  <div class="x402-price-row">
809
- <div class="x402-price" data-price>—<span class="x402-currency"> USDC</span></div>
1047
+ <div class="x402-price-main">
1048
+ <div class="x402-price" id="x402-price" data-price>—<span class="x402-currency"> USDC</span></div>
1049
+ <div class="x402-price-sub" data-price-sub></div>
1050
+ </div>
810
1051
  <div class="x402-network" data-network>resolving…</div>
811
1052
  </div>
812
- <div class="x402-body" data-body></div>
1053
+ <div class="x402-body" data-body aria-live="polite"></div>
813
1054
  <div class="x402-foot">
814
- <span class="x402-secure">${escapeHtml(CONFIG.footerNote)}</span>
1055
+ <span class="x402-secure">${ICONS.lock}${escapeHtml(CONFIG.footerNote)}</span>
815
1056
  ${CONFIG.brand?.name ? `<a href="${escapeHtml(CONFIG.brand.url || '#')}" target="_blank" rel="noopener">Powered by ${escapeHtml(CONFIG.brand.name)}</a>` : ''}
816
1057
  </div>
817
1058
  </div>
@@ -820,24 +1061,57 @@ class CheckoutModal {
820
1061
  this.overlay = overlay;
821
1062
  this.bodyEl = overlay.querySelector('[data-body]');
822
1063
  this.priceEl = overlay.querySelector('[data-price]');
1064
+ this.priceSubEl = overlay.querySelector('[data-price-sub]');
823
1065
  this.networkEl = overlay.querySelector('[data-network]');
824
1066
  overlay.querySelector('[data-close]').addEventListener('click', () => this.close('cancelled'));
825
1067
  overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close('cancelled'); });
826
- this.onKey = (e) => { if (e.key === 'Escape') this.close('cancelled'); };
1068
+ // Remember what had focus so we can restore it on close (a11y).
1069
+ this.previouslyFocused = typeof document !== 'undefined' ? document.activeElement : null;
1070
+ this.onKey = (e) => {
1071
+ if (e.key === 'Escape') { this.close('cancelled'); return; }
1072
+ if (e.key === 'Tab') this.trapTab(e);
1073
+ };
827
1074
  document.addEventListener('keydown', this.onKey);
828
- requestAnimationFrame(() => overlay.classList.add('x402-open'));
1075
+ requestAnimationFrame(() => {
1076
+ overlay.classList.add('x402-open');
1077
+ this.focusFirst();
1078
+ });
829
1079
  return new Promise((resolve, reject) => {
830
1080
  this.resolve = resolve;
831
1081
  this.reject = reject;
832
1082
  });
833
1083
  }
834
1084
 
1085
+ // All visible, enabled, focusable elements inside the modal, in DOM order.
1086
+ focusables() {
1087
+ if (!this.overlay) return [];
1088
+ const sel = 'button:not([disabled]), [href], input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])';
1089
+ return Array.from(this.overlay.querySelectorAll(sel)).filter((el) => el.offsetParent !== null);
1090
+ }
1091
+
1092
+ focusFirst() {
1093
+ const list = this.focusables();
1094
+ (list.find((el) => !el.hasAttribute('data-close')) || list[0])?.focus?.();
1095
+ }
1096
+
1097
+ // Keep Tab within the dialog — a modal must not leak focus to the page behind.
1098
+ trapTab(e) {
1099
+ const list = this.focusables();
1100
+ if (!list.length) return;
1101
+ const first = list[0];
1102
+ const last = list[list.length - 1];
1103
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
1104
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
1105
+ }
1106
+
835
1107
  close(reason) {
836
1108
  if (this.disposed) return;
837
1109
  this.disposed = true;
838
1110
  document.removeEventListener('keydown', this.onKey);
839
1111
  this.overlay.classList.remove('x402-open');
840
1112
  setTimeout(() => this.overlay.remove(), 180);
1113
+ // Restore focus to whatever triggered the modal.
1114
+ this.previouslyFocused?.focus?.();
841
1115
  if (reason === 'cancelled' && this.reject) {
842
1116
  const err = new Error('cancelled');
843
1117
  err.code = 'cancelled';
@@ -873,8 +1147,13 @@ class CheckoutModal {
873
1147
  }
874
1148
 
875
1149
  renderConnect() {
876
- const phantomDetected = typeof window !== 'undefined' && (window.solana?.isPhantom || window.phantom?.solana);
877
- const evmDetected = typeof window !== 'undefined' && window.ethereum;
1150
+ // Detect ALL injected wallets, not just Phantom/window.ethereum.
1151
+ const solanaWallets = discoverSolanaWallets();
1152
+ const evmWallets = discoverEvmWallets();
1153
+ this._solWallets = solanaWallets;
1154
+ this._evmWallets = evmWallets;
1155
+ const phantomDetected = solanaWallets.length > 0;
1156
+ const evmDetected = evmWallets.length > 0;
878
1157
  const solanaList = listSolanaAccepts(this.challenge);
879
1158
  // Keep a valid selection: honour the buyer's prior pick if it's still on
880
1159
  // offer, otherwise fall back to the default (USDC-first).
@@ -905,10 +1184,12 @@ class CheckoutModal {
905
1184
  // fallback, which needs to explain itself. One-shot via autoConnectTried.
906
1185
  if (this.opts.autoConnect && !this.autoConnectTried && !this.siwxFallbackNotice) {
907
1186
  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; }
1187
+ // Only auto-connect when there's exactly one wallet to choose — otherwise
1188
+ // the buyer must pick which one.
1189
+ const solanaViable = solanaAccept && solanaWallets.length === 1;
1190
+ const evmViable = evmAccept && evmWallets.length === 1;
1191
+ if (solanaViable && !evmViable) { this.runSolana(solanaAccept, solanaWallets[0].provider); return; }
1192
+ if (evmViable && !solanaViable) { this.runEvm(evmAccept, evmWallets[0].provider); return; }
912
1193
  }
913
1194
 
914
1195
  // When the merchant offers more than one Solana token (e.g. USDC and
@@ -934,40 +1215,75 @@ class CheckoutModal {
934
1215
  const solMeta = solanaList.length > 1
935
1216
  ? `${networkLabel(solanaAccept.network, solanaAccept)} · ${solInfo.symbol}`
936
1217
  : 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
- `);
1218
+ if (solanaWallets.length) {
1219
+ solanaWallets.forEach((w, i) => buttons.push(`
1220
+ <button class="x402-wallet-btn" data-wallet-kind="solana" data-wallet-idx="${i}">
1221
+ <div class="x402-wallet-icon">${walletIconHtml(w.icon)}</div>
1222
+ <span class="x402-wallet-name">${escapeHtml(w.name)}</span>
1223
+ <span class="x402-wallet-meta"${i === 0 ? ' data-sol-meta' : ''}>${escapeHtml(solMeta)}</span>
1224
+ </button>
1225
+ `));
1226
+ } else {
1227
+ buttons.push(`
1228
+ <button class="x402-wallet-btn" disabled>
1229
+ <div class="x402-wallet-icon">${ICONS.phantom}</div>
1230
+ <span class="x402-wallet-name">No Solana wallet detected</span>
1231
+ <span class="x402-wallet-meta">${escapeHtml(solMeta)}</span>
1232
+ </button>
1233
+ `);
1234
+ }
944
1235
  }
945
1236
  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
- `);
1237
+ if (evmWallets.length) {
1238
+ evmWallets.forEach((w, i) => buttons.push(`
1239
+ <button class="x402-wallet-btn" data-wallet-kind="evm" data-wallet-idx="${i}">
1240
+ <div class="x402-wallet-icon">${walletIconHtml(w.icon)}</div>
1241
+ <span class="x402-wallet-name">${escapeHtml(w.name)}</span>
1242
+ <span class="x402-wallet-meta">${escapeHtml(networkLabel(evmAccept.network, evmAccept))}</span>
1243
+ </button>
1244
+ `));
1245
+ } else {
1246
+ buttons.push(`
1247
+ <button class="x402-wallet-btn" disabled>
1248
+ <div class="x402-wallet-icon">${ICONS.wallet}</div>
1249
+ <span class="x402-wallet-name">No EVM wallet detected</span>
1250
+ <span class="x402-wallet-meta">${escapeHtml(networkLabel(evmAccept.network, evmAccept))}</span>
1251
+ </button>
1252
+ `);
1253
+ }
953
1254
  }
1255
+ // When nothing is installed, don't dead-end — point users to a wallet.
1256
+ const installHelp = (!phantomDetected && !evmDetected)
1257
+ ? `<div class="x402-wallet-install">No wallet detected — install
1258
+ ${solanaAccept ? `<a href="https://phantom.app/download" target="_blank" rel="noopener">Phantom</a>` : ''}${solanaAccept && evmAccept ? ' or ' : ''}${evmAccept ? `<a href="https://metamask.io/download" target="_blank" rel="noopener">MetaMask</a>` : ''}
1259
+ to continue.</div>`
1260
+ : '';
954
1261
  const fallbackBox = this.siwxFallbackNotice
955
1262
  ? `<div class="x402-siwx-fallback">${escapeHtml(this.siwxFallbackNotice)}</div>`
956
1263
  : '';
1264
+ // True for the exact scheme: the signed authorization can only move the
1265
+ // stated amount to the stated recipient — a strong, honest trust signal.
1266
+ const trustInfo = tokenInfo(this.accept);
1267
+ const trustAmt = formatAmount(this.accept.amount, trustInfo.decimals);
1268
+ const trustLine = `<div class="x402-trust">${ICONS.shield}<span>You authorize exactly ${escapeHtml(trustAmt)} ${escapeHtml(trustInfo.symbol)} — nothing more can be charged.</span></div>`;
957
1269
  this.bodyEl.innerHTML = `
958
1270
  ${this.renderSteps('connect', { discover: 'done' })}
959
1271
  ${fallbackBox}
960
1272
  ${tokenChooser}
961
1273
  <div class="x402-wallet-buttons">${buttons.join('')}</div>
1274
+ ${installHelp}
1275
+ ${(phantomDetected || evmDetected) ? trustLine : ''}
962
1276
  `;
1277
+ requestAnimationFrame(() => this.focusFirst());
963
1278
  const onClick = (e) => {
964
- const btn = e.target.closest('[data-wallet]');
1279
+ const btn = e.target.closest('[data-wallet-kind]');
965
1280
  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);
1281
+ const kind = btn.dataset.walletKind;
1282
+ const idx = Number(btn.dataset.walletIdx);
1283
+ if (kind === 'solana') this.runSolana(this.solanaAccept, this._solWallets[idx]?.provider);
1284
+ else if (kind === 'evm') this.runEvm(evmAccept, this._evmWallets[idx]?.provider);
969
1285
  };
970
- this.bodyEl.querySelectorAll('[data-wallet]').forEach((b) => b.addEventListener('click', onClick));
1286
+ this.bodyEl.querySelectorAll('[data-wallet-kind]').forEach((b) => b.addEventListener('click', onClick));
971
1287
 
972
1288
  // Token chooser — switch the active Solana token in place: update the
973
1289
  // headline price and the Phantom button's token label without a re-render
@@ -1041,10 +1357,15 @@ class CheckoutModal {
1041
1357
  [stepId]: 'error',
1042
1358
  [`${stepId}_meta`]: 'failed',
1043
1359
  })}
1044
- <div class="x402-error-box"><strong>${escapeHtml(stepId)}:</strong> ${escapeHtml(message)}</div>
1360
+ <div class="x402-error-box">
1361
+ <div class="x402-error-title">${escapeHtml(STEP_LABELS[stepId] || 'Something went wrong')}</div>
1362
+ <div class="x402-error-detail">${escapeHtml(message)}</div>
1363
+ </div>
1045
1364
  <button class="x402-pay-btn" data-retry>Try again</button>
1046
1365
  `;
1047
- this.bodyEl.querySelector('[data-retry]').addEventListener('click', () => this.start());
1366
+ const retry = this.bodyEl.querySelector('[data-retry]');
1367
+ retry.addEventListener('click', () => this.start());
1368
+ requestAnimationFrame(() => retry.focus());
1048
1369
  }
1049
1370
 
1050
1371
  renderDone({ result, payment, siwx }) {
@@ -1054,7 +1375,8 @@ class CheckoutModal {
1054
1375
  const addrShort = siwx.address ? `${siwx.address.slice(0, 8)}…${siwx.address.slice(-6)}` : '—';
1055
1376
  receiptHtml = `
1056
1377
  <div class="x402-receipt">
1057
- <div class="x402-receipt-title">Welcome back!</div>
1378
+ <div class="x402-receipt-check">${ICONS.check}</div>
1379
+ <div class="x402-receipt-title">Welcome back</div>
1058
1380
  <div class="x402-receipt-row">
1059
1381
  <span class="x402-k">network</span>
1060
1382
  <span class="x402-v">${escapeHtml(networkLabel(siwx.network) || siwx.network || '—')}</span>
@@ -1074,7 +1396,8 @@ class CheckoutModal {
1074
1396
  const txShort = payment?.transaction ? `${payment.transaction.slice(0, 8)}…${payment.transaction.slice(-6)}` : '—';
1075
1397
  receiptHtml = `
1076
1398
  <div class="x402-receipt">
1077
- <div class="x402-receipt-title">Payment confirmed!</div>
1399
+ <div class="x402-receipt-check">${ICONS.check}</div>
1400
+ <div class="x402-receipt-title">Payment confirmed</div>
1078
1401
  <div class="x402-receipt-row">
1079
1402
  <span class="x402-k">network</span>
1080
1403
  <span class="x402-v">${escapeHtml(networkLabel(payment?.network) || '—')}</span>
@@ -1093,21 +1416,27 @@ class CheckoutModal {
1093
1416
  </div>
1094
1417
  `;
1095
1418
  }
1419
+ // A string result is human-readable content (a summary, an answer) — show it
1420
+ // as prose, not in a monospace JSON box. Objects stay as formatted JSON.
1421
+ const isProse = typeof result === 'string';
1096
1422
  this.bodyEl.innerHTML = `
1097
1423
  ${receiptHtml}
1098
- <div class="x402-result">${escapeHtml(resultStr).slice(0, 4000)}</div>
1424
+ <div class="x402-result${isProse ? ' x402-prose' : ''}">${escapeHtml(resultStr).slice(0, 4000)}</div>
1099
1425
  <button class="x402-pay-btn" data-done>Done</button>
1100
1426
  `;
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
- });
1427
+ const doneBtn = this.bodyEl.querySelector('[data-done]');
1428
+ doneBtn.addEventListener('click', () => this.close('done'));
1429
+ requestAnimationFrame(() => doneBtn.focus());
1107
1430
  }
1108
1431
 
1109
1432
  async start() {
1110
1433
  this.bodyEl.innerHTML = this.renderSteps('discover');
1434
+ // Shimmer skeletons for the price/network while we fetch the challenge.
1435
+ this.priceEl.innerHTML = '<span class="x402-skeleton" style="display:inline-block;width:92px;height:26px;vertical-align:middle"></span>';
1436
+ this.networkEl.innerHTML = '<span class="x402-skeleton" style="display:inline-block;width:58px;height:11px;border-radius:99px"></span>';
1437
+ // Pre-warm the Solana web3 import now (while the user reads the modal and we
1438
+ // fetch the challenge) so a slow CDN doesn't stall at sign time. Best-effort.
1439
+ loadSolanaWeb3().catch(() => {});
1111
1440
  try {
1112
1441
  const challenge = await discoverChallenge(this.opts);
1113
1442
  this.challenge = challenge;
@@ -1130,13 +1459,14 @@ class CheckoutModal {
1130
1459
  }
1131
1460
  }
1132
1461
 
1133
- async runSolana(accept) {
1462
+ async runSolana(accept, providerArg) {
1134
1463
  this.accept = accept;
1135
1464
  this.setPrice(accept);
1136
- this.renderProgress('connect', { text: 'Opening Phantom…' });
1465
+ const provider = providerArg || window.phantom?.solana || window.solana;
1466
+ const walletName = provider?.isPhantom ? 'Phantom' : (this._solWallets?.find((w) => w.provider === provider)?.name || 'wallet');
1467
+ this.renderProgress('connect', { text: `Opening ${walletName}…` });
1137
1468
  try {
1138
- const provider = window.phantom?.solana || window.solana;
1139
- if (!provider) throw new Error('Phantom wallet not detected');
1469
+ if (!provider) throw new Error('No Solana wallet detected');
1140
1470
  const conn = await provider.connect();
1141
1471
  const payerAddress = (conn?.publicKey || provider.publicKey)?.toString();
1142
1472
  if (!payerAddress) throw new Error('Phantom did not return a public key');
@@ -1185,12 +1515,12 @@ class CheckoutModal {
1185
1515
  }
1186
1516
  }
1187
1517
 
1188
- async runEvm(accept) {
1518
+ async runEvm(accept, providerArg) {
1189
1519
  this.accept = accept;
1190
1520
  this.setPrice(accept);
1191
1521
  this.renderProgress('connect', { text: 'Opening browser wallet…' });
1192
1522
  try {
1193
- const eth = window.ethereum;
1523
+ const eth = providerArg || window.ethereum;
1194
1524
  if (!eth) throw new Error('No EVM wallet detected');
1195
1525
  const accounts = await eth.request({ method: 'eth_requestAccounts' });
1196
1526
  const payerAddress = accounts?.[0];
@@ -1303,6 +1633,9 @@ class CheckoutModal {
1303
1633
  this.renderProgress('verify', {
1304
1634
  text: attempt ? 'Retrying after upstream throttle…' : 'Calling merchant endpoint…',
1305
1635
  });
1636
+ // One idempotency key for this payment's whole lifetime (all retries and any
1637
+ // "Try again") so a re-sent X-PAYMENT settles at most once — no double charge.
1638
+ if (!this.idempotencyKey) this.idempotencyKey = `x402-${randomHex(16)}`;
1306
1639
  try {
1307
1640
  const res = await fetch(this.opts.endpoint, {
1308
1641
  method: this.opts.method || 'GET',
@@ -1310,6 +1643,8 @@ class CheckoutModal {
1310
1643
  ...(this.opts.headers || {}),
1311
1644
  ...(this.opts.body && !this.opts.headers?.['content-type'] ? { 'content-type': 'application/json' } : {}),
1312
1645
  'X-PAYMENT': xPayment,
1646
+ 'Idempotency-Key': this.idempotencyKey,
1647
+ 'X-Idempotency-Key': this.idempotencyKey,
1313
1648
  },
1314
1649
  body: this.opts.body ? (typeof this.opts.body === 'string' ? this.opts.body : JSON.stringify(this.opts.body)) : undefined,
1315
1650
  });
@@ -1587,12 +1922,35 @@ function randomHex(bytes) {
1587
1922
  return Array.from(arr).map((b) => b.toString(16).padStart(2, '0')).join('');
1588
1923
  }
1589
1924
 
1925
+ // Try the configured primary URL, then each independent CDN fallback, racing
1926
+ // every attempt against a timeout so a hung CDN doesn't stall the payment — it
1927
+ // just moves on to the next. Returns the first module that imports; throws only
1928
+ // if every candidate fails.
1929
+ async function importWithFallback(primary, fallbacks, label) {
1930
+ const seen = new Set();
1931
+ const candidates = [primary, ...fallbacks].filter((u) => u && !seen.has(u) && seen.add(u));
1932
+ let lastErr;
1933
+ for (const url of candidates) {
1934
+ try {
1935
+ return await Promise.race([
1936
+ import(/* @vite-ignore */ url),
1937
+ new Promise((_, reject) =>
1938
+ setTimeout(() => reject(new Error(`timed out loading ${label} from ${url}`)), ESM_IMPORT_TIMEOUT_MS),
1939
+ ),
1940
+ ]);
1941
+ } catch (err) {
1942
+ lastErr = err;
1943
+ }
1944
+ }
1945
+ throw lastErr || new Error(`could not load ${label} from any CDN`);
1946
+ }
1947
+
1590
1948
  let _solanaWeb3 = null;
1591
1949
  async function loadSolanaWeb3() {
1592
1950
  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);
1951
+ // Loaded on demand (only when a Solana payment is attempted) and cached. The
1952
+ // fallback chain means one CDN being down doesn't break Solana checkout.
1953
+ _solanaWeb3 = await importWithFallback(CONFIG.esm.solanaWeb3, ESM_FALLBACKS.solanaWeb3, '@solana/web3.js');
1596
1954
  return _solanaWeb3;
1597
1955
  }
1598
1956