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