@three-ws/x402-modal 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +180 -0
- package/README.md +306 -0
- package/TUTORIAL.md +281 -0
- package/dist/x402-modal.mjs +1371 -0
- package/dist/x402-modal.mjs.map +7 -0
- package/dist/x402.global.js +353 -0
- package/dist/x402.global.js.map +7 -0
- package/docs/BACKEND.md +163 -0
- package/docs/CONFIGURATION.md +106 -0
- package/docs/PROTOCOL.md +102 -0
- package/package.json +75 -0
- package/src/global.js +64 -0
- package/src/util.js +147 -0
- package/src/x402-modal.js +1446 -0
- package/types/index.d.ts +149 -0
package/TUTORIAL.md
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# Tutorials — @three-ws/x402-modal
|
|
2
|
+
|
|
3
|
+
Hands-on, copy-paste walkthroughs. Each one is self-contained and ends with
|
|
4
|
+
something running. Pick the path that matches your stack:
|
|
5
|
+
|
|
6
|
+
1. [Tutorial 1 — Sell an API call in 5 minutes (Base, no backend)](#tutorial-1--sell-an-api-call-in-5-minutes-base-no-backend)
|
|
7
|
+
2. [Tutorial 2 — Programmatic checkout in a SPA](#tutorial-2--programmatic-checkout-in-a-spa)
|
|
8
|
+
3. [Tutorial 3 — A content paywall](#tutorial-3--a-content-paywall)
|
|
9
|
+
4. [Tutorial 4 — Self-hosting & full branding](#tutorial-4--self-hosting--full-branding)
|
|
10
|
+
5. [Tutorial 5 — Adding the Solana path (the backend helper)](#tutorial-5--adding-the-solana-path-the-backend-helper)
|
|
11
|
+
6. [Tutorial 6 — Spending caps for autonomous agents](#tutorial-6--spending-caps-for-autonomous-agents)
|
|
12
|
+
7. [Troubleshooting](#troubleshooting)
|
|
13
|
+
|
|
14
|
+
Every tutorial assumes you already have — or are about to build — an x402 server
|
|
15
|
+
that answers a protected route with `402 Payment Required`. If you don't, skim
|
|
16
|
+
[the backend doc](./docs/BACKEND.md) first; it shows the minimal server side.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Tutorial 1 — Sell an API call in 5 minutes (Base, no backend)
|
|
21
|
+
|
|
22
|
+
**Goal:** charge USDC on Base for one API call, with zero backend beyond your
|
|
23
|
+
existing x402 server. The Base path is fully client-side.
|
|
24
|
+
|
|
25
|
+
### 1. Have an endpoint that returns 402
|
|
26
|
+
|
|
27
|
+
Your server should respond to an unpaid request with `402` and an `accepts`
|
|
28
|
+
array describing a Base USDC payment. A minimal challenge body looks like:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"x402Version": 2,
|
|
33
|
+
"accepts": [{
|
|
34
|
+
"scheme": "exact",
|
|
35
|
+
"network": "eip155:8453",
|
|
36
|
+
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
37
|
+
"payTo": "0xYourMerchantWallet",
|
|
38
|
+
"amount": "10000",
|
|
39
|
+
"maxTimeoutSeconds": 600,
|
|
40
|
+
"extra": { "name": "USDC", "version": "2", "decimals": 6 }
|
|
41
|
+
}]
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
(`amount` is atomic — `10000` = `$0.01` at 6 decimals.)
|
|
46
|
+
|
|
47
|
+
### 2. Add the modal to your page
|
|
48
|
+
|
|
49
|
+
```html
|
|
50
|
+
<script type="module" src="https://unpkg.com/@three-ws/x402-modal/global"></script>
|
|
51
|
+
|
|
52
|
+
<button
|
|
53
|
+
data-x402-endpoint="https://api.acme.com/paid/summarize"
|
|
54
|
+
data-x402-method="POST"
|
|
55
|
+
data-x402-body='{"text":"Long article text here..."}'
|
|
56
|
+
data-x402-merchant="Acme AI"
|
|
57
|
+
data-x402-action="Summarize article">
|
|
58
|
+
Summarize for $0.01
|
|
59
|
+
</button>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### 3. Handle the result
|
|
63
|
+
|
|
64
|
+
```html
|
|
65
|
+
<script>
|
|
66
|
+
document.querySelector('button').addEventListener('x402:result', (e) => {
|
|
67
|
+
const { result, payment } = e.detail;
|
|
68
|
+
document.querySelector('#out').textContent = JSON.stringify(result, null, 2);
|
|
69
|
+
console.log('settled tx:', payment.transaction);
|
|
70
|
+
});
|
|
71
|
+
document.querySelector('button').addEventListener('x402:error', (e) => {
|
|
72
|
+
alert('Payment failed: ' + e.detail.error);
|
|
73
|
+
});
|
|
74
|
+
</script>
|
|
75
|
+
<pre id="out"></pre>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
That's the whole integration. Click → MetaMask signs an EIP-3009 authorization
|
|
79
|
+
(no gas, no on-chain tx for the user) → your endpoint runs and settles → the
|
|
80
|
+
result renders.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Tutorial 2 — Programmatic checkout in a SPA
|
|
85
|
+
|
|
86
|
+
**Goal:** trigger checkout from your own button in React/Vue/Svelte/vanilla and
|
|
87
|
+
get the result back as a promise.
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
npm i @three-ws/x402-modal
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```jsx
|
|
94
|
+
// React example
|
|
95
|
+
import { pay } from '@three-ws/x402-modal';
|
|
96
|
+
|
|
97
|
+
function BuyButton({ text }) {
|
|
98
|
+
const [out, setOut] = useState(null);
|
|
99
|
+
const [err, setErr] = useState(null);
|
|
100
|
+
|
|
101
|
+
async function onClick() {
|
|
102
|
+
setErr(null);
|
|
103
|
+
try {
|
|
104
|
+
const res = await pay({
|
|
105
|
+
endpoint: '/api/paid/summarize',
|
|
106
|
+
method: 'POST',
|
|
107
|
+
body: { text },
|
|
108
|
+
merchant: 'Acme AI',
|
|
109
|
+
action: 'Summarize',
|
|
110
|
+
});
|
|
111
|
+
setOut(res.result);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
if (e.code === 'cancelled') return; // user closed the modal — not an error
|
|
114
|
+
setErr(e.message);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<>
|
|
120
|
+
<button onClick={onClick}>Summarize for $0.01</button>
|
|
121
|
+
{err && <p className="error">{err}</p>}
|
|
122
|
+
{out && <pre>{JSON.stringify(out, null, 2)}</pre>}
|
|
123
|
+
</>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The ESM import has **no side effects** — it won't scan the DOM or touch
|
|
129
|
+
`window`. You control exactly when the modal opens.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Tutorial 3 — A content paywall
|
|
134
|
+
|
|
135
|
+
**Goal:** blur premium content until the visitor pays, then reveal it.
|
|
136
|
+
|
|
137
|
+
```html
|
|
138
|
+
<script type="module" src="https://unpkg.com/@three-ws/x402-modal/global"></script>
|
|
139
|
+
|
|
140
|
+
<article id="premium" class="locked">
|
|
141
|
+
<div class="blurred">…premium article body…</div>
|
|
142
|
+
<button
|
|
143
|
+
data-x402-endpoint="https://api.acme.com/paid/article/42"
|
|
144
|
+
data-x402-merchant="Acme Times"
|
|
145
|
+
data-x402-action="Unlock this article">
|
|
146
|
+
Unlock — $0.25
|
|
147
|
+
</button>
|
|
148
|
+
</article>
|
|
149
|
+
|
|
150
|
+
<style>
|
|
151
|
+
.locked .blurred { filter: blur(8px); pointer-events: none; user-select: none; }
|
|
152
|
+
.unlocked .blurred { filter: none; }
|
|
153
|
+
</style>
|
|
154
|
+
|
|
155
|
+
<script>
|
|
156
|
+
const article = document.getElementById('premium');
|
|
157
|
+
article.querySelector('button').addEventListener('x402:result', (e) => {
|
|
158
|
+
// Put the unlocked content from the endpoint into the page, then reveal.
|
|
159
|
+
article.querySelector('.blurred').innerHTML = e.detail.result.html;
|
|
160
|
+
article.classList.replace('locked', 'unlocked');
|
|
161
|
+
// Remember it so a refresh doesn't re-lock (optional).
|
|
162
|
+
localStorage.setItem('unlocked:article:42', '1');
|
|
163
|
+
});
|
|
164
|
+
</script>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
To skip the paywall for returning buyers, check your flag on load — and if your
|
|
168
|
+
server supports **SIWX** (sign-in-with-x), the modal will automatically offer
|
|
169
|
+
"Already paid? Sign in" so they re-enter by signing instead of paying again.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Tutorial 4 — Self-hosting & full branding
|
|
174
|
+
|
|
175
|
+
**Goal:** serve the script yourself, point the Solana backend at your own
|
|
176
|
+
domain, and replace the footer attribution — without writing JS.
|
|
177
|
+
|
|
178
|
+
```html
|
|
179
|
+
<script
|
|
180
|
+
type="module"
|
|
181
|
+
src="https://cdn.acme.com/x402.global.js"
|
|
182
|
+
data-x402-api-origin="https://pay.acme.com"
|
|
183
|
+
data-x402-brand-label="Powered by Acme Pay"
|
|
184
|
+
data-x402-brand-href="https://acme.com/pay"
|
|
185
|
+
data-x402-builder-wallet="acme"
|
|
186
|
+
data-x402-builder-service="acme_checkout"></script>
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Grab `x402.global.js` from the package's `dist/` (or build it with
|
|
190
|
+
`npm run build`) and host it anywhere static.
|
|
191
|
+
|
|
192
|
+
Prefer JS? Configure before the first payment:
|
|
193
|
+
|
|
194
|
+
```js
|
|
195
|
+
import { configure } from '@three-ws/x402-modal';
|
|
196
|
+
|
|
197
|
+
configure({
|
|
198
|
+
apiOrigin: 'https://pay.acme.com',
|
|
199
|
+
brand: { label: 'Powered by Acme Pay', href: 'https://acme.com/pay' },
|
|
200
|
+
builderCode: { wallet: 'acme', service: 'acme_checkout' },
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
To drop the footer link entirely, set `data-x402-builder-disable` and a
|
|
205
|
+
`brand` with no `href`.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## Tutorial 5 — Adding the Solana path (the backend helper)
|
|
210
|
+
|
|
211
|
+
**Goal:** accept USDC on Solana too. This is the only path that needs a backend,
|
|
212
|
+
because building a Solana transfer needs RPC + the facilitator fee-payer.
|
|
213
|
+
|
|
214
|
+
The modal calls two actions on `{apiOrigin}/api/x402-checkout`:
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
POST /api/x402-checkout?action=prepare
|
|
218
|
+
body: { accept, buyer }
|
|
219
|
+
→ { tx_base64 } # a VersionedTransaction the buyer will sign
|
|
220
|
+
|
|
221
|
+
POST /api/x402-checkout?action=encode
|
|
222
|
+
body: { accept, signed_tx_base64, resource_url, builder_code? }
|
|
223
|
+
→ { x_payment } # the base64 X-PAYMENT value to send to the merchant
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
A reference Express handler that delegates to an x402 facilitator SDK lives in
|
|
227
|
+
[`examples/server.mjs`](./examples/server.mjs), and the exact field contract is
|
|
228
|
+
in [`docs/BACKEND.md`](./docs/BACKEND.md).
|
|
229
|
+
|
|
230
|
+
Once the helper is live, no client change is needed beyond pointing `apiOrigin`
|
|
231
|
+
at it (Tutorial 4). When your `402` advertises **both** a Solana and a Base
|
|
232
|
+
`accept`, the modal shows a wallet picker; when it advertises one, it goes
|
|
233
|
+
straight there.
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Tutorial 6 — Spending caps for autonomous agents
|
|
238
|
+
|
|
239
|
+
**Goal:** let an agent (or a kiosk, or a shared browser) pay automatically, but
|
|
240
|
+
cap how much it can spend per call / hour / day.
|
|
241
|
+
|
|
242
|
+
```js
|
|
243
|
+
import { pay } from '@three-ws/x402-modal';
|
|
244
|
+
|
|
245
|
+
await pay({
|
|
246
|
+
endpoint: '/api/paid/inference',
|
|
247
|
+
body: { prompt },
|
|
248
|
+
autoConnect: true, // skip the picker when one wallet is present
|
|
249
|
+
caps: {
|
|
250
|
+
maxPerCall: 500_000, // $0.50 max for any single call
|
|
251
|
+
maxPerHour: 5_000_000, // $5.00/hour
|
|
252
|
+
maxPerDay: 20_000_000, // $20.00/day
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Amounts are **micro-USD** (`1_000_000` = `$1`). Caps are tracked per wallet
|
|
258
|
+
address in `localStorage`, bucketed by rolling UTC hour and day, and survive
|
|
259
|
+
page reloads. A call that would breach a cap is rejected *before* the wallet
|
|
260
|
+
prompt with a clear reason; a payment that fails downstream rolls its
|
|
261
|
+
reservation back so it doesn't count against the budget.
|
|
262
|
+
|
|
263
|
+
> Caps are a client-side guardrail, not a trust boundary. For hard limits an
|
|
264
|
+
> agent can't bypass, also enforce server-side.
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Troubleshooting
|
|
269
|
+
|
|
270
|
+
| Symptom | Cause / fix |
|
|
271
|
+
|---|---|
|
|
272
|
+
| **"Endpoint did not return 402 (got 200)"** | The modal was pointed at a free/unprotected URL, or your server didn't send a `402`. Confirm the route is actually gated. |
|
|
273
|
+
| **"no `accepts` array could be found"** | Your `402` body (or `payment-required` header) is missing the `accepts` array. See the challenge shape in Tutorial 1. |
|
|
274
|
+
| **"NaN USDC" in the price** | Your `accept` used a field other than `amount`/`maxAmountRequired`. The modal normalizes both; anything else needs mapping. |
|
|
275
|
+
| **Solana payment errors with "component … was blocked"** | A strict Content-Security-Policy blocked the dynamic `@solana/web3.js` import. Allow the CDN, set `solanaWeb3Url` to a self-hosted copy, or use the Base path (no third-party code). |
|
|
276
|
+
| **EVM signature rejected by the facilitator** | Check the USDC `extra.version` in your `accept` matches the deployed contract (Base USDC is `"2"`), and that `payTo`/`asset` are correct. |
|
|
277
|
+
| **Modal opens then immediately shows the picker disabled** | No supported wallet detected. Install Phantom (Solana) or MetaMask (EVM), or advertise an `accept` for a chain the visitor's wallet supports. |
|
|
278
|
+
| **`pay()` rejected but nothing went wrong** | The user closed the modal: the rejection's `.code === 'cancelled'`. Treat it as a no-op, not an error. |
|
|
279
|
+
|
|
280
|
+
Still stuck? Open an issue at
|
|
281
|
+
<https://github.com/nirholas/three.ws/issues>.
|