@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,137 @@
1
+ # Examples
2
+
3
+ Every example in [`../examples`](../examples) is runnable. They point at
4
+ placeholder endpoints (`https://api.example.com/…`) — swap in a real x402 endpoint
5
+ to take an actual payment. This page indexes them and shows the smallest snippet
6
+ for each integration style.
7
+
8
+ | Example | Shows | Run it |
9
+ | --- | --- | --- |
10
+ | [`plain-html/`](../examples/plain-html) | No build step. Declarative `data-x402-*` buttons, a programmatic `pay()` call, and `x402:result`/`x402:error` handling from one CDN `<script>`. | Open `index.html`, or `npx serve examples/plain-html`. |
11
+ | [`react/`](../examples/react) | Using the shipped `./react` wrapper (`X402Button`). | Copy `App.jsx` into your app — see [`react/README.md`](../examples/react/README.md). |
12
+ | [`server-express/`](../examples/server-express) | A full Express server mounting the Solana checkout router + a demo paid route that returns a real x402 v2 challenge. | `cd examples/server-express && npm install && npm start`, open http://localhost:3000. |
13
+ | [`solana-crypto-paywall/`](../examples/solana-crypto-paywall) | An end-to-end Solana paywall: a paid endpoint, the checkout server, and a local facilitator. | See its [`README.md`](../examples/solana-crypto-paywall/README.md). |
14
+
15
+ ## Which do I need?
16
+
17
+ - **EVM payments** sign in the browser (EIP-3009) — the **plain-html** or
18
+ **react** client is all you need, no server.
19
+ - **Solana payments** also need the checkout endpoint (**server-express** or
20
+ **solana-crypto-paywall**) to build and wrap the transfer. See
21
+ [server setup](./server-setup.md).
22
+
23
+ ---
24
+
25
+ ## 1. Plain HTML — declarative
26
+
27
+ The leanest integration: load the module and annotate a button. No JavaScript.
28
+
29
+ ```html
30
+ <script type="module" src="https://unpkg.com/@three-ws/x402-payment-modal"></script>
31
+
32
+ <button
33
+ data-x402-endpoint="https://api.example.com/paid/summarize"
34
+ data-x402-method="POST"
35
+ data-x402-body='{"url":"https://en.wikipedia.org/wiki/x402"}'
36
+ data-x402-merchant="Acme"
37
+ data-x402-action="Summarize">
38
+ Summarize for $0.01
39
+ </button>
40
+
41
+ <script>
42
+ document.addEventListener('x402:result', (e) => console.log('paid:', e.detail.result));
43
+ document.addEventListener('x402:error', (e) => console.error(e.detail.error));
44
+ </script>
45
+ ```
46
+
47
+ ## 2. Plain HTML — programmatic
48
+
49
+ ```html
50
+ <script type="module">
51
+ import { pay } from 'https://unpkg.com/@three-ws/x402-payment-modal';
52
+
53
+ document.getElementById('go').addEventListener('click', async () => {
54
+ try {
55
+ const { result, payment } = await pay({
56
+ endpoint: 'https://api.example.com/paid/translate',
57
+ method: 'POST',
58
+ body: { text: 'Hello', to: 'es' },
59
+ merchant: 'Acme Translate',
60
+ action: 'Translate',
61
+ });
62
+ console.log(result, 'tx:', payment?.transaction);
63
+ } catch (err) {
64
+ if (err.code !== 'cancelled') console.error(err);
65
+ }
66
+ });
67
+ </script>
68
+ ```
69
+
70
+ ## 3. React
71
+
72
+ ```jsx
73
+ import { X402Button } from '@three-ws/x402-payment-modal/react';
74
+
75
+ export default function Demo() {
76
+ return (
77
+ <X402Button
78
+ endpoint="/api/paid/summarize"
79
+ method="POST"
80
+ body={{ url: 'https://en.wikipedia.org/wiki/x402' }}
81
+ merchant="Acme"
82
+ action="Summarize"
83
+ label="Summarize for $0.01"
84
+ onResult={(r) => console.log('paid', r.payment)}
85
+ />
86
+ );
87
+ }
88
+ ```
89
+
90
+ See the [React reference](./react.md) for the `useX402` hook and all props.
91
+
92
+ ## 4. Express checkout server (Solana)
93
+
94
+ ```js
95
+ import express from 'express';
96
+ import { x402CheckoutRouter } from '@three-ws/x402-payment-modal/server/express';
97
+
98
+ const app = express();
99
+ app.use(express.json());
100
+ app.use('/api/x402-checkout', x402CheckoutRouter({
101
+ rpcUrls: [process.env.SOLANA_RPC_URL], // dedicated RPC for production
102
+ }));
103
+ app.listen(3000);
104
+ ```
105
+
106
+ Then point the client at it:
107
+
108
+ ```js
109
+ import { configure } from '@three-ws/x402-payment-modal';
110
+ configure({ checkoutOrigin: 'https://your-server.com' });
111
+ ```
112
+
113
+ Full server guide: [server setup](./server-setup.md).
114
+
115
+ ## 5. Building a 402 challenge with `solanaAccept`
116
+
117
+ On the merchant side, build spec-shaped accepts (USDC default, optional second
118
+ token for a picker):
119
+
120
+ ```js
121
+ import { solanaAccept } from '@three-ws/x402-payment-modal/server';
122
+
123
+ const common = { payTo, feePayer, maxTimeoutSeconds: 60 };
124
+ const accepts = [
125
+ solanaAccept({ token: 'usdc', uiAmount: 0.01, ...common }), // $0.01 USDC
126
+ // Optional: offer a second SPL token → the modal shows a picker.
127
+ // solanaAccept({ token: 'three', uiAmount: 1000, ...common }),
128
+ ];
129
+
130
+ res.status(402).json({ x402Version: 2, accepts });
131
+ ```
132
+
133
+ ## See also
134
+
135
+ - [Tutorial](../TUTORIAL.md) — build a paid endpoint end-to-end.
136
+ - [API reference](./api-reference.md) · [Server setup](./server-setup.md) ·
137
+ [React reference](./react.md) · [Architecture](./architecture.md)
@@ -3,7 +3,8 @@
3
3
  The browser entry point. Import it as an ES module, or drop the bundle on a page
4
4
  and use `window.X402`. For how these pieces fit together see
5
5
  [architecture](./architecture.md); for the Solana backend see
6
- [server setup](./server-setup.md).
6
+ [server setup](./server-setup.md); for the React bindings see the
7
+ [React reference](./react.md).
7
8
 
8
9
  ```js
9
10
  import { pay, configure, init, version } from '@three-ws/x402-payment-modal';
@@ -92,8 +93,10 @@ import { configure } from '@three-ws/x402-payment-modal';
92
93
  configure({
93
94
  checkoutOrigin: 'https://pay.example.com',
94
95
  checkoutPath: '/api/x402-checkout',
95
- brand: { name: 'Example', url: 'https://example.com' },
96
+ brand: { name: 'Example', url: 'https://example.com', logo: '/logo.svg' },
96
97
  footerNote: 'Secured by x402',
98
+ theme: 'auto', // 'auto' | 'light' | 'dark'
99
+ cssVars: { '--x402-accent': '#ff5c00' }, // runtime brand-match
97
100
  builderCode: { wallet: 'examplewallet', service: 'example_api' },
98
101
  esm: {
99
102
  solanaWeb3: 'https://esm.sh/@solana/web3.js@1.95.0',
@@ -106,11 +109,16 @@ configure({
106
109
  |------------------|---------------------------------------|-----------------------------------------------------------------------------------------------|
107
110
  | `checkoutOrigin` | `string \| null` | Origin serving the Solana checkout endpoint. `null` resolves it from the script `src` or page origin. |
108
111
  | `checkoutPath` | `string` | Checkout path. Default `'/api/x402-checkout'`. |
109
- | `brand` | `{ name, url }` | Footer attribution. |
110
- | `footerNote` | `string` | Text on the left side of the footer. |
112
+ | `brand` | `{ name?, url?, logo? }` | Footer attribution (`name`/`url`) and an optional header `logo` (URL). |
113
+ | `footerNote` | `string` | Text on the left side of the footer. Default `'x402 · onchain settled'`. |
114
+ | `theme` | `'auto' \| 'light' \| 'dark'` | Force the color scheme. Default `'auto'` (follow the OS). See [theming](./theming.md). |
115
+ | `cssVars` | `Record<string,string> \| null` | Flat map of `--x402-*` design tokens to brand-match at runtime, e.g. `{ '--x402-radius': '8px' }`. |
111
116
  | `builderCode` | `{ wallet, service }` | ERC-8021 builder-code echo. Each value lowercase `[a-z0-9_]{1,32}`. |
112
117
  | `esm` | `{ solanaWeb3, nobleHashesSha3 }` | CDN URLs for crypto helpers loaded on demand. Repoint for strict CSP / self-hosting. |
113
118
 
119
+ Nested objects (`brand`, `builderCode`, `esm`, `cssVars`) are **shallow-merged**,
120
+ so you can set a single field without clearing the others.
121
+
114
122
  > The `checkoutOrigin` / `checkoutPath` settings only matter for the Solana rail.
115
123
  > EVM-only sites can ignore them.
116
124
 
@@ -136,11 +144,30 @@ The package version string.
136
144
 
137
145
  ```js
138
146
  import { version } from '@three-ws/x402-payment-modal';
139
- console.log(version); // "1.1.0"
147
+ console.log(version); // e.g. "1.2.0"
140
148
  ```
141
149
 
142
150
  ---
143
151
 
152
+ ## Token constants
153
+
154
+ For inline merchants composing a 402 challenge in the browser, the client also
155
+ exports the well-known Solana mints (also at `window.X402.tokens`):
156
+
157
+ ```js
158
+ import { USDC_MINT_SOLANA, THREE_MINT, KNOWN_SOLANA_TOKENS }
159
+ from '@three-ws/x402-payment-modal';
160
+ ```
161
+
162
+ | Export | Type | Notes |
163
+ |--------|------|-------|
164
+ | `USDC_MINT_SOLANA` | `string` | Solana USDC mint (mainnet) — the always-on default settlement asset. |
165
+ | `THREE_MINT` | `string` | An optional opt-in SPL token recognized by the modal; used only when an endpoint chooses to accept it alongside USDC. |
166
+ | `KNOWN_SOLANA_TOKENS` | `Readonly<Record<string, { symbol, name, decimals, stable?, accent?, glyph? }>>` | Mints the modal renders with the correct symbol/decimals even when the `accept` omits `extra.name`/`extra.decimals`. |
167
+
168
+ The server side builds accepts with the higher-level `solanaAccept()` helper — see
169
+ [server setup](./server-setup.md).
170
+
144
171
  ## HTML data attributes
145
172
 
146
173
  You can drive the modal declaratively without writing JavaScript. Add attributes
@@ -168,4 +168,10 @@ rolled back. Callers should treat `cancelled` as a no-op, not a failure.
168
168
  noble hashes) are loaded on demand from CDN ESM and can be repointed for
169
169
  strict CSP via [`configure`](./api-reference.md#configure).
170
170
  - **Server:** optional, only for the Solana rail. Exposed at
171
- `@three-ws/x402-payment-modal/server` with Express and Vercel adapters.
171
+ `@three-ws/x402-payment-modal/server` (framework-agnostic) with Express
172
+ (`./server/express`) and Vercel (`./server/vercel`) adapters. Peer deps
173
+ `@solana/web3.js` + `@solana/spl-token` (and `express` for that adapter) are
174
+ optional — install only when you mount the checkout.
175
+ - **React:** optional bindings at `@three-ws/x402-payment-modal/react`
176
+ (`X402Button`, `useX402`) that dynamically import the browser core on first use,
177
+ so they are SSR-safe. See the [React reference](./react.md).
package/docs/react.md ADDED
@@ -0,0 +1,163 @@
1
+ # React reference
2
+
3
+ The `./react` subpath ships first-class React bindings for the modal:
4
+
5
+ ```js
6
+ import { X402Button, useX402, configure } from '@three-ws/x402-payment-modal/react';
7
+ ```
8
+
9
+ `react` is an **optional peer dependency** — you already have it in a React app.
10
+ The browser-only core (`@three-ws/x402-payment-modal`) is **dynamically imported
11
+ on first use**, so nothing from it runs during render or on the server. That makes
12
+ both exports **SSR-safe** in Next.js, Remix, Astro, etc. — no `dynamic`/`ssr:false`
13
+ wrapper needed.
14
+
15
+ For the underlying `pay()` contract and `PayResult` shape, see the
16
+ [API reference](./api-reference.md). For the payment lifecycle, see
17
+ [architecture](./architecture.md).
18
+
19
+ ---
20
+
21
+ ## `<X402Button>`
22
+
23
+ A drop-in pay button. It renders a `<button>`, runs the modal on click, and calls
24
+ `onResult` / `onError`. While a payment is in flight it is `disabled`, shows
25
+ `Processing…`, and sets `aria-busy`. User cancellation is silent (no `onError`).
26
+
27
+ ```jsx
28
+ import { X402Button } from '@three-ws/x402-payment-modal/react';
29
+
30
+ export default function Buy() {
31
+ return (
32
+ <X402Button
33
+ endpoint="/api/paid/summarize"
34
+ method="POST"
35
+ body={{ url: 'https://en.wikipedia.org/wiki/x402' }}
36
+ merchant="Acme Summaries"
37
+ action="Summarize article"
38
+ label="Summarize for $0.01"
39
+ caps={{ maxPerCall: 100_000 }} // 0.10 USDC (atomic micro-USD)
40
+ onResult={(r) => console.log('paid', r.payment)}
41
+ onError={(e) => console.error('payment failed', e)}
42
+ className="my-pay-btn" // extra props spread onto the <button>
43
+ />
44
+ );
45
+ }
46
+ ```
47
+
48
+ ### Props
49
+
50
+ | Prop | Type | Notes |
51
+ |---------------|-------------------------------|-----------------------------------------------------------------------------|
52
+ | `endpoint` | `string` | **Required.** The x402-protected URL. |
53
+ | `method` | `string` | HTTP method. Defaults to `GET` (or `POST` when `body` is set). |
54
+ | `body` | `unknown` | Request body. Objects are JSON-stringified. |
55
+ | `headers` | `Record<string,string>` | Extra request headers. |
56
+ | `merchant` | `string` | Shown in the modal header. |
57
+ | `action` | `string` | Shown in the modal header. |
58
+ | `caps` | `SpendingCaps` | Client-side caps (stablecoin only). See [spending caps](./spending-caps.md).|
59
+ | `autoConnect` | `boolean` | Skip the picker when exactly one wallet is detected. |
60
+ | `label` | `string` | Button text. Default `"Pay"`. `children` overrides it. |
61
+ | `onResult` | `(result: PayResult) => void` | Called on success. |
62
+ | `onError` | `(error: Error) => void` | Called on failure. **Not** called when the user cancels. |
63
+ | `children` | `ReactNode` | Custom button content (overrides `label`). |
64
+ | …`rest` | `button` attributes | Any other `<button>` prop (`className`, `style`, `id`, …) is spread through.|
65
+
66
+ `onResult` receives the full [`PayResult`](./api-reference.md#payresult):
67
+ `{ ok, result, payment?, siwx?, response }`.
68
+
69
+ ---
70
+
71
+ ## `useX402(defaults?)`
72
+
73
+ A headless payment hook with a small state machine. Use it when you want full
74
+ control over the trigger UI (your own button, a menu item, an effect) instead of
75
+ `<X402Button>`.
76
+
77
+ ```jsx
78
+ import { useX402 } from '@three-ws/x402-payment-modal/react';
79
+
80
+ function PremiumGate() {
81
+ const { pay, status, result, error, reset, isPaying } = useX402({
82
+ merchant: 'Acme', // defaults merged under every pay() call
83
+ action: 'Unlock premium',
84
+ });
85
+
86
+ if (status === 'done') {
87
+ return <pre>{JSON.stringify(result.result, null, 2)}</pre>;
88
+ }
89
+
90
+ return (
91
+ <>
92
+ <button disabled={isPaying} onClick={() => pay({ endpoint: '/api/paid/premium' })}>
93
+ {isPaying ? 'Processing…' : 'Unlock for $0.05'}
94
+ </button>
95
+ {status === 'error' && (
96
+ <p role="alert">
97
+ {error?.message} <button onClick={reset}>Try again</button>
98
+ </p>
99
+ )}
100
+ </>
101
+ );
102
+ }
103
+ ```
104
+
105
+ ### Return value
106
+
107
+ | Field | Type | Notes |
108
+ |------------|---------------------------------------------------|------------------------------------------------------------------|
109
+ | `pay` | `(opts?: Partial<PayOptions>) => Promise<PayResult \| undefined>` | Opens the modal; `opts` are merged over `defaults`. Resolves to the result, or `undefined` if cancelled. Re-entrancy is guarded — a second call while one is in flight is a no-op. |
110
+ | `status` | `'idle' \| 'paying' \| 'done' \| 'error'` | Current state. Cancellation returns to `idle`. |
111
+ | `result` | `PayResult \| null` | The last successful result. |
112
+ | `error` | `Error \| null` | The last non-cancellation error. |
113
+ | `reset` | `() => void` | Clear `result`/`error` back to `idle`. |
114
+ | `isPaying` | `boolean` | `status === 'paying'`. |
115
+
116
+ `pay` re-throws non-cancellation errors so you can `try/catch` at the call site as
117
+ well as read `error`.
118
+
119
+ ---
120
+
121
+ ## `configure(opts?)`
122
+
123
+ Set modal-wide config (checkout origin, theme, branding, builder-code, CDN URLs)
124
+ before the first payment. The React wrapper's `configure` is **async** — it
125
+ resolves once the core module has loaded and applied the config — so call it once
126
+ at app startup:
127
+
128
+ ```jsx
129
+ import { useEffect } from 'react';
130
+ import { configure } from '@three-ws/x402-payment-modal/react';
131
+
132
+ export default function App({ children }) {
133
+ useEffect(() => {
134
+ configure({
135
+ checkoutOrigin: 'https://pay.acme.com',
136
+ theme: 'dark',
137
+ brand: { name: 'Acme', url: 'https://acme.com', logo: '/logo.svg' },
138
+ });
139
+ }, []);
140
+ return children;
141
+ }
142
+ ```
143
+
144
+ The accepted options are identical to the core
145
+ [`configure()`](./api-reference.md#configure).
146
+
147
+ ---
148
+
149
+ ## SSR notes
150
+
151
+ - Import from `@three-ws/x402-payment-modal/react` anywhere — the heavy core is
152
+ only loaded inside `pay()` / the button's click handler.
153
+ - The first payment pays a one-time dynamic-import cost; subsequent ones reuse the
154
+ loaded module.
155
+ - `configure()` here returns a `Promise`; awaiting it (or firing it in an effect)
156
+ guarantees the config is applied before the first modal opens.
157
+
158
+ ## Solana payments still need a server
159
+
160
+ EVM signs in the browser; **Solana needs the checkout endpoint** that builds and
161
+ wraps the transfer. Stand one up before going live — see
162
+ [server setup](./server-setup.md) and the runnable
163
+ [`examples/server-express`](../examples/server-express).
@@ -44,14 +44,38 @@ npm install @solana/web3.js@^1.95 @solana/spl-token@^0.4
44
44
 
45
45
  EVM-only sites can skip this entirely.
46
46
 
47
+ ## Adapter options
48
+
49
+ Every adapter (`x402CheckoutRouter`, `createVercelCheckoutHandler`) and the core
50
+ `handleCheckout` take the same options:
51
+
52
+ | Option | Type | Default | Purpose |
53
+ |------------------|-------------|---------|-------------------------------------------------------------------------|
54
+ | `rpcUrl` | `string` | public RPC | Single Solana **mainnet** RPC URL. |
55
+ | `rpcUrls` | `string[]` | — | Ordered mainnet RPCs tried with **failover** on a transient RPC error. **Preferred for production.** |
56
+ | `devnetRpcUrl` | `string` | public devnet | Single Solana **devnet** RPC URL. |
57
+ | `devnetRpcUrls` | `string[]` | — | Ordered devnet RPCs with failover. |
58
+ | `origin` | `string` | `'*'` | `Access-Control-Allow-Origin` for the adapter. |
59
+ | `logger` | `Function` | `console.error` | Called with the root cause of an unexpected (non-`CheckoutError`) failure before the generic `502`. |
60
+
61
+ > **Use a dedicated RPC.** With no `rpcUrl`/`rpcUrls` the helpers fall back to the
62
+ > public Solana RPC, which is heavily rate-limited and **warns once** at startup —
63
+ > it will fail under real load. Pass a list (Helius / Triton / QuickNode) via
64
+ > `rpcUrls` so the adapter can rotate on a transient error:
65
+ >
66
+ > ```js
67
+ > x402CheckoutRouter({ rpcUrls: [process.env.SOLANA_RPC_PRIMARY, process.env.SOLANA_RPC_BACKUP] });
68
+ > ```
69
+
47
70
  ## Environment variables
48
71
 
49
72
  | Variable | Purpose |
50
73
  |-------------------|---------------------------------------------------------------|
51
74
  | `SOLANA_RPC_URL` | Mainnet RPC endpoint used to build/serialize the transaction. |
52
75
 
53
- You may also pass `rpcUrl` (and `devnetRpcUrl`) explicitly to the adapters or to
54
- `prepareSolanaCheckout`; explicit options take precedence over the env var.
76
+ These are your wiring convention the package reads no env var itself. Pass the
77
+ value into the option (`rpcUrl: process.env.SOLANA_RPC_URL`). Explicit options
78
+ always take precedence.
55
79
 
56
80
  ## Mounting with Express
57
81
 
@@ -74,9 +98,10 @@ app.use(
74
98
  app.listen(3000);
75
99
  ```
76
100
 
77
- `x402CheckoutRouter({ rpcUrl?, devnetRpcUrl?, origin? })` returns an Express
78
- `RequestHandler`. It sets permissive CORS by default (`origin: '*'`), answers
79
- `OPTIONS` preflight, and requires `POST` for the actual calls.
101
+ `x402CheckoutRouter({ rpcUrl?, rpcUrls?, devnetRpcUrl?, devnetRpcUrls?, origin?, logger? })`
102
+ returns an Express `RequestHandler` (see [Adapter options](#adapter-options)). It
103
+ sets permissive CORS by default (`origin: '*'`), answers `OPTIONS` preflight, and
104
+ requires `POST` for the actual calls.
80
105
 
81
106
  ## Mounting with Vercel / Next.js (pages API)
82
107
 
@@ -228,12 +253,44 @@ Response:
228
253
  The client puts `x_payment` into the `X-PAYMENT` header and retries the original
229
254
  request.
230
255
 
256
+ ## Building the 402 challenge: `solanaAccept`
257
+
258
+ On the merchant side, build spec-shaped `accept` entries for your 402 body with
259
+ `solanaAccept` — no hardcoded mints, USDC by default, an optional second token for
260
+ a picker:
261
+
262
+ ```js
263
+ import { solanaAccept } from '@three-ws/x402-payment-modal/server';
264
+
265
+ const common = { payTo, feePayer, maxTimeoutSeconds: 60 };
266
+ const accepts = [
267
+ solanaAccept({ token: 'usdc', uiAmount: 0.25, ...common }), // $0.25 USDC
268
+ // Optional second token → the modal renders a token picker:
269
+ // solanaAccept({ token: 'three', uiAmount: 1000, ...common }),
270
+ // …or any SPL mint:
271
+ // solanaAccept({ mint: 'So111…', decimals: 9, name: 'MyToken', uiAmount: 5, ...common }),
272
+ ];
273
+
274
+ res.status(402).json({ x402Version: 2, accepts });
275
+ ```
276
+
277
+ `solanaAccept({ token?, mint?, payTo, feePayer, amount?, uiAmount?, decimals?, name?, network?, maxTimeoutSeconds? })`
278
+ needs a `token: 'usdc' | 'three'` **or** an explicit `mint`, plus the price as
279
+ `amount` (atomic integer string) **or** `uiAmount` (human units, converted via
280
+ decimals). `feePayer` is the facilitator sponsor that pays the SOL network fee.
281
+
231
282
  ## Helpers
232
283
 
233
284
  | Export | Description |
234
285
  |----------------------------|------------------------------------------------------------------------|
286
+ | `solanaAccept(args)` | Build one Solana `accept` entry for a 402 challenge (see above). |
287
+ | `prepareSolanaCheckout(args)` | Build the partially-signed v0 transaction the buyer signs. |
288
+ | `encodeX402Payment(args)` | Wrap the buyer-signed tx into the base64 `X-PAYMENT` envelope. |
289
+ | `handleCheckout(args)` | Route `prepare`/`encode`; returns `{ status, body }`. |
235
290
  | `CheckoutError` | `Error` subclass with `.status` and `.code`; mapped to HTTP by router. |
236
291
  | `isSolanaNetwork(network)` | `true` for Solana mainnet/devnet network identifiers. |
237
292
  | `X402_VERSION` | `2` — the x402 envelope version produced by `encode`. |
238
293
  | `NETWORK_SOLANA_MAINNET` | Canonical Solana mainnet network id. |
239
- | `NETWORK_SOLANA_DEVNET` | Canonical Solana devnet network id. |
294
+ | `NETWORK_SOLANA_DEVNET` | Canonical Solana devnet network id. |
295
+ | `USDC_MINT_SOLANA`, `THREE_MINT` | Well-known mint constants. |
296
+ | `WELL_KNOWN_SOLANA_TOKENS` | `{ usdc, three }` token metadata keyed by lowercase shortcut. |
@@ -6,8 +6,9 @@ Runnable examples for [`@three-ws/x402-payment-modal`](https://www.npmjs.com/pac
6
6
  | Example | What it shows | Fastest way to try it |
7
7
  | --- | --- | --- |
8
8
  | [`plain-html/`](./plain-html) | No build step. Declarative `data-x402-*` buttons, a programmatic `pay()` call, and `x402:result` / `x402:error` event handling — all from a single CDN `<script>`. | Open `plain-html/index.html` in a browser (or serve the folder, e.g. `npx serve plain-html`). |
9
- | [`react/`](./react) | A reusable `<X402Button>` component that wraps `pay()` with a dynamic import, so it is SSR-safe. | Copy `react/X402Button.jsx` into your app — see [`react/README.md`](./react/README.md). |
9
+ | [`react/`](./react) | The shipped `./react` wrapper — `<X402Button>` and the `useX402()` hook, both SSR-safe. | Copy `react/App.jsx` into your app — see [`react/README.md`](./react/README.md). |
10
10
  | [`server-express/`](./server-express) | A complete Express server that mounts the Solana checkout router and serves a demo paid endpoint returning a real x402 v2 challenge. | `cd server-express && npm install && npm start`, then open http://localhost:3000 |
11
+ | [`solana-crypto-paywall/`](./solana-crypto-paywall) | End-to-end Solana paywall: a paid endpoint, the checkout server, and a local facilitator. | See [`solana-crypto-paywall/README.md`](./solana-crypto-paywall/README.md). |
11
12
 
12
13
  ## Which do I need?
13
14
 
@@ -0,0 +1,95 @@
1
+ /**
2
+ * React example for @three-ws/x402-payment-modal.
3
+ *
4
+ * This uses the package's SHIPPED React wrapper — `@three-ws/x402-payment-modal/react`
5
+ * — not a hand-rolled component. The wrapper exports:
6
+ *
7
+ * - <X402Button> a drop-in pay button
8
+ * - useX402() a headless { pay, status, result, error, reset, isPaying } hook
9
+ *
10
+ * Both dynamically import the browser-only core on first use, so they are
11
+ * SSR-safe (nothing runs during render or on the server) in Next.js, Remix, etc.
12
+ *
13
+ * For Solana payments your app must also run the checkout endpoint
14
+ * (see ../../docs/server-setup.md and ../server-express). EVM payments sign
15
+ * in-browser and need no server.
16
+ *
17
+ * The endpoints below are PLACEHOLDERS — swap in a real x402 endpoint.
18
+ */
19
+
20
+ import { useState } from 'react';
21
+ import { X402Button, useX402 } from '@three-ws/x402-payment-modal/react';
22
+
23
+ export default function App() {
24
+ return (
25
+ <main style={{ maxWidth: 520, margin: '40px auto', fontFamily: 'system-ui' }}>
26
+ <h1>x402 Payment Modal — React</h1>
27
+
28
+ <h2>1. Drop-in button</h2>
29
+ <ButtonDemo />
30
+
31
+ <h2>2. Headless hook</h2>
32
+ <HookDemo />
33
+ </main>
34
+ );
35
+ }
36
+
37
+ /** The simplest integration: render <X402Button> and handle the result. */
38
+ function ButtonDemo() {
39
+ const [last, setLast] = useState(null);
40
+
41
+ return (
42
+ <>
43
+ <X402Button
44
+ endpoint="https://api.example.com/paid/summarize"
45
+ method="POST"
46
+ body={{ url: 'https://en.wikipedia.org/wiki/x402' }}
47
+ merchant="Acme Summaries"
48
+ action="Summarize article"
49
+ label="Summarize for $0.01"
50
+ // Client-side cap (stablecoin only): 0.10 USDC per call.
51
+ caps={{ maxPerCall: 100_000 }}
52
+ onResult={(r) => setLast(r)}
53
+ onError={(e) => console.error('payment failed', e)}
54
+ />
55
+ {last && (
56
+ <pre style={{ marginTop: 12 }}>
57
+ {JSON.stringify({ payment: last.payment, result: last.result }, null, 2)}
58
+ </pre>
59
+ )}
60
+ </>
61
+ );
62
+ }
63
+
64
+ /** Full control over the trigger UI via the useX402 state machine. */
65
+ function HookDemo() {
66
+ const { pay, status, result, error, reset, isPaying } = useX402({
67
+ merchant: 'Acme',
68
+ action: 'Unlock premium',
69
+ });
70
+
71
+ if (status === 'done') {
72
+ return (
73
+ <>
74
+ <pre>{JSON.stringify(result.result, null, 2)}</pre>
75
+ <button onClick={reset}>Reset</button>
76
+ </>
77
+ );
78
+ }
79
+
80
+ return (
81
+ <>
82
+ <button
83
+ disabled={isPaying}
84
+ onClick={() => pay({ endpoint: 'https://api.example.com/paid/premium' })}
85
+ >
86
+ {isPaying ? 'Processing…' : 'Unlock for $0.05'}
87
+ </button>
88
+ {status === 'error' && (
89
+ <p role="alert" style={{ color: 'crimson' }}>
90
+ {error?.message} <button onClick={reset}>Try again</button>
91
+ </p>
92
+ )}
93
+ </>
94
+ );
95
+ }