@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/CHANGELOG.md +24 -0
- package/CONTRIBUTING.md +92 -0
- package/LICENSE +38 -180
- package/README.md +541 -175
- package/TUTORIAL.md +1 -1
- package/dist/x402-modal.mjs +12 -9
- package/dist/x402-modal.mjs.map +2 -2
- package/dist/x402.global.js +10 -9
- package/dist/x402.global.js.map +2 -2
- package/docs/BACKEND.md +2 -1
- package/docs/CONFIGURATION.md +20 -6
- package/docs/EXAMPLES.md +279 -0
- package/examples/index.html +119 -0
- package/examples/server.mjs +144 -0
- package/package.json +11 -9
- package/src/x402-modal.js +20 -15
- package/types/index.d.ts +7 -3
package/docs/CONFIGURATION.md
CHANGED
|
@@ -6,8 +6,13 @@ Three ways to configure, in increasing specificity (later wins):
|
|
|
6
6
|
2. **`configure({ … })`** — global defaults, set once at startup.
|
|
7
7
|
3. **`pay({ … })` options** — per-call overrides.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
Defaults are vendor-neutral: an un-configured drop-in shows no footer
|
|
10
|
+
attribution and echoes no builder code. Set `brand` and `builderCode` to opt in.
|
|
11
|
+
|
|
12
|
+
> The public API (`pay`, `configure`, `getConfig`, `init`, `bindElement`,
|
|
13
|
+
> `readOptsFrom`, `version`, `CheckoutModal`) is documented in the
|
|
14
|
+
> [README API reference](../README.md#api-reference). The `/global` CDN build
|
|
15
|
+
> additionally exposes `window.X402 = { pay, init, configure, version }`.
|
|
11
16
|
|
|
12
17
|
---
|
|
13
18
|
|
|
@@ -22,14 +27,14 @@ getConfig(); // → fully-resolved snapshot
|
|
|
22
27
|
| field | type | default | purpose |
|
|
23
28
|
|---|---|---|---|
|
|
24
29
|
| `apiOrigin` | `string \| null` | `null` → script origin | Origin of the Solana `prepare`/`encode` helper. `''` = same-origin. Ignored by the EVM path. |
|
|
25
|
-
| `brand` | `{ label?, href? }` | `
|
|
26
|
-
| `builderCode` | `{ wallet?, service? } \| null` | `
|
|
30
|
+
| `brand` | `{ label?, href? } \| null` | `null` (hidden) | Footer attribution. `null` hides the footer link; set `{ label, href? }` to show it. Merge-updated (set only `label` and `href` survives). |
|
|
31
|
+
| `builderCode` | `{ wallet?, service? } \| null` | `null` (no echo) | ERC-8021 self-attribution echoed when the `402` declares a builder code. `null` disables the echo. Codes must match `^[a-z0-9_]{1,32}$`. |
|
|
27
32
|
| `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. |
|
|
28
33
|
| `nobleHashesUrl` | `string` | `esm.sh/@noble/hashes@1.4.0/sha3` | CDN keccak module, used only for EVM SIWX sign-in. |
|
|
29
34
|
|
|
30
35
|
`configure()` merges: `configure({ brand: { label: 'X' } })` keeps the existing
|
|
31
36
|
`href`. Pass `apiOrigin: null` to reset to script-origin resolution; pass
|
|
32
|
-
`builderCode: null` to switch the echo off.
|
|
37
|
+
`brand: null` to hide the footer, or `builderCode: null` to switch the echo off.
|
|
33
38
|
|
|
34
39
|
---
|
|
35
40
|
|
|
@@ -103,4 +108,13 @@ with full `prefers-color-scheme` light/dark support and a `prefers-reduced-motio
|
|
|
103
108
|
```
|
|
104
109
|
|
|
105
110
|
The footer text/link is set by `brand`; the two header lines by
|
|
106
|
-
`merchant` / `action`.
|
|
111
|
+
`merchant` / `action`. The full list of `.x402-*` hooks and a dark-mode note are
|
|
112
|
+
in the [README theming section](../README.md#theming--styling-hooks).
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## See also
|
|
117
|
+
|
|
118
|
+
- [`EXAMPLES.md`](./EXAMPLES.md) — runnable recipes per framework.
|
|
119
|
+
- [`BACKEND.md`](./BACKEND.md) — the Solana `prepare`/`encode` helper contract.
|
|
120
|
+
- [`PROTOCOL.md`](./PROTOCOL.md) — the four-step `402 → sign → settle` flow.
|
package/docs/EXAMPLES.md
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# Examples
|
|
2
|
+
|
|
3
|
+
Runnable recipes for `@three-ws/x402-modal`. Every snippet below is complete and
|
|
4
|
+
copy-pasteable. All you need to supply is your own x402-protected endpoint (one
|
|
5
|
+
that answers with `402 Payment Required` + an `accepts[]` array — see
|
|
6
|
+
[`PROTOCOL.md`](./PROTOCOL.md)).
|
|
7
|
+
|
|
8
|
+
For two fully-wired files you can serve and click, see
|
|
9
|
+
[`../examples/index.html`](../examples/index.html) (the demo page) and
|
|
10
|
+
[`../examples/server.mjs`](../examples/server.mjs) (the Solana backend helper).
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 1 — Declarative button (zero JS, CDN only)
|
|
15
|
+
|
|
16
|
+
The smallest integration. The `/global` script binds the button automatically.
|
|
17
|
+
|
|
18
|
+
```html
|
|
19
|
+
<!doctype html>
|
|
20
|
+
<meta charset="utf-8" />
|
|
21
|
+
|
|
22
|
+
<button
|
|
23
|
+
data-x402-endpoint="https://api.example.com/paid/summarize"
|
|
24
|
+
data-x402-method="POST"
|
|
25
|
+
data-x402-body='{"text":"hello world"}'
|
|
26
|
+
data-x402-merchant="Acme"
|
|
27
|
+
data-x402-action="Summarize">
|
|
28
|
+
Pay & summarize
|
|
29
|
+
</button>
|
|
30
|
+
<pre id="out"></pre>
|
|
31
|
+
|
|
32
|
+
<script type="module" src="https://unpkg.com/@three-ws/x402-modal/global"></script>
|
|
33
|
+
<script type="module">
|
|
34
|
+
const btn = document.querySelector('button');
|
|
35
|
+
btn.addEventListener('x402:result', (e) => {
|
|
36
|
+
document.getElementById('out').textContent =
|
|
37
|
+
JSON.stringify(e.detail.result, null, 2);
|
|
38
|
+
});
|
|
39
|
+
btn.addEventListener('x402:error', (e) => {
|
|
40
|
+
document.getElementById('out').textContent = 'Error: ' + e.detail.error;
|
|
41
|
+
});
|
|
42
|
+
</script>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 2 — Programmatic checkout (`pay()`)
|
|
48
|
+
|
|
49
|
+
Drive the flow from your own handler and await the result.
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
import { pay } from '@three-ws/x402-modal';
|
|
53
|
+
|
|
54
|
+
async function buy() {
|
|
55
|
+
try {
|
|
56
|
+
const out = await pay({
|
|
57
|
+
endpoint: '/api/paid/summarize',
|
|
58
|
+
method: 'POST',
|
|
59
|
+
body: { text: 'hello world' },
|
|
60
|
+
merchant: 'Acme',
|
|
61
|
+
action: 'Summarize',
|
|
62
|
+
});
|
|
63
|
+
console.log('result:', out.result);
|
|
64
|
+
console.log('payment:', out.payment); // { network, payer, transaction }
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err.code === 'cancelled') return; // user closed the modal
|
|
67
|
+
console.error('payment failed:', err.message);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## 3 — Content paywall
|
|
75
|
+
|
|
76
|
+
Unlock content after a single micropayment. CDN-only, no install.
|
|
77
|
+
|
|
78
|
+
```html
|
|
79
|
+
<button id="buy">Unlock article — $0.05</button>
|
|
80
|
+
<article id="content" hidden></article>
|
|
81
|
+
|
|
82
|
+
<script type="module" src="https://unpkg.com/@three-ws/x402-modal/global"></script>
|
|
83
|
+
<script>
|
|
84
|
+
document.getElementById('buy').addEventListener('click', async () => {
|
|
85
|
+
try {
|
|
86
|
+
const out = await window.X402.pay({
|
|
87
|
+
endpoint: '/api/article/42',
|
|
88
|
+
merchant: 'The Daily',
|
|
89
|
+
action: 'Unlock article',
|
|
90
|
+
});
|
|
91
|
+
const el = document.getElementById('content');
|
|
92
|
+
el.textContent = out.result.body;
|
|
93
|
+
el.hidden = false;
|
|
94
|
+
document.getElementById('buy').remove();
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (err.code !== 'cancelled') alert(err.message);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
</script>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The endpoint should return `402` for the first request and the article body once
|
|
103
|
+
the `X-PAYMENT` settles. SIWX re-entry (see below) lets a returning buyer skip
|
|
104
|
+
re-paying.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## 4 — React
|
|
109
|
+
|
|
110
|
+
`pay()` is a plain promise — no provider, no context.
|
|
111
|
+
|
|
112
|
+
```jsx
|
|
113
|
+
import { useState, useCallback } from 'react';
|
|
114
|
+
import { pay } from '@three-ws/x402-modal';
|
|
115
|
+
|
|
116
|
+
export function PayButton({ endpoint }) {
|
|
117
|
+
const [out, setOut] = useState(null);
|
|
118
|
+
const [error, setError] = useState(null);
|
|
119
|
+
|
|
120
|
+
const onPay = useCallback(async () => {
|
|
121
|
+
setError(null);
|
|
122
|
+
try {
|
|
123
|
+
const res = await pay({ endpoint, merchant: 'Acme', action: 'Run' });
|
|
124
|
+
setOut(res.result);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
if (err.code === 'cancelled') return;
|
|
127
|
+
setError(err.message);
|
|
128
|
+
}
|
|
129
|
+
}, [endpoint]);
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<>
|
|
133
|
+
<button onClick={onPay}>Pay & run</button>
|
|
134
|
+
{error && <p role="alert">{error}</p>}
|
|
135
|
+
{out && <pre>{JSON.stringify(out, null, 2)}</pre>}
|
|
136
|
+
</>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Set global config once in your app entry:
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
import { configure } from '@three-ws/x402-modal';
|
|
145
|
+
configure({ brand: { label: 'Powered by Acme', href: 'https://acme.com' } });
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## 5 — Vue 3
|
|
151
|
+
|
|
152
|
+
```vue
|
|
153
|
+
<script setup>
|
|
154
|
+
import { ref } from 'vue';
|
|
155
|
+
import { pay } from '@three-ws/x402-modal';
|
|
156
|
+
|
|
157
|
+
const out = ref(null);
|
|
158
|
+
const error = ref(null);
|
|
159
|
+
|
|
160
|
+
async function onPay() {
|
|
161
|
+
error.value = null;
|
|
162
|
+
try {
|
|
163
|
+
const res = await pay({ endpoint: '/api/paid/run', merchant: 'Acme' });
|
|
164
|
+
out.value = res.result;
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (err.code !== 'cancelled') error.value = err.message;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
</script>
|
|
170
|
+
|
|
171
|
+
<template>
|
|
172
|
+
<button @click="onPay">Pay & run</button>
|
|
173
|
+
<p v-if="error" role="alert">{{ error }}</p>
|
|
174
|
+
<pre v-if="out">{{ out }}</pre>
|
|
175
|
+
</template>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## 6 — Self-hosted & fully branded
|
|
181
|
+
|
|
182
|
+
Repoint the Solana backend and add a footer, all from the script tag — no JS:
|
|
183
|
+
|
|
184
|
+
```html
|
|
185
|
+
<script
|
|
186
|
+
type="module"
|
|
187
|
+
src="https://your.cdn/x402.global.js"
|
|
188
|
+
data-x402-api-origin="https://pay.your-company.com"
|
|
189
|
+
data-x402-brand-label="Powered by Acme"
|
|
190
|
+
data-x402-brand-href="https://acme.com"
|
|
191
|
+
data-x402-builder-wallet="acme"
|
|
192
|
+
data-x402-builder-service="acme_checkout"></script>
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Equivalent from JS, before the first `pay()`:
|
|
196
|
+
|
|
197
|
+
```js
|
|
198
|
+
import { configure } from '@three-ws/x402-modal';
|
|
199
|
+
|
|
200
|
+
configure({
|
|
201
|
+
apiOrigin: 'https://pay.your-company.com',
|
|
202
|
+
brand: { label: 'Powered by Acme', href: 'https://acme.com' },
|
|
203
|
+
builderCode: { wallet: 'acme', service: 'acme_checkout' },
|
|
204
|
+
});
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## 7 — Spending caps for an autonomous agent
|
|
210
|
+
|
|
211
|
+
Cap how much a single browser session can spend, so a misbehaving agent can't
|
|
212
|
+
drain a wallet. Amounts are micro-USD (`1_000_000` = `$1`).
|
|
213
|
+
|
|
214
|
+
```js
|
|
215
|
+
import { pay } from '@three-ws/x402-modal';
|
|
216
|
+
|
|
217
|
+
const out = await pay({
|
|
218
|
+
endpoint: '/api/paid/inference',
|
|
219
|
+
body: { prompt: 'summarize this' },
|
|
220
|
+
autoConnect: true, // skip the picker if exactly one wallet exists
|
|
221
|
+
caps: {
|
|
222
|
+
maxPerCall: 250_000, // $0.25 per call
|
|
223
|
+
maxPerHour: 5_000_000, // $5/hour
|
|
224
|
+
maxPerDay: 20_000_000, // $20/day
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
A breach is rejected **before** the wallet prompt; a downstream failure rolls the
|
|
230
|
+
reservation back. Caps are advisory client-side guardrails — enforce
|
|
231
|
+
authoritative limits server-side too.
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## 8 — Declarative buttons with the ESM build
|
|
236
|
+
|
|
237
|
+
The ESM build is side-effect-free, so it does **not** auto-bind. Call `init()`
|
|
238
|
+
yourself (and again after injecting buttons):
|
|
239
|
+
|
|
240
|
+
```js
|
|
241
|
+
import { init } from '@three-ws/x402-modal';
|
|
242
|
+
|
|
243
|
+
init(); // binds every [data-x402-endpoint] on the page
|
|
244
|
+
|
|
245
|
+
// after dynamically inserting more buttons:
|
|
246
|
+
someContainer.append(newButton);
|
|
247
|
+
init(); // idempotent — re-scans, skips already-bound elements
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## 9 — Handling SIWX re-entry
|
|
253
|
+
|
|
254
|
+
When a server supports Sign-In-With-X, a wallet that has already paid can
|
|
255
|
+
re-enter by signing a challenge instead of paying again. `PayResult.siwx` is
|
|
256
|
+
present in that case, and the declarative path also fires `x402:siwx-signed`.
|
|
257
|
+
|
|
258
|
+
```js
|
|
259
|
+
const out = await pay({ endpoint: '/api/paid/feed' });
|
|
260
|
+
|
|
261
|
+
if (out.siwx) {
|
|
262
|
+
console.log('re-entered via sign-in:', out.siwx.address, out.siwx.network);
|
|
263
|
+
} else {
|
|
264
|
+
console.log('fresh payment:', out.payment.transaction);
|
|
265
|
+
}
|
|
266
|
+
console.log('content:', out.result);
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
```js
|
|
270
|
+
// declarative equivalent
|
|
271
|
+
btn.addEventListener('x402:siwx-signed', (e) =>
|
|
272
|
+
console.log('signed in as', e.detail.address));
|
|
273
|
+
btn.addEventListener('x402:result', (e) => render(e.detail.result));
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
See [`../TUTORIAL.md`](../TUTORIAL.md) for longer end-to-end walkthroughs and
|
|
279
|
+
[`CONFIGURATION.md`](./CONFIGURATION.md) for the full option reference.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>@three-ws/x402-modal — demo</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: light dark; }
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
body {
|
|
11
|
+
margin: 0; font: 16px/1.6 -apple-system, system-ui, sans-serif;
|
|
12
|
+
background: #0b0c10; color: #e9eaf0;
|
|
13
|
+
min-height: 100vh; padding: 64px 24px 120px;
|
|
14
|
+
}
|
|
15
|
+
main { max-width: 680px; margin: 0 auto; }
|
|
16
|
+
h1 { font-size: 36px; line-height: 1.1; margin: 0 0 8px; letter-spacing: -0.02em; }
|
|
17
|
+
.lede { font-size: 18px; color: #aab; margin: 0 0 36px; }
|
|
18
|
+
h2 { font-size: 20px; margin: 40px 0 10px; }
|
|
19
|
+
p { color: #c7c9d6; }
|
|
20
|
+
code { background: #15161e; padding: 2px 6px; border-radius: 6px; font-size: 14px; }
|
|
21
|
+
.card {
|
|
22
|
+
background: #12131b; border: 1px solid #23252f; border-radius: 16px;
|
|
23
|
+
padding: 22px; margin: 16px 0;
|
|
24
|
+
}
|
|
25
|
+
button.cta {
|
|
26
|
+
cursor: pointer; border: none; border-radius: 12px;
|
|
27
|
+
background: linear-gradient(135deg, #0a84ff, #0066d6); color: #fff;
|
|
28
|
+
padding: 13px 20px; font: inherit; font-weight: 700; letter-spacing: -0.01em;
|
|
29
|
+
}
|
|
30
|
+
button.cta:hover { filter: brightness(1.08); }
|
|
31
|
+
pre {
|
|
32
|
+
background: #0e0f16; border: 1px solid #23252f; border-radius: 12px;
|
|
33
|
+
padding: 14px; overflow: auto; font-size: 13px; color: #cfd2e3;
|
|
34
|
+
margin-top: 14px; white-space: pre-wrap; word-break: break-word;
|
|
35
|
+
}
|
|
36
|
+
.muted { color: #7b7f93; font-size: 13px; }
|
|
37
|
+
.err { color: #f87171; }
|
|
38
|
+
</style>
|
|
39
|
+
</head>
|
|
40
|
+
<body>
|
|
41
|
+
<main>
|
|
42
|
+
<h1>@three-ws/x402-modal</h1>
|
|
43
|
+
<p class="lede">A drop-in payment modal for any x402 paid endpoint. Two ways to wire it, both below.</p>
|
|
44
|
+
|
|
45
|
+
<h2>1 — Declarative (<code>data-x402-*</code>)</h2>
|
|
46
|
+
<p>The button below is bound automatically by the global script. Point
|
|
47
|
+
<code>data-x402-endpoint</code> at your own x402 route to try it live.</p>
|
|
48
|
+
<div class="card">
|
|
49
|
+
<button
|
|
50
|
+
class="cta"
|
|
51
|
+
id="declarative"
|
|
52
|
+
data-x402-endpoint="https://api.acme.example/api/paid/summarize"
|
|
53
|
+
data-x402-method="POST"
|
|
54
|
+
data-x402-body='{"text":"hello world"}'
|
|
55
|
+
data-x402-merchant="Acme"
|
|
56
|
+
data-x402-action="Summarize">
|
|
57
|
+
Pay & summarize
|
|
58
|
+
</button>
|
|
59
|
+
<pre id="declarative-out" class="muted">result will appear here…</pre>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<h2>2 — Programmatic (<code>pay()</code>)</h2>
|
|
63
|
+
<p>Call <code>pay()</code> from your own handler and await the result.</p>
|
|
64
|
+
<div class="card">
|
|
65
|
+
<button class="cta" id="programmatic">Pay with pay()</button>
|
|
66
|
+
<pre id="programmatic-out" class="muted">result will appear here…</pre>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<p class="muted">
|
|
70
|
+
This page loads the local build at <code>../dist/x402.global.js</code>.
|
|
71
|
+
Run <code>npm run build</code> in the package root first, then serve this
|
|
72
|
+
folder (e.g. <code>npx serve</code>) and open it. The demo endpoint is a
|
|
73
|
+
placeholder — point it at your own x402 route to try it live.
|
|
74
|
+
</p>
|
|
75
|
+
</main>
|
|
76
|
+
|
|
77
|
+
<!-- Local build. In production, use the CDN:
|
|
78
|
+
<script type="module" src="https://unpkg.com/@three-ws/x402-modal/global"></script> -->
|
|
79
|
+
<script type="module" src="../dist/x402.global.js"></script>
|
|
80
|
+
|
|
81
|
+
<script type="module">
|
|
82
|
+
import { pay } from '../dist/x402-modal.mjs';
|
|
83
|
+
|
|
84
|
+
// Declarative button → listen for the DOM events the global build fires.
|
|
85
|
+
const dEl = document.getElementById('declarative');
|
|
86
|
+
const dOut = document.getElementById('declarative-out');
|
|
87
|
+
dEl.addEventListener('x402:result', (e) => {
|
|
88
|
+
dOut.classList.remove('muted', 'err');
|
|
89
|
+
dOut.textContent = JSON.stringify(e.detail.result, null, 2);
|
|
90
|
+
});
|
|
91
|
+
dEl.addEventListener('x402:error', (e) => {
|
|
92
|
+
dOut.classList.add('err');
|
|
93
|
+
dOut.textContent = 'Error: ' + e.detail.error;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Programmatic button → await pay().
|
|
97
|
+
const pOut = document.getElementById('programmatic-out');
|
|
98
|
+
document.getElementById('programmatic').addEventListener('click', async () => {
|
|
99
|
+
pOut.className = 'muted';
|
|
100
|
+
pOut.textContent = 'opening modal…';
|
|
101
|
+
try {
|
|
102
|
+
const out = await pay({
|
|
103
|
+
endpoint: 'https://api.acme.example/api/paid/summarize',
|
|
104
|
+
method: 'POST',
|
|
105
|
+
body: { text: 'hello world' },
|
|
106
|
+
merchant: 'Acme',
|
|
107
|
+
action: 'Summarize',
|
|
108
|
+
});
|
|
109
|
+
pOut.className = '';
|
|
110
|
+
pOut.textContent = JSON.stringify({ result: out.result, payment: out.payment }, null, 2);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
if (e.code === 'cancelled') { pOut.className = 'muted'; pOut.textContent = 'cancelled.'; return; }
|
|
113
|
+
pOut.className = 'err';
|
|
114
|
+
pOut.textContent = 'Error: ' + e.message;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
</script>
|
|
118
|
+
</body>
|
|
119
|
+
</html>
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// examples/server.mjs — reference Solana checkout helper for @three-ws/x402-modal.
|
|
2
|
+
//
|
|
3
|
+
// The EVM/Base payment path is fully client-side and needs NO backend. This
|
|
4
|
+
// helper exists only for the Solana path, which must build a transfer
|
|
5
|
+
// transaction (RPC + facilitator fee-payer) for Phantom to sign.
|
|
6
|
+
//
|
|
7
|
+
// It implements the two actions the modal calls:
|
|
8
|
+
// POST /api/x402-checkout?action=prepare { accept, buyer }
|
|
9
|
+
// → { network, tx_base64 }
|
|
10
|
+
// POST /api/x402-checkout?action=encode { accept, signed_tx_base64, resource_url, builder_code? }
|
|
11
|
+
// → { x_payment }
|
|
12
|
+
//
|
|
13
|
+
// Run it:
|
|
14
|
+
// npm i @solana/web3.js @solana/spl-token
|
|
15
|
+
// SOLANA_RPC="https://api.mainnet-beta.solana.com" node examples/server.mjs
|
|
16
|
+
//
|
|
17
|
+
// Then point the modal at it:
|
|
18
|
+
// configure({ apiOrigin: 'http://localhost:8787' })
|
|
19
|
+
//
|
|
20
|
+
// In production, validate `accept` against your own catalog (never trust a
|
|
21
|
+
// client-supplied payTo/asset/amount), rate-limit `prepare`, and shape
|
|
22
|
+
// `payload` to whatever your x402 facilitator expects. See ../docs/BACKEND.md.
|
|
23
|
+
|
|
24
|
+
import { createServer } from 'node:http';
|
|
25
|
+
import {
|
|
26
|
+
Connection,
|
|
27
|
+
PublicKey,
|
|
28
|
+
TransactionMessage,
|
|
29
|
+
VersionedTransaction,
|
|
30
|
+
} from '@solana/web3.js';
|
|
31
|
+
import {
|
|
32
|
+
getAssociatedTokenAddressSync,
|
|
33
|
+
createTransferCheckedInstruction,
|
|
34
|
+
} from '@solana/spl-token';
|
|
35
|
+
|
|
36
|
+
const PORT = Number(process.env.PORT || 8787);
|
|
37
|
+
const RPC = process.env.SOLANA_RPC || 'https://api.mainnet-beta.solana.com';
|
|
38
|
+
const connection = new Connection(RPC, 'confirmed');
|
|
39
|
+
|
|
40
|
+
function isSolana(net) {
|
|
41
|
+
return typeof net === 'string' && (net === 'solana' || net.startsWith('solana:'));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function readJson(req) {
|
|
45
|
+
const chunks = [];
|
|
46
|
+
for await (const c of req) chunks.push(c);
|
|
47
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function json(res, status, body) {
|
|
51
|
+
const payload = JSON.stringify(body);
|
|
52
|
+
res.writeHead(status, {
|
|
53
|
+
'content-type': 'application/json',
|
|
54
|
+
// Allow cross-origin use when the script is served from another origin.
|
|
55
|
+
'access-control-allow-origin': '*',
|
|
56
|
+
'access-control-allow-headers': 'content-type',
|
|
57
|
+
'access-control-allow-methods': 'POST, OPTIONS',
|
|
58
|
+
});
|
|
59
|
+
res.end(payload);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function handlePrepare(req, res) {
|
|
63
|
+
const { accept, buyer } = await readJson(req);
|
|
64
|
+
if (!isSolana(accept?.network)) {
|
|
65
|
+
return json(res, 400, {
|
|
66
|
+
error: 'wrong_network',
|
|
67
|
+
error_description: 'prepare only builds Solana transactions; EVM clients sign EIP-3009 locally.',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const mint = new PublicKey(accept.asset);
|
|
72
|
+
const buyerKey = new PublicKey(buyer);
|
|
73
|
+
const payTo = new PublicKey(accept.payTo);
|
|
74
|
+
const feePayer = new PublicKey(accept.extra.feePayer);
|
|
75
|
+
const decimals = Number(accept.extra.decimals ?? 6);
|
|
76
|
+
const amount = BigInt(accept.amount);
|
|
77
|
+
|
|
78
|
+
const fromAta = getAssociatedTokenAddressSync(mint, buyerKey);
|
|
79
|
+
const toAta = getAssociatedTokenAddressSync(mint, payTo);
|
|
80
|
+
|
|
81
|
+
const ix = createTransferCheckedInstruction(
|
|
82
|
+
fromAta, mint, toAta, buyerKey, amount, decimals,
|
|
83
|
+
);
|
|
84
|
+
// Append the x402 reference account your facilitator watches for settlement.
|
|
85
|
+
// Many facilitators expect a read-only reference pubkey on the transfer ix:
|
|
86
|
+
if (accept.extra.reference) {
|
|
87
|
+
ix.keys.push({ pubkey: new PublicKey(accept.extra.reference), isSigner: false, isWritable: false });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const { blockhash } = await connection.getLatestBlockhash('confirmed');
|
|
91
|
+
const message = new TransactionMessage({
|
|
92
|
+
payerKey: feePayer,
|
|
93
|
+
recentBlockhash: blockhash,
|
|
94
|
+
instructions: [ix],
|
|
95
|
+
}).compileToV0Message();
|
|
96
|
+
const tx = new VersionedTransaction(message);
|
|
97
|
+
|
|
98
|
+
return json(res, 200, {
|
|
99
|
+
network: accept.network,
|
|
100
|
+
tx_base64: Buffer.from(tx.serialize()).toString('base64'),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function handleEncode(req, res) {
|
|
105
|
+
const { accept, signed_tx_base64, resource_url, builder_code } = await readJson(req);
|
|
106
|
+
if (!signed_tx_base64 || !resource_url) {
|
|
107
|
+
return json(res, 400, { error: 'bad_request', error_description: 'signed_tx_base64 and resource_url are required' });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Standard Solana exact-scheme payload. Adapt `payload` to your facilitator.
|
|
111
|
+
const paymentPayload = {
|
|
112
|
+
x402Version: 2,
|
|
113
|
+
scheme: 'exact',
|
|
114
|
+
network: accept.network,
|
|
115
|
+
resource: { url: resource_url, mimeType: 'application/json' },
|
|
116
|
+
accepted: accept,
|
|
117
|
+
payload: { transaction: signed_tx_base64 },
|
|
118
|
+
...(builder_code ? { extensions: { 'builder-code': builder_code } } : {}),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
return json(res, 200, {
|
|
122
|
+
x_payment: Buffer.from(JSON.stringify(paymentPayload), 'utf8').toString('base64'),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const server = createServer(async (req, res) => {
|
|
127
|
+
try {
|
|
128
|
+
if (req.method === 'OPTIONS') return json(res, 204, {});
|
|
129
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
130
|
+
if (req.method === 'POST' && url.pathname === '/api/x402-checkout') {
|
|
131
|
+
const action = url.searchParams.get('action');
|
|
132
|
+
if (action === 'prepare') return await handlePrepare(req, res);
|
|
133
|
+
if (action === 'encode') return await handleEncode(req, res);
|
|
134
|
+
return json(res, 404, { error: 'not_found', error_description: `unknown action: ${action ?? '(none)'}` });
|
|
135
|
+
}
|
|
136
|
+
return json(res, 404, { error: 'not_found' });
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return json(res, 500, { error: 'server_error', error_description: String(err?.message || err) });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
server.listen(PORT, () => {
|
|
143
|
+
console.log(`x402-checkout helper on http://localhost:${PORT} (RPC: ${RPC})`);
|
|
144
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@three-ws/x402-modal",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "A drop-in, dependency-free payment modal for any x402 paid endpoint. One script tag turns a 402 challenge into a polished checkout: wallet connect (Phantom on Solana, MetaMask/EVM via EIP-3009), the 402 → sign → settle flow, SIWX re-entry, spending caps, and a receipt — vanilla JS, no bundler required.
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "A drop-in, dependency-free payment modal for any x402 paid endpoint. One script tag turns a 402 challenge into a polished checkout: wallet connect (Phantom on Solana, MetaMask/EVM via EIP-3009), the 402 → sign → settle flow, SIWX re-entry, spending caps, and a receipt — vanilla JS, no bundler required.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"x402",
|
|
7
7
|
"payments",
|
|
@@ -23,16 +23,15 @@
|
|
|
23
23
|
"agent-payments",
|
|
24
24
|
"402"
|
|
25
25
|
],
|
|
26
|
-
"author": "
|
|
27
|
-
"license": "
|
|
28
|
-
"homepage": "https://
|
|
26
|
+
"author": "nirholas",
|
|
27
|
+
"license": "UNLICENSED",
|
|
28
|
+
"homepage": "https://github.com/nirholas/x402-modal#readme",
|
|
29
29
|
"repository": {
|
|
30
30
|
"type": "git",
|
|
31
|
-
"url": "git+https://github.com/nirholas/
|
|
32
|
-
"directory": "x402-modal-sdk"
|
|
31
|
+
"url": "git+https://github.com/nirholas/x402-modal.git"
|
|
33
32
|
},
|
|
34
33
|
"bugs": {
|
|
35
|
-
"url": "https://github.com/nirholas/
|
|
34
|
+
"url": "https://github.com/nirholas/x402-modal/issues"
|
|
36
35
|
},
|
|
37
36
|
"type": "module",
|
|
38
37
|
"main": "./dist/x402-modal.mjs",
|
|
@@ -54,9 +53,12 @@
|
|
|
54
53
|
"src",
|
|
55
54
|
"types",
|
|
56
55
|
"docs",
|
|
56
|
+
"examples",
|
|
57
57
|
"README.md",
|
|
58
58
|
"TUTORIAL.md",
|
|
59
|
-
"
|
|
59
|
+
"CONTRIBUTING.md",
|
|
60
|
+
"LICENSE",
|
|
61
|
+
"CHANGELOG.md"
|
|
60
62
|
],
|
|
61
63
|
"scripts": {
|
|
62
64
|
"build": "node build.mjs",
|