@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.
@@ -0,0 +1,279 @@
1
+ // Runnable demo: a free crypto-price API paywalled with x402 on Solana, served
2
+ // to the @three-ws/x402-payment-modal you ship on npm.
3
+ //
4
+ // What it proves end-to-end:
5
+ // 1. The published modal (loaded from unpkg in public/index.html) drives the
6
+ // whole 402 → Phantom connect → sign → settle flow with zero wallet code.
7
+ // 2. A real free API (CoinGecko, no key) is gated behind the payment and only
8
+ // returned once the USDC payment verifies + settles on Solana mainnet.
9
+ // 3. The payout wallet is set AT RUNTIME from the page — never from a .env or
10
+ // a source constant. Start the server, paste your address, then pay.
11
+ //
12
+ // The only "config" that isn't runtime is the facilitator's fee-payer sponsor
13
+ // (a PUBLIC Solana account, not a secret) and the CoinGecko/facilitator URLs.
14
+ //
15
+ // Run (from inside this package's repo — uses the repo's express + @solana deps):
16
+ // node examples/solana-crypto-paywall/server.mjs
17
+ // open http://localhost:4021
18
+ //
19
+ // As a standalone project (outside this repo): `npm install`, then change the
20
+ // two relative imports below to '@three-ws/x402-payment-modal/server/express'
21
+ // and '@three-ws/x402-payment-modal/server'.
22
+
23
+ import express from 'express';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { dirname, join } from 'node:path';
26
+ import { PublicKey } from '@solana/web3.js';
27
+
28
+ // In-repo: import the package's checkout helpers from source. Standalone: swap
29
+ // these for the published package subpaths (see header).
30
+ import { x402CheckoutRouter } from '../../server/express.js';
31
+ import { solanaAccept, NETWORK_SOLANA_MAINNET } from '../../server/checkout.js';
32
+
33
+ import {
34
+ decodePaymentHeader,
35
+ verifyPayment,
36
+ settlePayment,
37
+ encodePaymentResponse,
38
+ } from './facilitator.mjs';
39
+
40
+ const __dirname = dirname(fileURLToPath(import.meta.url));
41
+ const PORT = Number(process.env.PORT) || 4021;
42
+
43
+ // The facilitator co-signs Solana settlements as the fee payer. The default
44
+ // below is a PUBLIC sponsor pubkey (it pays the SOL network fee so the buyer
45
+ // needs only USDC), NOT a secret and NOT the payout wallet. Override it with
46
+ // X402_FEE_PAYER_SOLANA to point at the sponsor of whichever facilitator you use.
47
+ const FEE_PAYER = process.env.X402_FEE_PAYER_SOLANA || '2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4';
48
+
49
+ // A public Solana RPC works for a quick try (rate-limited). Pass a dedicated RPC
50
+ // for anything real. Not a payout/secret — purely network plumbing.
51
+ const SOLANA_RPC_URL = process.env.SOLANA_RPC_URL || 'https://api.mainnet-beta.solana.com';
52
+
53
+ // Price per call. USDC has 6 decimals, so $0.01 = 10000 atomic units.
54
+ const PRICE_USD = '0.01';
55
+ const PRICE_ATOMIC = '10000';
56
+
57
+ // ── Runtime merchant config (the whole point of this demo) ───────────────────
58
+ // The payout wallet lives in memory and is set from the page after boot. There
59
+ // is deliberately no env var and no default — until you POST one, the paid
60
+ // endpoint refuses to issue a challenge.
61
+ const merchant = { payTo: null, updatedAt: null };
62
+
63
+ function isValidSolanaAddress(addr) {
64
+ if (typeof addr !== 'string' || !/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(addr)) return false;
65
+ try {
66
+ // eslint-disable-next-line no-new
67
+ new PublicKey(addr);
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ // CoinGecko ids the picker offers, plus a few ticker aliases so callers can pass
75
+ // "btc" instead of "bitcoin". Display metadata keeps the UI honest without a
76
+ // second API round-trip.
77
+ const COIN_META = {
78
+ bitcoin: { symbol: 'BTC', name: 'Bitcoin' },
79
+ ethereum: { symbol: 'ETH', name: 'Ethereum' },
80
+ solana: { symbol: 'SOL', name: 'Solana' },
81
+ dogecoin: { symbol: 'DOGE', name: 'Dogecoin' },
82
+ ripple: { symbol: 'XRP', name: 'XRP' },
83
+ cardano: { symbol: 'ADA', name: 'Cardano' },
84
+ 'usd-coin': { symbol: 'USDC', name: 'USD Coin' },
85
+ 'avalanche-2': { symbol: 'AVAX', name: 'Avalanche' },
86
+ };
87
+ const TICKER_ALIASES = {
88
+ btc: 'bitcoin', eth: 'ethereum', sol: 'solana', doge: 'dogecoin',
89
+ xrp: 'ripple', ada: 'cardano', usdc: 'usd-coin', avax: 'avalanche-2',
90
+ };
91
+
92
+ function normalizeIds(raw) {
93
+ const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(',') : [];
94
+ const seen = new Set();
95
+ const out = [];
96
+ for (const item of list) {
97
+ const id = String(item || '').toLowerCase().trim();
98
+ const resolved = TICKER_ALIASES[id] || id;
99
+ if (!/^[a-z0-9-]{1,40}$/.test(resolved) || seen.has(resolved)) continue;
100
+ seen.add(resolved);
101
+ out.push(resolved);
102
+ if (out.length >= 12) break;
103
+ }
104
+ return out;
105
+ }
106
+
107
+ async function fetchCoinGeckoPrices(ids) {
108
+ const url =
109
+ 'https://api.coingecko.com/api/v3/simple/price' +
110
+ `?ids=${encodeURIComponent(ids.join(','))}` +
111
+ '&vs_currencies=usd&include_24hr_change=true&include_market_cap=true&include_last_updated_at=true';
112
+ const r = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(8000) });
113
+ if (!r.ok) {
114
+ const err = new Error(`CoinGecko responded ${r.status}`);
115
+ err.status = 503;
116
+ err.code = 'data_unavailable';
117
+ throw err;
118
+ }
119
+ const data = await r.json();
120
+ const coins = ids
121
+ .filter((id) => data[id])
122
+ .map((id) => {
123
+ const meta = COIN_META[id] || { symbol: id.toUpperCase().slice(0, 6), name: id };
124
+ const d = data[id];
125
+ return {
126
+ id,
127
+ symbol: meta.symbol,
128
+ name: meta.name,
129
+ price_usd: d.usd ?? null,
130
+ change_24h: d.usd_24h_change ?? null,
131
+ market_cap: d.usd_market_cap ?? null,
132
+ updated_at: d.last_updated_at ? new Date(d.last_updated_at * 1000).toISOString() : null,
133
+ };
134
+ });
135
+ if (!coins.length) {
136
+ const err = new Error('no live prices returned for the requested coins');
137
+ err.status = 503;
138
+ err.code = 'data_unavailable';
139
+ throw err;
140
+ }
141
+ return coins;
142
+ }
143
+
144
+ // ── App ──────────────────────────────────────────────────────────────────────
145
+ const app = express();
146
+ app.use(express.json());
147
+ app.disable('x-powered-by');
148
+
149
+ // The page is same-origin, but keep CORS permissive so the modal works if the
150
+ // demo is embedded from another origin during testing.
151
+ function cors(res) {
152
+ res.setHeader('Access-Control-Allow-Origin', '*');
153
+ res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
154
+ res.setHeader('Access-Control-Allow-Headers', 'content-type, x-payment, idempotency-key, x-idempotency-key');
155
+ res.setHeader('Access-Control-Expose-Headers', 'x-payment-response');
156
+ }
157
+ function jsonError(res, status, code, description) {
158
+ cors(res);
159
+ return res.status(status).json({ error: code, error_description: description });
160
+ }
161
+
162
+ // Solana checkout endpoints the modal POSTs to (prepare + encode). Phantom only
163
+ // signs serialized transactions, so the package builds the SPL transfer here.
164
+ app.options('/api/x402-checkout', (_req, res) => { cors(res); res.status(204).end(); });
165
+ app.all('/api/x402-checkout', x402CheckoutRouter({ rpcUrl: SOLANA_RPC_URL }));
166
+
167
+ // ── Runtime payout config ─────────────────────────────────────────────────────
168
+ app.options('/api/config', (_req, res) => { cors(res); res.status(204).end(); });
169
+
170
+ app.get('/api/config', (_req, res) => {
171
+ cors(res);
172
+ res.json({
173
+ configured: Boolean(merchant.payTo),
174
+ payTo: merchant.payTo,
175
+ updatedAt: merchant.updatedAt,
176
+ feePayer: FEE_PAYER,
177
+ network: 'solana',
178
+ asset: 'USDC',
179
+ priceUsd: PRICE_USD,
180
+ coins: Object.entries(COIN_META).map(([id, m]) => ({ id, ...m })),
181
+ });
182
+ });
183
+
184
+ app.post('/api/config', (req, res) => {
185
+ const payTo = (req.body?.payTo || '').trim();
186
+ if (!isValidSolanaAddress(payTo)) {
187
+ return jsonError(res, 400, 'invalid_address', 'payTo must be a valid base58 Solana address');
188
+ }
189
+ merchant.payTo = payTo;
190
+ merchant.updatedAt = new Date().toISOString();
191
+ cors(res);
192
+ res.json({ configured: true, payTo: merchant.payTo, updatedAt: merchant.updatedAt });
193
+ });
194
+
195
+ // ── The paid endpoint ─────────────────────────────────────────────────────────
196
+ // GET-less, POST-only so the resource URL (used for facilitator matching) stays
197
+ // query-free. Body: { ids: ["bitcoin", "ethereum", ...] }.
198
+ app.options('/api/paid/crypto', (_req, res) => { cors(res); res.status(204).end(); });
199
+
200
+ app.post('/api/paid/crypto', async (req, res) => {
201
+ if (!merchant.payTo) {
202
+ return jsonError(res, 503, 'payout_not_configured', 'Set a payout address first (POST /api/config { payTo }).');
203
+ }
204
+
205
+ const resourceUrl = `${req.protocol}://${req.get('host')}${req.path}`;
206
+ const accept = solanaAccept({
207
+ token: 'usdc',
208
+ uiAmount: Number(PRICE_USD),
209
+ payTo: merchant.payTo,
210
+ feePayer: FEE_PAYER,
211
+ maxTimeoutSeconds: 60,
212
+ network: NETWORK_SOLANA_MAINNET,
213
+ });
214
+ // solanaAccept omits `resource`; the facilitator matches the signed payload
215
+ // against it, so advertise the same absolute URL the modal will sign over.
216
+ accept.resource = resourceUrl;
217
+
218
+ const paymentHeader = req.headers['x-payment'];
219
+
220
+ // No payment yet → answer with the x402 v2 challenge so the modal opens.
221
+ if (!paymentHeader) {
222
+ cors(res);
223
+ return res.status(402).json({
224
+ x402Version: 2,
225
+ error: 'Payment required',
226
+ resource: {
227
+ url: resourceUrl,
228
+ description: 'Live crypto prices (CoinGecko) — pay $0.01 USDC on Solana.',
229
+ mimeType: 'application/json',
230
+ },
231
+ accepts: [accept],
232
+ });
233
+ }
234
+
235
+ try {
236
+ // 1. Verify the signed payment matches what we offered (facilitator-checked).
237
+ const paymentPayload = decodePaymentHeader(paymentHeader);
238
+ await verifyPayment({ paymentPayload, requirement: accept });
239
+
240
+ // 2. Do the paid work BEFORE settling. If the live feed is down we throw a
241
+ // 503 here and never settle — the buyer keeps their funds and can retry.
242
+ const ids = normalizeIds(req.body?.ids);
243
+ if (!ids.length) {
244
+ return jsonError(res, 400, 'invalid_request', 'provide a non-empty `ids` array of CoinGecko ids');
245
+ }
246
+ const coins = await fetchCoinGeckoPrices(ids);
247
+
248
+ // 3. Settle on-chain. Only now does USDC actually move.
249
+ const settled = await settlePayment({ paymentPayload, requirement: accept });
250
+
251
+ cors(res);
252
+ res.setHeader('x-payment-response', encodePaymentResponse(settled));
253
+ res.setHeader('cache-control', 'no-store');
254
+ res.json({
255
+ asof: new Date().toISOString(),
256
+ base: 'usd',
257
+ source: 'coingecko',
258
+ coins,
259
+ paidWith: { network: 'solana', asset: 'USDC', amount: PRICE_USD, payTo: merchant.payTo },
260
+ });
261
+ } catch (err) {
262
+ return jsonError(res, err.status || 502, err.code || 'payment_failed', err.message || 'payment failed');
263
+ }
264
+ });
265
+
266
+ // Static demo page.
267
+ app.use(express.static(join(__dirname, 'public')));
268
+
269
+ app.listen(PORT, () => {
270
+ console.log('');
271
+ console.log(' x402 Solana crypto paywall — demo');
272
+ console.log(` ▸ open http://localhost:${PORT}`);
273
+ console.log(` ▸ paid endpoint POST /api/paid/crypto`);
274
+ console.log(` ▸ checkout router /api/x402-checkout (prepare + encode)`);
275
+ console.log(` ▸ payout wallet set it at runtime in the page (POST /api/config)`);
276
+ console.log(` ▸ fee payer ${FEE_PAYER} (public facilitator sponsor)`);
277
+ console.log(` ▸ Solana RPC ${SOLANA_RPC_URL}`);
278
+ console.log('');
279
+ });
package/package.json CHANGED
@@ -1,113 +1,128 @@
1
1
  {
2
- "name": "@three-ws/x402-payment-modal",
3
- "version": "1.1.0",
4
- "description": "Drop-in x402 payment modal for any paid HTTP endpoint. One ES module, zero runtime dependencies — handles wallet connect (Phantom/Solana + EVM via EIP-3009), the 402 → sign → settle flow, SIWX re-entry, client-side spending caps, and a receipt. Vanilla JS, no bundler required.",
5
- "type": "module",
6
- "main": "./src/index.js",
7
- "module": "./src/index.js",
8
- "types": "./types/index.d.ts",
9
- "browser": "./dist/x402.min.js",
10
- "unpkg": "./dist/x402.min.js",
11
- "jsdelivr": "./dist/x402.min.js",
12
- "exports": {
13
- ".": {
14
- "types": "./types/index.d.ts",
15
- "import": "./src/index.js",
16
- "default": "./src/index.js"
17
- },
18
- "./min": {
19
- "types": "./types/index.d.ts",
20
- "default": "./dist/x402.min.js"
21
- },
22
- "./server": {
23
- "types": "./types/server.d.ts",
24
- "import": "./server/checkout.js",
25
- "default": "./server/checkout.js"
26
- },
27
- "./server/express": {
28
- "import": "./server/express.js",
29
- "default": "./server/express.js"
30
- },
31
- "./server/vercel": {
32
- "import": "./server/vercel.js",
33
- "default": "./server/vercel.js"
34
- },
35
- "./package.json": "./package.json"
36
- },
37
- "files": [
38
- "src",
39
- "dist",
40
- "types",
41
- "server",
42
- "examples",
43
- "docs",
44
- "README.md",
45
- "TUTORIAL.md",
46
- "CHANGELOG.md",
47
- "LICENSE"
48
- ],
49
- "scripts": {
50
- "build": "node build.mjs",
51
- "test": "node --test \"test/*.test.js\"",
52
- "prepublishOnly": "npm run build"
53
- },
54
- "keywords": [
55
- "x402",
56
- "payments",
57
- "micropayments",
58
- "paywall",
59
- "pay-per-call",
60
- "usdc",
61
- "stablecoin",
62
- "solana",
63
- "phantom",
64
- "evm",
65
- "base",
66
- "metamask",
67
- "eip-3009",
68
- "siwx",
69
- "wallet",
70
- "checkout",
71
- "modal",
72
- "agentic",
73
- "web3",
74
- "402",
75
- "three-ws"
76
- ],
77
- "author": "three.ws <support@three.ws> (https://three.ws)",
78
- "license": "Apache-2.0",
79
- "homepage": "https://three.ws",
80
- "repository": {
81
- "type": "git",
82
- "url": "git+https://github.com/nirholas/three.ws.git",
83
- "directory": "x402-payment-modal"
84
- },
85
- "bugs": {
86
- "url": "https://github.com/nirholas/three.ws/issues"
87
- },
88
- "peerDependencies": {
89
- "@solana/web3.js": "^1.95.0",
90
- "@solana/spl-token": "^0.4.0"
91
- },
92
- "peerDependenciesMeta": {
93
- "@solana/web3.js": {
94
- "optional": true
95
- },
96
- "@solana/spl-token": {
97
- "optional": true
98
- }
99
- },
100
- "devDependencies": {
101
- "esbuild": "^0.21.0"
102
- },
103
- "engines": {
104
- "node": ">=18"
105
- },
106
- "publishConfig": {
107
- "access": "public"
108
- },
109
- "sideEffects": [
110
- "./src/index.js",
111
- "./dist/x402.min.js"
112
- ]
2
+ "name": "@three-ws/x402-payment-modal",
3
+ "version": "1.2.0",
4
+ "description": "Drop-in x402 payment modal for any paid HTTP endpoint. One ES module, zero runtime dependencies — handles wallet connect (Phantom/Solana + EVM via EIP-3009), the 402 → sign → settle flow, SIWX re-entry, client-side spending caps, and a receipt. Vanilla JS, no bundler required.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "module": "./src/index.js",
8
+ "types": "./types/index.d.ts",
9
+ "browser": "./dist/x402.min.js",
10
+ "unpkg": "./dist/x402.min.js",
11
+ "jsdelivr": "./dist/x402.min.js",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./types/index.d.ts",
15
+ "import": "./src/index.js",
16
+ "default": "./src/index.js"
17
+ },
18
+ "./min": {
19
+ "types": "./types/index.d.ts",
20
+ "default": "./dist/x402.min.js"
21
+ },
22
+ "./server": {
23
+ "types": "./types/server.d.ts",
24
+ "import": "./server/checkout.js",
25
+ "default": "./server/checkout.js"
26
+ },
27
+ "./server/express": {
28
+ "import": "./server/express.js",
29
+ "default": "./server/express.js"
30
+ },
31
+ "./server/vercel": {
32
+ "import": "./server/vercel.js",
33
+ "default": "./server/vercel.js"
34
+ },
35
+ "./react": {
36
+ "types": "./react/index.d.ts",
37
+ "import": "./react/index.js",
38
+ "default": "./react/index.js"
39
+ },
40
+ "./package.json": "./package.json"
41
+ },
42
+ "files": [
43
+ "src",
44
+ "dist",
45
+ "types",
46
+ "server",
47
+ "react",
48
+ "examples",
49
+ "docs",
50
+ "README.md",
51
+ "TUTORIAL.md",
52
+ "CHANGELOG.md",
53
+ "CONTRIBUTING.md",
54
+ "LICENSE"
55
+ ],
56
+ "scripts": {
57
+ "build": "node build.mjs",
58
+ "test": "node --test \"test/*.test.js\"",
59
+ "prepublishOnly": "npm run build"
60
+ },
61
+ "keywords": [
62
+ "x402",
63
+ "payments",
64
+ "micropayments",
65
+ "paywall",
66
+ "pay-per-call",
67
+ "usdc",
68
+ "stablecoin",
69
+ "solana",
70
+ "phantom",
71
+ "evm",
72
+ "base",
73
+ "metamask",
74
+ "eip-3009",
75
+ "siwx",
76
+ "wallet",
77
+ "checkout",
78
+ "modal",
79
+ "agentic",
80
+ "web3",
81
+ "402"
82
+ ],
83
+ "author": "nirholas",
84
+ "license": "UNLICENSED",
85
+ "homepage": "https://github.com/nirholas/x402-payment-modal#readme",
86
+ "repository": {
87
+ "type": "git",
88
+ "url": "git+https://github.com/nirholas/x402-payment-modal.git"
89
+ },
90
+ "bugs": {
91
+ "url": "https://github.com/nirholas/x402-payment-modal/issues"
92
+ },
93
+ "peerDependencies": {
94
+ "@solana/web3.js": "^1.95.0",
95
+ "@solana/spl-token": "^0.4.0",
96
+ "express": "^4.18.0 || ^5.0.0",
97
+ "react": ">=17"
98
+ },
99
+ "peerDependenciesMeta": {
100
+ "@solana/web3.js": {
101
+ "optional": true
102
+ },
103
+ "@solana/spl-token": {
104
+ "optional": true
105
+ },
106
+ "express": {
107
+ "optional": true
108
+ },
109
+ "react": {
110
+ "optional": true
111
+ }
112
+ },
113
+ "devDependencies": {
114
+ "@solana/spl-token": "^0.4.14",
115
+ "@solana/web3.js": "^1.98.4",
116
+ "esbuild": "^0.21.0"
117
+ },
118
+ "engines": {
119
+ "node": ">=18"
120
+ },
121
+ "publishConfig": {
122
+ "access": "public"
123
+ },
124
+ "sideEffects": [
125
+ "./src/index.js",
126
+ "./dist/x402.min.js"
127
+ ]
113
128
  }
@@ -0,0 +1,39 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode } from 'react';
2
+ import type { PayOptions, PayResult, X402Config } from '@three-ws/x402-payment-modal';
3
+
4
+ export type X402Status = 'idle' | 'paying' | 'done' | 'error';
5
+
6
+ export interface UseX402Return {
7
+ /** Open the modal and run the 402 → sign → settle flow. Resolves to the result, or `undefined` if the user cancelled. */
8
+ pay: (opts?: Partial<PayOptions>) => Promise<PayResult | undefined>;
9
+ status: X402Status;
10
+ result: PayResult | null;
11
+ error: Error | null;
12
+ reset: () => void;
13
+ isPaying: boolean;
14
+ }
15
+
16
+ /** Payment hook with a small state machine. `defaults` are merged under every `pay()` call. */
17
+ export function useX402(defaults?: Partial<PayOptions>): UseX402Return;
18
+
19
+ /** Configure the modal before the first payment (resolves once applied). */
20
+ export function configure(opts?: X402Config): Promise<Required<X402Config>>;
21
+
22
+ export interface X402ButtonProps extends Omit<ComponentPropsWithoutRef<'button'>, 'onError' | 'children'> {
23
+ endpoint: string;
24
+ method?: string;
25
+ body?: unknown;
26
+ merchant?: string;
27
+ action?: string;
28
+ caps?: PayOptions['caps'];
29
+ headers?: Record<string, string>;
30
+ autoConnect?: boolean;
31
+ label?: string;
32
+ onResult?: (result: PayResult) => void;
33
+ onError?: (error: Error) => void;
34
+ children?: ReactNode;
35
+ }
36
+
37
+ /** Drop-in pay button. */
38
+ export function X402Button(props: X402ButtonProps): JSX.Element;
39
+ export default X402Button;
package/react/index.js ADDED
@@ -0,0 +1,112 @@
1
+ // @three-ws/x402-payment-modal/react — first-class React bindings.
2
+ //
3
+ // import { X402Button, useX402 } from '@three-ws/x402-payment-modal/react';
4
+ //
5
+ // The core package is a browser-only ES module (it renders a modal and talks to
6
+ // a wallet), so it is dynamically imported on first use — nothing from it runs
7
+ // during render or on the server, keeping this SSR-safe. `react` is an optional
8
+ // peer dependency; this file uses `createElement` so it needs no JSX build step.
9
+
10
+ import { createElement, useCallback, useRef, useState } from 'react';
11
+
12
+ let _modPromise;
13
+ function loadCore() {
14
+ if (!_modPromise) _modPromise = import('@three-ws/x402-payment-modal');
15
+ return _modPromise;
16
+ }
17
+
18
+ /**
19
+ * Configure the modal (checkout origin, theme, branding, …) before the first
20
+ * payment. Resolves once the core module has loaded and applied the config.
21
+ */
22
+ export function configure(opts) {
23
+ return loadCore().then((m) => m.configure(opts));
24
+ }
25
+
26
+ /**
27
+ * Headless-ish payment hook with a small state machine.
28
+ * @param {object} [defaults] PayOptions merged under every pay() call.
29
+ * @returns {{ pay: Function, status: string, result: any, error: Error|null, reset: Function, isPaying: boolean }}
30
+ */
31
+ export function useX402(defaults = {}) {
32
+ const [status, setStatus] = useState('idle'); // idle | paying | done | error
33
+ const [result, setResult] = useState(null);
34
+ const [error, setError] = useState(null);
35
+ const inflight = useRef(false);
36
+ // Keep the latest defaults without making pay() identity churn every render.
37
+ const defaultsRef = useRef(defaults);
38
+ defaultsRef.current = defaults;
39
+
40
+ const pay = useCallback(async (opts = {}) => {
41
+ if (inflight.current) return undefined;
42
+ inflight.current = true;
43
+ setStatus('paying');
44
+ setError(null);
45
+ setResult(null);
46
+ try {
47
+ const m = await loadCore();
48
+ const res = await m.pay({ ...defaultsRef.current, ...opts });
49
+ setResult(res);
50
+ setStatus('done');
51
+ return res;
52
+ } catch (err) {
53
+ // User dismissed the modal — not an error; return to idle quietly.
54
+ if (err && err.code === 'cancelled') {
55
+ setStatus('idle');
56
+ return undefined;
57
+ }
58
+ setError(err);
59
+ setStatus('error');
60
+ throw err;
61
+ } finally {
62
+ inflight.current = false;
63
+ }
64
+ }, []);
65
+
66
+ const reset = useCallback(() => {
67
+ setStatus('idle');
68
+ setResult(null);
69
+ setError(null);
70
+ }, []);
71
+
72
+ return { pay, status, result, error, reset, isPaying: status === 'paying' };
73
+ }
74
+
75
+ /**
76
+ * Drop-in pay button. Passes its payment props to `pay()`; calls `onResult` on
77
+ * success and `onError` on failure (cancellation is silent).
78
+ */
79
+ export function X402Button({
80
+ endpoint,
81
+ method,
82
+ body,
83
+ merchant,
84
+ action,
85
+ caps,
86
+ headers,
87
+ autoConnect,
88
+ label = 'Pay',
89
+ onResult,
90
+ onError,
91
+ children,
92
+ ...rest
93
+ }) {
94
+ const { pay, isPaying } = useX402();
95
+
96
+ const handleClick = useCallback(async () => {
97
+ try {
98
+ const res = await pay({ endpoint, method, body, merchant, action, caps, headers, autoConnect });
99
+ if (res) onResult?.(res);
100
+ } catch (err) {
101
+ onError?.(err);
102
+ }
103
+ }, [pay, endpoint, method, body, merchant, action, caps, headers, autoConnect, onResult, onError]);
104
+
105
+ return createElement(
106
+ 'button',
107
+ { type: 'button', onClick: handleClick, disabled: isPaying, 'aria-busy': isPaying, ...rest },
108
+ isPaying ? 'Processing…' : (children ?? label),
109
+ );
110
+ }
111
+
112
+ export default X402Button;