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