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