@three-ws/x402-modal 0.2.0 → 0.2.1

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/README.md CHANGED
@@ -4,64 +4,134 @@
4
4
 
5
5
  **A drop-in payment modal for any [x402](https://x402.org) paid endpoint.**
6
6
 
7
- One script tag turns an HTTP `402 Payment Required` into a polished checkout:
8
- wallet connect (Phantom on Solana, MetaMask/EVM via EIP-3009), the
9
- `402 sign → settle` flow, SIWX re-entry, spending caps, and a receipt — all
10
- in **vanilla JS, with no bundler and no framework**.
7
+ One `<script>` tag turns an HTTP `402 Payment Required` into a polished checkout:
8
+ discover the challenge, connect a wallet (Phantom on Solana, MetaMask / any EVM
9
+ wallet via EIP-3009), sign, settle, and show a receipt — in **vanilla JS, with no
10
+ bundler and no framework**.
11
11
 
12
12
  [![npm](https://img.shields.io/npm/v/@three-ws/x402-modal?logo=npm&color=cb3837)](https://www.npmjs.com/package/@three-ws/x402-modal)
13
- [![downloads](https://img.shields.io/npm/dm/@three-ws/x402-modal?color=cb3837)](https://www.npmjs.com/package/@three-ws/x402-modal)
14
- ![license](https://img.shields.io/npm/l/@three-ws/x402-modal?color=3b82f6)
13
+ [![bundle size](https://img.shields.io/badge/CDN%20gzip-~12%20kB-3b82f6)](https://www.jsdelivr.com/package/npm/@three-ws/x402-modal)
14
+ [![License: Proprietary](https://img.shields.io/badge/license-Proprietary-red.svg)](./LICENSE)
15
15
  ![node](https://img.shields.io/node/v/@three-ws/x402-modal?color=339933&logo=node.js)
16
+ ![dependencies](https://img.shields.io/badge/runtime%20deps-0-22c55e)
16
17
 
17
- [Quick start](#quick-start) · [How it works](#how-it-works) · [API](#api) · [Configuration](#configuration) · [Backend](#the-backend) · [Tutorials](./TUTORIAL.md) · [FAQ](#faq)
18
+ [Install](#install) · [Quickstart](#quickstart) · [How it works](#how-it-works) · [API](#api-reference) · [Configuration](#configuration-reference) · [Frameworks](#framework-guides) · [Wallets](#wallets--assets) · [FAQ](#faq--troubleshooting)
18
19
 
19
20
  </div>
20
21
 
21
22
  ---
22
23
 
23
- ## Why
24
+ ## What & why
24
25
 
25
26
  [x402](https://x402.org) revives HTTP `402 Payment Required` as a real payment
26
27
  rail: a server answers a request with a `402` whose body lists what it
27
- `accepts` (asset, amount, network, pay-to), the client pays, and re-sends the
28
- request with an `X-PAYMENT` header. It's perfect for pay-per-call APIs, agent
28
+ `accepts` (asset · amount · network · pay-to), the client pays, and re-sends the
29
+ request with an `X-PAYMENT` header. It is built for pay-per-call APIs, agent
29
30
  economies, and content paywalls — but every merchant ends up rebuilding the same
30
31
  fiddly client: parse the challenge, connect a wallet, sign the right thing for
31
- the right chain, retry, settle, show a receipt.
32
+ the right chain, retry, settle, and show a receipt.
32
33
 
33
34
  **This package is that client, done once and done well.** Point it at a `402`
34
- endpoint and it renders the entire flow. The EVM/Base path is 100%
35
- client-side. The Solana path needs one small backend helper (see
36
- [The backend](#the-backend)).
35
+ endpoint and it renders the entire flow.
36
+
37
+ ### Features
38
+
39
+ - **Zero-config drop-in** — one `<script>` tag binds every `data-x402-endpoint`
40
+ element on the page. No build step, no framework, no wallet adapter.
41
+ - **Works against any origin** — the `402 → sign → settle` flow is
42
+ merchant-agnostic. Point it at your endpoint or someone else's.
43
+ - **Solana + EVM** — Phantom on Solana, MetaMask / any injected `window.ethereum`
44
+ on Base and other EVM chains. USDC by default; arbitrary SPL tokens supported.
45
+ - **No bundler required** — ships a single self-contained IIFE for `<script>`
46
+ use, plus a side-effect-free ESM build for bundlers.
47
+ - **Gasless for the payer** — EVM uses EIP-3009 signed authorizations; Solana
48
+ uses a facilitator fee-payer. The buyer never needs native gas.
49
+ - **Spending caps** — per-wallet `localStorage` caps by call / hour / day, so an
50
+ autonomous agent can't overspend.
51
+ - **Zero runtime dependencies** — the two optional wallet libraries are loaded
52
+ on demand from a CDN, only when that wallet path actually runs.
53
+ - **Every state designed** — discover, connect, sign, settle, retry, error, and
54
+ receipt all render as live rows with light/dark theming.
55
+
56
+ The EVM/Base path is **100% client-side**. The Solana path needs one small
57
+ backend helper (see [The backend](#the-solana-backend)).
37
58
 
38
- ## Quick start
59
+ ---
60
+
61
+ ## Install
39
62
 
40
- ### 1 One script tag (zero JS)
63
+ ### npm (bundlers / frameworks)
64
+
65
+ ```sh
66
+ npm i @three-ws/x402-modal
67
+ ```
68
+
69
+ ```js
70
+ import { pay, configure } from '@three-ws/x402-modal'; // ESM, no side effects
71
+ ```
72
+
73
+ ### CDN `<script>` (no install, no bundler)
74
+
75
+ The `/global` build auto-binds `[data-x402-endpoint]` elements and exposes
76
+ `window.X402`. Pick a CDN:
41
77
 
42
78
  ```html
79
+ <!-- unpkg -->
43
80
  <script type="module" src="https://unpkg.com/@three-ws/x402-modal/global"></script>
44
81
 
45
- <button
46
- data-x402-endpoint="https://api.example.com/paid/summarize"
47
- data-x402-method="POST"
48
- data-x402-body='{"text":"hello world"}'
49
- data-x402-merchant="Acme"
50
- data-x402-action="Summarize">
51
- Pay &amp; summarize
52
- </button>
82
+ <!-- jsDelivr -->
83
+ <script type="module" src="https://cdn.jsdelivr.net/npm/@three-ws/x402-modal/global"></script>
53
84
  ```
54
85
 
55
- Clicking the button opens the modal, runs the payment, calls the endpoint, and
56
- fires an `x402:result` event on the button with `{ ok, result, payment, response }`:
86
+ > Pin a version for production, e.g.
87
+ > `https://unpkg.com/@three-ws/x402-modal@0.2.0/global`.
57
88
 
58
- ```js
59
- document.querySelector('button').addEventListener('x402:result', (e) => {
60
- console.log('paid + got result:', e.detail.result);
61
- });
89
+ The CDN bundle is a single self-contained file (~12 kB gzipped). No `npm`, no
90
+ build step.
91
+
92
+ ---
93
+
94
+ ## Quickstart
95
+
96
+ The smallest possible page that pops the modal and settles a `402`. Save it as
97
+ `index.html`, replace the endpoint with your own x402 route, and open it:
98
+
99
+ ```html
100
+ <!doctype html>
101
+ <html lang="en">
102
+ <head><meta charset="utf-8" /><title>x402 checkout</title></head>
103
+ <body>
104
+ <button
105
+ data-x402-endpoint="https://api.example.com/paid/summarize"
106
+ data-x402-method="POST"
107
+ data-x402-body='{"text":"hello world"}'
108
+ data-x402-merchant="Acme"
109
+ data-x402-action="Summarize">
110
+ Pay &amp; summarize
111
+ </button>
112
+
113
+ <pre id="out"></pre>
114
+
115
+ <script type="module" src="https://unpkg.com/@three-ws/x402-modal/global"></script>
116
+ <script type="module">
117
+ const btn = document.querySelector('button');
118
+ btn.addEventListener('x402:result', (e) => {
119
+ document.getElementById('out').textContent =
120
+ JSON.stringify(e.detail.result, null, 2);
121
+ });
122
+ btn.addEventListener('x402:error', (e) => {
123
+ document.getElementById('out').textContent = 'Error: ' + e.detail.error;
124
+ });
125
+ </script>
126
+ </body>
127
+ </html>
62
128
  ```
63
129
 
64
- ### 2 Programmatic (full control)
130
+ Clicking the button opens the modal, runs the payment, calls the endpoint, and
131
+ fires `x402:result` on the button with the full
132
+ `{ ok, result, payment, response }` payload.
133
+
134
+ Prefer to drive it yourself? Call `pay()` and await the result:
65
135
 
66
136
  ```js
67
137
  import { pay } from '@three-ws/x402-modal';
@@ -74,149 +144,253 @@ const out = await pay({
74
144
  action: 'Summarize',
75
145
  });
76
146
 
77
- console.log(out.result); // the endpoint's response, after settlement
78
- console.log(out.payment); // { network, payer, transaction }
147
+ console.log(out.result); // the endpoint's response, after settlement
148
+ console.log(out.payment); // { network, payer, transaction }
79
149
  ```
80
150
 
81
151
  `pay()` resolves once the paid call returns `200`, or rejects with an `Error`
82
152
  whose `.code === 'cancelled'` if the user closes the modal.
83
153
 
84
- ### 3 — Self-hosted, fully branded
154
+ ---
85
155
 
86
- ```html
87
- <script
88
- type="module"
89
- src="https://your.cdn/x402.global.js"
90
- data-x402-api-origin="https://pay.your-company.com"
91
- data-x402-brand-label="Powered by Acme"
92
- data-x402-brand-href="https://acme.com"></script>
156
+ ## How it works
157
+
158
+ The modal drives the four-step x402 flow. Each step renders as a live row in the
159
+ modal (spinner → check → error), with a **Try again** affordance on failure,
160
+ automatic retry on a `429` upstream throttle (the payment isn't settled until the
161
+ work succeeds, so re-sending can't double-charge), and a receipt with an explorer
162
+ link on success.
163
+
164
+ ```mermaid
165
+ sequenceDiagram
166
+ participant U as User
167
+ participant M as x402-modal
168
+ participant W as Wallet (Phantom / EVM)
169
+ participant E as Merchant endpoint
170
+
171
+ U->>M: click / pay({ endpoint })
172
+ M->>E: 1. DISCOVER — request with no payment
173
+ E-->>M: 402 + accepts[] (asset · amount · network · payTo)
174
+ M->>U: render price + network, pick a wallet
175
+ U->>W: 2. CONNECT
176
+ W-->>M: account address
177
+ M->>W: 3. SIGN — EVM: EIP-3009 typed data · Solana: built tx
178
+ W-->>M: signed authorization / transaction
179
+ M->>E: 4. SETTLE — re-send request with X-PAYMENT
180
+ E-->>M: 200 + result + x-payment-response receipt
181
+ M-->>U: receipt + explorer link, resolve pay()
93
182
  ```
94
183
 
95
- or from JS, before the first `pay()`:
184
+ 1. **Discover** send the request you described with no payment. Expect a `402`
185
+ with a JSON body containing `accepts[]`, or a `401` with a base64-JSON
186
+ `payment-required` header (MCP 2025-06-18). Anything else is an error.
187
+ 2. **Connect** — pick a wallet that can satisfy an accept: Solana → Phantom,
188
+ EVM → MetaMask / any injected `window.ethereum`. When the `402` advertises
189
+ more than one network the modal shows a wallet picker; with exactly one it
190
+ goes straight there.
191
+ 3. **Sign** — EVM signs an EIP-3009 `transferWithAuthorization` (no on-chain tx,
192
+ no gas for the payer). Solana has a backend build the transaction, then
193
+ Phantom signs it.
194
+ 4. **Settle** — re-send the request with the `X-PAYMENT` header. The endpoint
195
+ runs the work, settles on-chain, and returns `200` plus an
196
+ `x-payment-response` receipt.
197
+
198
+ > The full step-by-step protocol, including the `401`/`payment-required` header
199
+ > variant and SIWX re-entry, lives in [`docs/PROTOCOL.md`](./docs/PROTOCOL.md).
96
200
 
97
- ```js
98
- import { configure } from '@three-ws/x402-modal';
201
+ ---
99
202
 
100
- configure({
101
- apiOrigin: 'https://pay.your-company.com', // Solana checkout backend
102
- brand: { label: 'Powered by Acme', href: 'https://acme.com' },
103
- });
203
+ ## API reference
204
+
205
+ The ESM entry (`@three-ws/x402-modal`) is **side-effect-free** importing it
206
+ never touches `window` or binds anything. The `/global` entry adds the auto-bind
207
+ behavior and `window.X402`.
208
+
209
+ ### `pay(options): Promise<PayResult>`
210
+
211
+ Open the modal for one endpoint and resolve after settlement.
212
+
213
+ ```ts
214
+ function pay(options: PayOptions): Promise<PayResult>;
104
215
  ```
105
216
 
106
- > **No dependencies to install.** The ESM build leaves the two optional wallet
107
- > libraries (`@solana/web3.js` for Solana, a keccak for EVM sign-in) as runtime
108
- > CDN imports, fetched only when that wallet path actually runs. The `/global`
109
- > build is a single self-contained file.
217
+ | option | type | default | notes |
218
+ |---------------|-------------------------------|------------------|-------|
219
+ | `endpoint` | `string` | (**required**) | the x402-protected URL to pay for and call |
220
+ | `method` | `string` | `GET` / `POST`\* | \*defaults to `POST` when a `body` is set |
221
+ | `body` | `object \| string` | — | forwarded to the endpoint (object → JSON) |
222
+ | `headers` | `Record<string,string>` | — | merged into discovery + paid calls |
223
+ | `merchant` | `string` | header default | shown in the modal header |
224
+ | `action` | `string` | header default | shown in the modal header (e.g. "Summarize") |
225
+ | `caps` | `SpendingCaps` | — | µUSD spending caps (see [caps](#spending-caps)) |
226
+ | `autoConnect` | `boolean` | `false` | skip the picker when exactly one wallet is detected |
227
+ | `apiOrigin` | `string` | global config | per-call override of the Solana checkout backend |
228
+ | `brand` | `{ label, href }` | global config | per-call footer override |
229
+
230
+ **Returns** `PayResult`:
231
+
232
+ ```ts
233
+ interface PayResult {
234
+ ok: true;
235
+ result: unknown; // the endpoint's response body (JSON or text)
236
+ payment?: { network, payer, transaction, ... }; // present on a fresh payment
237
+ siwx?: { address, network }; // present on SIWX re-entry instead of paying
238
+ response: { status: number; headers: Record<string, string> };
239
+ }
240
+ ```
110
241
 
111
- ## How it works
242
+ **Rejects** with an `Error`. A user-cancelled modal rejects with
243
+ `err.code === 'cancelled'` — check for it to distinguish a deliberate close from
244
+ a real failure.
112
245
 
246
+ ### `configure(config): config`
247
+
248
+ Merge config into the global defaults and return the resolved snapshot. Call it
249
+ once at startup, before the first `pay()`. See [Configuration](#configuration-reference).
250
+
251
+ ```ts
252
+ function configure(config?: X402Config): Required<X402Config>;
113
253
  ```
114
- pay({ endpoint })
115
-
116
- ├─ 1. discover GET/POST endpoint → 402 (or 401 + payment-required header)
117
- │ parse `accepts[]` (asset · amount · network · payTo)
118
-
119
- ├─ 2. connect pick a wallet that can satisfy an accept:
120
- │ Solana Phantom EVM → MetaMask / window.ethereum
121
-
122
- ├─ 3. authorize Solana: backend builds the tx → Phantom signs it
123
- │ EVM: wallet signs an EIP-3009 transferWithAuthorization
124
- │ (no on-chain tx, no gas for the payer)
125
-
126
- └─ 4. verify re-send the request with `X-PAYMENT` → endpoint runs the work,
127
- settles on-chain, returns 200 + `x-payment-response` receipt
254
+
255
+ ### `getConfig(): config`
256
+
257
+ Read the current resolved global config.
258
+
259
+ ```ts
260
+ function getConfig(): Required<X402Config>;
128
261
  ```
129
262
 
130
- Each step renders as a live row in the modal (spinner → check → error), with a
131
- **Try again** affordance on failure, automatic retry on a `429` upstream
132
- throttle (the payment isn't settled until the work succeeds, so re-sending can't
133
- double-charge), and a receipt with an explorer link on success.
263
+ ### `init(): void`
134
264
 
135
- ### Networks
265
+ Scan the document and bind every `[data-x402-endpoint]` element. Idempotent.
136
266
 
137
- | Network | Wallet | Scheme | Needs a backend? |
138
- |---|---|---|---|
139
- | **Base** (`eip155:8453`) | MetaMask / any `window.ethereum` | EIP-3009 `transferWithAuthorization` | **No** — fully client-side |
140
- | Base Sepolia, Arbitrum, Optimism | same | EIP-3009 | No |
141
- | **Solana** (`solana:*`) | Phantom | `exact` (facilitator-settled) | Yes — `prepare`/`encode` helper |
267
+ The `/global` build calls this automatically (and re-scans on DOM mutation via a
268
+ `MutationObserver`). Call it yourself only when using the **ESM** build with
269
+ declarative `data-*` buttons.
142
270
 
143
- When a `402` advertises more than one network the modal shows a wallet picker;
144
- when it advertises exactly one, it goes straight there.
271
+ ### `bindElement(el: Element): void`
145
272
 
146
- ## API
273
+ Bind one element's click to open the modal. On success it dispatches
274
+ `x402:result` (and `x402:siwx-signed` when re-entry was via SIWX); on failure
275
+ `x402:error`. A user cancel dispatches nothing. Idempotent per element.
147
276
 
148
- ### `pay(options): Promise<PayResult>`
277
+ ### `readOptsFrom(el: Element): PayOptions`
149
278
 
150
- | option | type | default | notes |
151
- |---------------|-------------------------------|--------------------|-------|
152
- | `endpoint` | `string` | — (**required**) | the x402-protected URL to pay for and call |
153
- | `method` | `string` | `GET` / `POST`* | *POST when a `body` is set |
154
- | `body` | `object \| string` | — | forwarded to the endpoint (object → JSON) |
155
- | `headers` | `Record<string,string>` | — | merged into discovery + paid calls |
156
- | `merchant` | `string` | `Payment` | shown in the modal header |
157
- | `action` | `string` | `Pay-per-call` | shown in the modal header |
158
- | `caps` | `{ maxPerCall, maxPerHour, maxPerDay }` | — | µUSD spending caps (see [Configuration](#configuration)) |
159
- | `autoConnect` | `boolean` | `false` | skip the picker when exactly one wallet is detected |
160
- | `apiOrigin` | `string` | global config | per-call override of the Solana checkout backend |
161
- | `brand` | `{ label, href }` | global config | per-call footer override |
279
+ Read `PayOptions` from an element's `data-x402-*` attributes. Useful if you want
280
+ to bind elements yourself with custom event handling.
162
281
 
163
- Returns `{ ok: true, result, payment?, siwx?, response }`. `payment` is present
164
- on a fresh payment (`{ network, payer, transaction }`); `siwx` is present when
165
- the user re-entered via sign-in instead of paying.
282
+ ### `version: string`
166
283
 
167
- ### `configure(config): config` · `getConfig(): config`
284
+ The package version string (e.g. `"0.2.0"`).
168
285
 
169
- Set global defaults once at startup. See [Configuration](#configuration).
286
+ ### `class CheckoutModal`
170
287
 
171
- ### `init(): void`
288
+ The low-level modal controller. Most callers should use `pay()` instead.
172
289
 
173
- Scan the document and bind every `[data-x402-endpoint]` element. The `/global`
174
- build calls this automatically (and re-scans on DOM mutation); call it yourself
175
- only when using the ESM build with declarative buttons.
290
+ ```ts
291
+ class CheckoutModal {
292
+ constructor(opts: PayOptions);
293
+ mount(): Promise<PayResult>; // mount the DOM, return the result promise
294
+ start(): Promise<void>; // begin discovery
295
+ close(reason?: string): void; // close; reason 'cancelled' rejects mount()
296
+ }
297
+ ```
298
+
299
+ `pay(opts)` is exactly `new CheckoutModal(opts)`, then `mount()` + `start()`.
176
300
 
177
- ### DOM events (declarative usage)
301
+ ### DOM events
178
302
 
179
303
  Bound elements dispatch bubbling `CustomEvent`s:
180
304
 
181
- - `x402:result` `detail` is the full `PayResult`.
182
- - `x402:error` — `detail` is `{ error: string }`. (Cancellation does **not** fire this.)
183
- - `x402:siwx-signed` `detail` is `{ address, network }`, when re-entry was via SIWX.
305
+ | event | `detail` | when |
306
+ |---------------------|---------------------------|------|
307
+ | `x402:result` | the full `PayResult` | the paid call succeeded |
308
+ | `x402:siwx-signed` | `{ address, network }` | the user re-entered via SIWX instead of paying (fires just before `x402:result`) |
309
+ | `x402:error` | `{ error: string }` | the flow failed (a **cancel does not fire this**) |
310
+
311
+ ### Exports map
312
+
313
+ | import specifier | build | shape |
314
+ |--------------------------------------|-------|-------|
315
+ | `@three-ws/x402-modal` | `dist/x402-modal.mjs` | ESM, side-effect-free public API |
316
+ | `@three-ws/x402-modal/global` | `dist/x402.global.js` | minified IIFE, auto-binds + `window.X402` |
317
+ | `@three-ws/x402-modal/src` | `src/x402-modal.js` | unbundled source (advanced / debugging) |
184
318
 
185
- ### `data-*` attributes (declarative usage)
319
+ The `unpkg` and `jsdelivr` package fields point at `/global`, so bare
320
+ `unpkg.com/@three-ws/x402-modal` also resolves to the drop-in build.
186
321
 
187
- `data-x402-endpoint` (required), `data-x402-method`, `data-x402-body` (JSON),
188
- `data-x402-headers` (JSON), `data-x402-caps` (JSON), `data-x402-api-origin`,
189
- `data-x402-merchant`, `data-x402-action`.
322
+ ---
323
+
324
+ ## Configuration reference
325
+
326
+ Three ways to configure, in increasing specificity (later wins):
327
+
328
+ 1. **`data-x402-*` on the `/global` `<script>` tag** — declarative, no JS.
329
+ 2. **`configure({ … })`** — global defaults, set once at startup.
330
+ 3. **`pay({ … })` options** — per-call overrides.
190
331
 
191
- ## Configuration
332
+ **Defaults are vendor-neutral.** An un-configured drop-in shows **no footer
333
+ attribution** (`brand: null`) and **echoes no builder code** (`builderCode:
334
+ null`). You opt in by setting them.
192
335
 
193
- All fields are optional; the defaults reproduce the hosted three.ws modal.
336
+ ### `configure()` / `pay()` options
337
+
338
+ | option | type | default | description |
339
+ |------------------|-------------------------------|--------------------------------------|-------------|
340
+ | `apiOrigin` | `string \| null` | `null` → resolves to the script's origin | Origin serving the Solana `prepare`/`encode` helpers. `''` = same-origin. Ignored by the EVM path. |
341
+ | `brand` | `{ label?, href? } \| null` | `null` (footer hidden) | Footer attribution. `null` hides it; `{ label, href? }` renders the label (as an anchor when `href` is set). Merge-updated. |
342
+ | `builderCode` | `{ wallet?, service? } \| null` | `null` (no echo) | ERC-8021 self-attribution, echoed back only when the `402` declares a builder code. Codes must match `^[a-z0-9_]{1,32}$`. |
343
+ | `solanaWeb3Url` | `string` | `esm.sh/@solana/web3.js@1.95.3` | CDN module dynamic-imported on the Solana path. Repoint to self-host under a strict CSP. |
344
+ | `nobleHashesUrl` | `string` | `esm.sh/@noble/hashes@1.4.0/sha3` | CDN keccak module, used only for EVM SIWX sign-in. |
345
+
346
+ `configure()` merges: `configure({ brand: { label: 'X' } })` keeps the existing
347
+ `href`. Pass `apiOrigin: null` to reset to script-origin resolution; `brand:
348
+ null` to hide the footer; `builderCode: null` to switch the echo off.
194
349
 
195
350
  ```js
196
- configure({
197
- // Origin serving the Solana prepare/encode checkout helpers. Only the Solana
198
- // path uses it; the EVM path needs no backend. null → resolve from the
199
- // script's own origin; '' → same-origin.
200
- apiOrigin: 'https://pay.example.com',
351
+ import { configure } from '@three-ws/x402-modal';
201
352
 
202
- // Footer attribution.
353
+ configure({
354
+ apiOrigin: 'https://pay.example.com', // Solana backend
203
355
  brand: { label: 'Powered by Acme', href: 'https://acme.com' },
204
-
205
- // ERC-8021 builder-code self-attribution, echoed back only when the 402
206
- // challenge declares a builder code. null disables the echo.
207
356
  builderCode: { wallet: 'acme', service: 'acme_checkout' },
208
-
209
- // Override the on-demand CDN modules (e.g. to self-host under a strict CSP).
210
- solanaWeb3Url: 'https://esm.sh/@solana/web3.js@1.95.3?bundle',
211
- nobleHashesUrl: 'https://esm.sh/@noble/hashes@1.4.0/sha3?bundle',
212
357
  });
213
358
  ```
214
359
 
360
+ ### `data-x402-*` attributes
361
+
362
+ **On the `/global` `<script>` tag** (global config):
363
+
364
+ | attribute | maps to |
365
+ |------------------------------|-------------------------------------------|
366
+ | `data-x402-api-origin` | `apiOrigin` |
367
+ | `data-x402-brand-label` | `brand.label` |
368
+ | `data-x402-brand-href` | `brand.href` |
369
+ | `data-x402-builder-wallet` | `builderCode.wallet` |
370
+ | `data-x402-builder-service` | `builderCode.service` |
371
+ | `data-x402-builder-disable` | `builderCode = null` (presence or `"true"`) |
372
+ | `data-x402-solana-web3-url` | `solanaWeb3Url` |
373
+ | `data-x402-noble-hashes-url` | `nobleHashesUrl` |
374
+
375
+ **On a clickable element** (per-button `pay()` options):
376
+
377
+ | attribute | type | default | description |
378
+ |------------------------|--------|---------------------------|-------------|
379
+ | `data-x402-endpoint` | string | — (**required** to bind) | the x402-protected URL |
380
+ | `data-x402-method` | string | `GET`, or `POST` if a body is set | HTTP method |
381
+ | `data-x402-body` | JSON | — | request body (parsed as JSON; falls back to raw string) |
382
+ | `data-x402-headers` | JSON | — | extra request headers |
383
+ | `data-x402-caps` | JSON | — | spending caps (see below) |
384
+ | `data-x402-api-origin` | string | global config | per-button Solana backend override |
385
+ | `data-x402-merchant` | string | header default | modal header line 1 |
386
+ | `data-x402-action` | string | the element's text | modal header line 2 |
387
+
215
388
  ### Spending caps
216
389
 
217
- Caps are enforced in `localStorage`, bucketed by rolling UTC hour and day, and
218
- survive reloads. Amounts are **micro-USD** (`1_000_000` = `$1`). A failed payment
219
- rolls its reservation back.
390
+ Caps are enforced in `localStorage`, tracked per wallet address, bucketed by
391
+ rolling UTC hour and day, and survive reloads. Amounts are **micro-USD**
392
+ (`1_000_000` = `$1`). A breach is rejected **before** the wallet prompt; a
393
+ downstream failure rolls the reservation back.
220
394
 
221
395
  ```js
222
396
  await pay({
@@ -229,78 +403,270 @@ await pay({
229
403
  });
230
404
  ```
231
405
 
406
+ ```ts
407
+ interface SpendingCaps {
408
+ maxPerCall?: number | string;
409
+ maxPerHour?: number | string;
410
+ maxPerDay?: number | string;
411
+ }
412
+ ```
413
+
232
414
  Stablecoins (USDC, USDT, DAI) are converted to µUSD exactly. Non-stable assets
233
- pass through atomic in the browser (no price feed is fetched to keep the script
234
- dependency-free) — enforce those server-side.
415
+ pass through atomic in the browser no price feed is fetched, to keep the script
416
+ dependency-free — so **enforce those server-side**.
235
417
 
236
- ## The backend
418
+ ---
237
419
 
238
- **EVM / Base needs no backend.** The payer signs an EIP-3009
239
- `transferWithAuthorization` in their wallet and the modal sends the signed
240
- authorization straight to your merchant endpoint as `X-PAYMENT`. Your x402
241
- server (and its facilitator) verify and settle it.
420
+ ## Framework guides
242
421
 
243
- **Solana needs one tiny helper**, because building a Solana transfer transaction
244
- requires RPC access and the facilitator's fee-payer. The modal expects two
245
- actions at `{apiOrigin}/api/x402-checkout`:
422
+ ### Vanilla HTML (declarative)
246
423
 
247
- | action | request | response |
248
- |---|---|---|
249
- | `?action=prepare` | `{ accept, buyer }` | `{ tx_base64 }` — an unsigned/partially-signed `VersionedTransaction` |
250
- | `?action=encode` | `{ accept, signed_tx_base64, resource_url, builder_code? }` | `{ x_payment }` — the base64 `X-PAYMENT` value to send to the merchant |
424
+ ```html
425
+ <script type="module" src="https://unpkg.com/@three-ws/x402-modal/global"></script>
251
426
 
252
- `apiOrigin` defaults to the origin that served the script, so when you self-host
253
- both the script and this helper there is nothing to configure. See
254
- [`docs/BACKEND.md`](./docs/BACKEND.md) for the full contract and a reference
255
- implementation, and [`examples/`](./examples) for runnable code.
427
+ <button
428
+ data-x402-endpoint="/api/paid/summarize"
429
+ data-x402-method="POST"
430
+ data-x402-body='{"text":"hello"}'
431
+ data-x402-merchant="Acme"
432
+ data-x402-action="Summarize">
433
+ Pay &amp; summarize
434
+ </button>
256
435
 
257
- ## Install
436
+ <script type="module">
437
+ document.querySelector('button')
438
+ .addEventListener('x402:result', (e) => console.log(e.detail.result));
439
+ </script>
440
+ ```
258
441
 
259
- ```sh
260
- npm i @three-ws/x402-modal
442
+ ### React
443
+
444
+ `pay()` is just a promise — call it from any handler. No provider, no context.
445
+
446
+ ```jsx
447
+ import { useState, useCallback } from 'react';
448
+ import { pay } from '@three-ws/x402-modal';
449
+
450
+ export function PayButton() {
451
+ const [out, setOut] = useState(null);
452
+ const [error, setError] = useState(null);
453
+
454
+ const onPay = useCallback(async () => {
455
+ setError(null);
456
+ try {
457
+ const res = await pay({
458
+ endpoint: '/api/paid/summarize',
459
+ method: 'POST',
460
+ body: { text: 'hello world' },
461
+ merchant: 'Acme',
462
+ action: 'Summarize',
463
+ });
464
+ setOut(res.result);
465
+ } catch (err) {
466
+ if (err.code === 'cancelled') return; // user closed the modal
467
+ setError(err.message);
468
+ }
469
+ }, []);
470
+
471
+ return (
472
+ <>
473
+ <button onClick={onPay}>Pay &amp; summarize</button>
474
+ {error && <p role="alert">{error}</p>}
475
+ {out && <pre>{JSON.stringify(out, null, 2)}</pre>}
476
+ </>
477
+ );
478
+ }
261
479
  ```
262
480
 
481
+ Set global config once in your app entry (e.g. `main.jsx`):
482
+
263
483
  ```js
264
- import { pay, configure } from '@three-ws/x402-modal'; // ESM, no side effects
484
+ import { configure } from '@three-ws/x402-modal';
485
+ configure({ brand: { label: 'Powered by Acme', href: 'https://acme.com' } });
486
+ ```
487
+
488
+ The same pattern works in Vue, Svelte, Solid, or any framework — import `pay`,
489
+ call it from an event handler, await the result.
490
+
491
+ ### `<script>`-only CDN (no build, no npm)
492
+
493
+ Use `window.X402` directly. Nothing to install:
494
+
495
+ ```html
496
+ <button id="buy">Buy article — $0.05</button>
497
+ <article id="content" hidden></article>
498
+
499
+ <script type="module" src="https://unpkg.com/@three-ws/x402-modal/global"></script>
500
+ <script>
501
+ document.getElementById('buy').addEventListener('click', async () => {
502
+ try {
503
+ const out = await window.X402.pay({
504
+ endpoint: '/api/article/42',
505
+ merchant: 'The Daily',
506
+ action: 'Unlock article',
507
+ });
508
+ const el = document.getElementById('content');
509
+ el.textContent = out.result.body;
510
+ el.hidden = false;
511
+ } catch (err) {
512
+ if (err.code !== 'cancelled') alert(err.message);
513
+ }
514
+ });
515
+ </script>
265
516
  ```
266
517
 
267
- or skip the install entirely and use the CDN `/global` build (auto-binds
268
- `[data-x402-endpoint]`, exposes `window.X402`).
518
+ `window.X402` exposes `{ pay, init, configure, version }`.
519
+
520
+ More end-to-end walkthroughs — content paywall, SPA checkout, self-hosting,
521
+ agents with caps — live in [`docs/EXAMPLES.md`](./docs/EXAMPLES.md) and
522
+ [`TUTORIAL.md`](./TUTORIAL.md), with runnable code in [`examples/`](./examples).
523
+
524
+ ---
525
+
526
+ ## Wallets & assets
527
+
528
+ | Network | Wallet | Scheme | Backend? |
529
+ |--------------------------------------|-------------------------------------|-----------------------------------------|----------|
530
+ | **Base** (`eip155:8453`) | MetaMask / any `window.ethereum` | EIP-3009 `transferWithAuthorization` | **No** — fully client-side |
531
+ | Base Sepolia, Arbitrum, Optimism | same | EIP-3009 | No |
532
+ | **Solana** (`solana:*`) | Phantom | `exact` (facilitator-settled) | Yes — `prepare`/`encode` helper |
533
+
534
+ - **USDC is the default asset.** Base USDC is signed via EIP-3009 (the domain
535
+ version is pinned to the on-chain separator). Solana USDC settles through the
536
+ facilitator.
537
+ - **Arbitrary SPL tokens** are supported on the Solana path — the `402`'s
538
+ `accept.asset` is the SPL mint, and `accept.extra` carries `name` / `decimals`.
539
+ Only stablecoins map exactly to µUSD for caps; cap non-stable assets
540
+ server-side.
541
+ - **No wallet adapter or WalletConnect needed.** Solana uses the injected
542
+ Phantom provider; EVM uses the injected `window.ethereum`.
543
+ - **The payer never pays gas.** On EVM, EIP-3009 is a gasless signed
544
+ authorization the facilitator submits. On Solana the facilitator is the
545
+ fee-payer.
546
+
547
+ ### The Solana backend
548
+
549
+ EVM/Base needs no backend. **Solana needs one tiny helper**, because a browser
550
+ wallet *signs* transactions but does not *build* them (building needs RPC access
551
+ and the facilitator's fee-payer). The modal POSTs to two actions at
552
+ `{apiOrigin}/api/x402-checkout`:
553
+
554
+ | action | request | response |
555
+ |-------------------|-------------------------------------------------------------|----------|
556
+ | `?action=prepare` | `{ accept, buyer }` | `{ tx_base64 }` — an unsigned/partially-signed `VersionedTransaction` |
557
+ | `?action=encode` | `{ accept, signed_tx_base64, resource_url, builder_code? }` | `{ x_payment }` — the base64 `X-PAYMENT` value to send to the merchant |
558
+
559
+ `apiOrigin` defaults to the origin that served the script, so when you self-host
560
+ both the script and this helper there is nothing to configure. The full contract,
561
+ a reference implementation, and a hardening checklist are in
562
+ [`docs/BACKEND.md`](./docs/BACKEND.md); runnable code is in
563
+ [`examples/server.mjs`](./examples/server.mjs).
269
564
 
270
- ## Security notes
565
+ ---
566
+
567
+ ## Theming & styling hooks
568
+
569
+ The modal injects one self-contained stylesheet (`#x402-styles`) scoped to
570
+ `.x402-*` classes, with full `prefers-color-scheme` light/dark support and a
571
+ `prefers-reduced-motion`-friendly entrance animation. To restyle, override those
572
+ classes after the script loads:
573
+
574
+ ```css
575
+ .x402-modal { border-radius: 8px; } /* the card */
576
+ .x402-pay-btn { background: #6d28d9; } /* the primary CTA */
577
+ .x402-wallet-btn:hover:not(:disabled) { border-color: #6d28d9; }
578
+ .x402-price { letter-spacing: -0.02em; } /* the price line */
579
+ ```
580
+
581
+ Key hooks: `.x402-overlay`, `.x402-modal`, `.x402-head`, `.x402-merchant`,
582
+ `.x402-price-row`, `.x402-network`, `.x402-step` (`.x402-active` / `.x402-done` /
583
+ `.x402-error`), `.x402-wallet-btn`, `.x402-pay-btn`, `.x402-close`. The two
584
+ header lines are set by `merchant` / `action`; the footer text/link by `brand`.
585
+
586
+ ---
271
587
 
272
- - The modal **never holds keys**. Signing happens in the user's wallet; the
588
+ ## Security
589
+
590
+ - **The modal never holds keys.** Signing happens in the user's wallet; the
273
591
  signed payload goes to your endpoint.
274
- - A `429` from the merchant is retried with the *same* signed payment — safe,
275
- because x402 settles only after the work succeeds.
276
- - Upstream throttle/billing text is never relayed to the buyer verbatim.
277
- - All endpoint-supplied strings are HTML-escaped before rendering.
278
- - For the Solana path, the dynamic CDN import can be blocked by a strict
279
- Content-Security-Policy; either allow it, repoint it via `solanaWeb3Url`, or
280
- steer users to the dependency-free Base path.
592
+ - **Client-side spending caps** are advisory guardrails enforced in
593
+ `localStorage` they stop *this browser* from overspending and survive
594
+ reloads, but a determined user can clear storage. Treat them as a UX guardrail
595
+ for agents and humans, and enforce authoritative limits server-side.
596
+ - **Idempotent retries.** A `429` from the merchant is retried with the *same*
597
+ signed payment safe, because x402 settles only after the work succeeds.
598
+ - **No leakage.** Upstream throttle/billing text is never relayed to the buyer
599
+ verbatim; all endpoint-supplied strings are HTML-escaped before rendering.
600
+ - **CSP note.** On the Solana path the dynamic CDN import can be blocked by a
601
+ strict Content-Security-Policy; either allow it, repoint it via `solanaWeb3Url`
602
+ / `nobleHashesUrl`, or steer users to the dependency-free Base path.
603
+
604
+ ---
605
+
606
+ ## FAQ & troubleshooting
281
607
 
282
- ## FAQ
608
+ **The modal opens but immediately errors with "Endpoint returned … but no
609
+ `accepts`".** Your endpoint isn't returning a valid x402 `402`. The modal expects
610
+ a `402` with a JSON body containing a non-empty `accepts[]` array (or a `401`
611
+ with a base64-JSON `payment-required` header). Pointing the modal at a free `200`
612
+ endpoint is treated as an error on purpose.
283
613
 
284
614
  **Do I need a wallet adapter / WalletConnect?** No. Solana uses the injected
285
- Phantom provider; EVM uses the injected `window.ethereum`.
615
+ Phantom provider; EVM uses the injected `window.ethereum`. If neither is present,
616
+ the modal tells the user to install a wallet.
286
617
 
287
- **Does the payer pay gas?** On EVM, no — EIP-3009 is a gasless signed
618
+ **Does the payer pay gas?** No. On EVM, EIP-3009 is a gasless signed
288
619
  authorization your facilitator submits. On Solana the facilitator is the
289
620
  fee-payer.
290
621
 
291
- **Can I theme it?** It ships a self-contained stylesheet with light/dark
292
- (`prefers-color-scheme`) support. Override the `.x402-*` classes, or set
293
- `brand` for the footer. The header reflects `merchant` / `action`.
622
+ **My Solana payment fails but Base works.** Base is fully client-side; Solana
623
+ needs the `prepare`/`encode` backend helper. Confirm `apiOrigin` resolves to a
624
+ host serving `/api/x402-checkout` (see [`docs/BACKEND.md`](./docs/BACKEND.md)),
625
+ and that a strict CSP isn't blocking the `@solana/web3.js` CDN import — repoint
626
+ it with `solanaWeb3Url` if so.
627
+
628
+ **`pay()` rejected — how do I tell a cancel from a real error?** A user-closed
629
+ modal rejects with `err.code === 'cancelled'`; everything else is a genuine
630
+ failure. The declarative path never fires `x402:error` on a cancel.
631
+
632
+ **A payment got rejected before the wallet even opened.** A spending cap was hit.
633
+ Caps are per-wallet in `localStorage`, bucketed by UTC hour/day. Raise the cap,
634
+ or clear the bucket by waiting out the window.
635
+
636
+ **Can I theme it / does it support dark mode?** Yes — see
637
+ [Theming](#theming--styling-hooks). It honors `prefers-color-scheme` out of the
638
+ box and exposes `.x402-*` classes for overrides.
639
+
640
+ **Does it work in React/Vue/Svelte?** Yes — it's framework-agnostic. Import
641
+ `pay()` and call it from a handler, or drop the `/global` script and use `data-*`
642
+ buttons. See [Framework guides](#framework-guides).
294
643
 
295
- **Framework support?** It's framework-agnostic. Import `pay()` and call it from
296
- a React/Vue/Svelte handler, or drop the `/global` script and use `data-*`
297
- buttons.
644
+ ---
645
+
646
+ ## Related packages
647
+
648
+ - **[`@three-ws/x402-payment-modal`](https://www.npmjs.com/package/@three-ws/x402-payment-modal)**
649
+ — a sibling package. If you arrived looking for that one, note the difference:
650
+ this package (`@three-ws/x402-modal`) is the **dependency-free, bundler-free
651
+ drop-in** focused on the smallest possible client (one `<script>` tag,
652
+ `window.X402`, `data-*` binding). Pick `x402-modal` when you want a CDN drop-in
653
+ with zero install.
654
+ - **[x402 spec](https://x402.org)** — the open `402 Payment Required` payment
655
+ protocol this modal implements.
656
+
657
+ ---
658
+
659
+ ## Documentation
298
660
 
299
- **Where does this run in production?** This is the same modal that powers
300
- payments on [three.ws](https://three.ws); the package is its standalone,
301
- configurable home.
661
+ - [`docs/PROTOCOL.md`](./docs/PROTOCOL.md) the four-step flow in detail.
662
+ - [`docs/CONFIGURATION.md`](./docs/CONFIGURATION.md) every option and attribute.
663
+ - [`docs/BACKEND.md`](./docs/BACKEND.md) — the Solana `prepare`/`encode` helper.
664
+ - [`docs/EXAMPLES.md`](./docs/EXAMPLES.md) — runnable recipes per framework.
665
+ - [`TUTORIAL.md`](./TUTORIAL.md) — hands-on, copy-paste walkthroughs.
666
+ - [`CONTRIBUTING.md`](./CONTRIBUTING.md) — build, test, and release.
667
+
668
+ ---
302
669
 
303
670
  ## License
304
671
 
305
- [Apache-2.0](./LICENSE) © three.ws. Part of the [three.ws](https://three.ws)
306
- platform for building, animating, rigging, and monetizing 3D AI agents.
672
+ Proprietary — Copyright (c) 2026 nirholas. All Rights Reserved. Unauthorized use, copying, modification, or distribution is prohibited. See [LICENSE](./LICENSE).