@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/CHANGELOG.md +71 -9
- package/CONTRIBUTING.md +79 -0
- package/LICENSE +38 -180
- package/README.md +238 -63
- package/dist/index.d.ts +14 -3
- package/dist/x402.js +564 -206
- package/dist/x402.min.js +308 -178
- package/docs/EXAMPLES.md +137 -0
- package/docs/api-reference.md +32 -5
- package/docs/architecture.md +7 -1
- package/docs/react.md +163 -0
- package/docs/server-setup.md +63 -6
- package/examples/README.md +2 -1
- package/examples/react/App.jsx +95 -0
- package/examples/react/README.md +34 -31
- package/examples/server-express/server.js +16 -9
- package/examples/solana-crypto-paywall/README.md +81 -0
- package/examples/solana-crypto-paywall/facilitator.mjs +170 -0
- package/examples/solana-crypto-paywall/package.json +17 -0
- package/examples/solana-crypto-paywall/public/index.html +506 -0
- package/examples/solana-crypto-paywall/server.mjs +279 -0
- package/package.json +126 -111
- package/react/index.d.ts +39 -0
- package/react/index.js +112 -0
- package/server/checkout.js +208 -66
- package/server/express.js +7 -4
- package/server/vercel.js +2 -2
- package/src/index.js +563 -205
- package/types/index.d.ts +14 -3
- package/types/server.d.ts +2 -1
- package/examples/react/X402Button.jsx +0 -84
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,
|
|
30
|
-
// Base/EVM USDC via EIP-3009), drives the
|
|
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.
|
|
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
|
-
//
|
|
44
|
-
// `data-*` attributes on the <script> tag (read once at load
|
|
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
|
-
|
|
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.
|
|
60
|
-
builderCode: { wallet
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
|
|
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
|
-
|
|
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.
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
480
|
-
|
|
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:
|
|
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: -
|
|
600
|
+
font-family: var(--x402-font);
|
|
492
601
|
-webkit-font-smoothing: antialiased;
|
|
493
|
-
color:
|
|
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:
|
|
500
|
-
border-radius:
|
|
501
|
-
box-shadow:
|
|
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.
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
529
|
-
|
|
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:
|
|
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:
|
|
539
|
-
border-bottom: 1px solid
|
|
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:
|
|
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:
|
|
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:
|
|
548
|
-
background:
|
|
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:
|
|
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
|
|
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
|
|
569
|
-
color:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
585
|
-
to { box-shadow: 0 0 0 8px
|
|
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:
|
|
589
|
-
.x402-step-meta { font-size: 12px; color:
|
|
590
|
-
.x402-step.x402-error .x402-step-meta { color:
|
|
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:
|
|
599
|
-
font-size: 14px; font-weight: 600; color:
|
|
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:
|
|
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
|
-
|
|
612
|
-
background: #f3f4f7;
|
|
745
|
+
background: var(--x402-surface-2); overflow: hidden;
|
|
613
746
|
}
|
|
614
|
-
.x402-wallet-icon.x402-
|
|
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:
|
|
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:
|
|
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:
|
|
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 {
|
|
632
|
-
|
|
633
|
-
|
|
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:
|
|
638
|
-
border-radius:
|
|
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
|
-
|
|
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:
|
|
646
|
-
.x402-pay-btn:active:not(:disabled) { transform: translateY(
|
|
647
|
-
.x402-pay-btn:disabled {
|
|
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:
|
|
652
|
-
border: 1.5px solid
|
|
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:
|
|
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:
|
|
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:
|
|
815
|
+
font-size: 12px; color: var(--x402-warn-fg); line-height: 1.45;
|
|
667
816
|
padding: 8px 10px; border-radius: 8px;
|
|
668
|
-
background:
|
|
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:
|
|
675
|
-
font-size:
|
|
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
|
|
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:
|
|
683
|
-
background:
|
|
684
|
-
border: 1px solid
|
|
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:
|
|
688
|
-
text-
|
|
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:
|
|
696
|
-
font-family:
|
|
853
|
+
font-size: 12px; padding: 3px 0;
|
|
854
|
+
font-family: var(--x402-mono);
|
|
697
855
|
}
|
|
698
|
-
.x402-receipt-row .x402-k { color:
|
|
699
|
-
.x402-receipt-row .x402-v { color:
|
|
700
|
-
.x402-receipt-row a { color:
|
|
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:
|
|
863
|
+
background: var(--x402-surface-2); border: 1px solid var(--x402-border);
|
|
706
864
|
max-height: 240px; overflow: auto;
|
|
707
|
-
font-family:
|
|
708
|
-
font-size: 12px; line-height: 1.5; color:
|
|
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
|
|
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:
|
|
875
|
+
font-size: 11px; color: var(--x402-muted);
|
|
717
876
|
}
|
|
718
|
-
.x402-foot a { color:
|
|
719
|
-
.x402-foot a:hover { color:
|
|
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
|
|
880
|
+
.x402-foot .x402-secure svg { width: 11px; height: 11px; }
|
|
722
881
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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 (
|
|
729
|
-
.x402-overlay {
|
|
730
|
-
.x402-modal {
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
.x402-
|
|
737
|
-
.x402-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
.x402-
|
|
742
|
-
.x402-
|
|
743
|
-
.x402-
|
|
744
|
-
.x402-
|
|
745
|
-
.x402-
|
|
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-
|
|
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
|
-
|
|
803
|
-
<div class="x402-
|
|
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"
|
|
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
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
876
|
-
const
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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
|
-
|
|
937
|
-
|
|
938
|
-
<
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
<
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
|
966
|
-
|
|
967
|
-
|
|
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"
|
|
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]')
|
|
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-
|
|
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-
|
|
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]')
|
|
1101
|
-
|
|
1102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1593
|
-
//
|
|
1594
|
-
_solanaWeb3 = await
|
|
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
|
|