@three-ws/x402-payment-modal 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -9
- package/CONTRIBUTING.md +79 -0
- package/LICENSE +38 -180
- package/README.md +238 -63
- package/dist/index.d.ts +14 -3
- package/dist/x402.js +564 -206
- package/dist/x402.min.js +308 -178
- package/docs/EXAMPLES.md +137 -0
- package/docs/api-reference.md +32 -5
- package/docs/architecture.md +7 -1
- package/docs/react.md +163 -0
- package/docs/server-setup.md +63 -6
- package/examples/README.md +2 -1
- package/examples/react/App.jsx +95 -0
- package/examples/react/README.md +34 -31
- package/examples/server-express/server.js +16 -9
- package/examples/solana-crypto-paywall/README.md +81 -0
- package/examples/solana-crypto-paywall/facilitator.mjs +170 -0
- package/examples/solana-crypto-paywall/package.json +17 -0
- package/examples/solana-crypto-paywall/public/index.html +506 -0
- package/examples/solana-crypto-paywall/server.mjs +279 -0
- package/package.json +126 -111
- package/react/index.d.ts +39 -0
- package/react/index.js +112 -0
- package/server/checkout.js +208 -66
- package/server/express.js +7 -4
- package/server/vercel.js +2 -2
- package/src/index.js +563 -205
- package/types/index.d.ts +14 -3
- package/types/server.d.ts +2 -1
- package/examples/react/X402Button.jsx +0 -84
package/docs/EXAMPLES.md
ADDED
|
@@ -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)
|
package/docs/api-reference.md
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
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).
|
package/docs/server-setup.md
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
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? })`
|
|
78
|
-
|
|
79
|
-
|
|
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. |
|
package/examples/README.md
CHANGED
|
@@ -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) |
|
|
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
|
+
}
|