@three-ws/x402-payment-modal 1.1.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 +57 -0
- package/LICENSE +180 -0
- package/README.md +347 -0
- package/TUTORIAL.md +188 -0
- package/dist/index.d.ts +141 -0
- package/dist/x402.js +1777 -0
- package/dist/x402.min.js +375 -0
- package/docs/api-reference.md +227 -0
- package/docs/architecture.md +171 -0
- package/docs/server-setup.md +239 -0
- package/docs/siwx.md +116 -0
- package/docs/spending-caps.md +92 -0
- package/docs/theming.md +124 -0
- package/examples/README.md +22 -0
- package/examples/plain-html/index.html +229 -0
- package/examples/react/README.md +69 -0
- package/examples/react/X402Button.jsx +84 -0
- package/examples/server-express/package.json +16 -0
- package/examples/server-express/public/index.html +89 -0
- package/examples/server-express/server.js +89 -0
- package/package.json +113 -0
- package/server/README.md +68 -0
- package/server/checkout.js +392 -0
- package/server/express.js +44 -0
- package/server/vercel.js +54 -0
- package/src/index.js +1776 -0
- package/types/index.d.ts +141 -0
- package/types/server.d.ts +109 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
`@three-ws/x402-payment-modal` is a single zero-dependency vanilla-JS ES module
|
|
4
|
+
that turns any [x402](https://x402.org)-protected HTTP endpoint into a one-click
|
|
5
|
+
checkout. It owns the entire client lifecycle: discovering the payment challenge,
|
|
6
|
+
connecting a wallet, signing the payment, retrying the request with proof, and
|
|
7
|
+
rendering a receipt plus the endpoint's result.
|
|
8
|
+
|
|
9
|
+
This document explains how the modal works end to end. For the public surface,
|
|
10
|
+
see the [API reference](./api-reference.md). For the Solana checkout backend, see
|
|
11
|
+
[server setup](./server-setup.md).
|
|
12
|
+
|
|
13
|
+
## The x402 flow in one sentence
|
|
14
|
+
|
|
15
|
+
> The merchant answers an unpaid request with **HTTP 402** describing what it
|
|
16
|
+
> wants; the modal makes the user **sign** a payment matching that description;
|
|
17
|
+
> the modal **retries** the same request with an `X-PAYMENT` header; the merchant
|
|
18
|
+
> **settles** the payment and returns the real result.
|
|
19
|
+
|
|
20
|
+
The package never holds funds and never moves money on its own. It only produces
|
|
21
|
+
a signed payment authorization and hands it back to the merchant, who settles it
|
|
22
|
+
through an x402 facilitator.
|
|
23
|
+
|
|
24
|
+
## Lifecycle
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
discover → connect → authorize → verify (retry + settle) → receipt
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
These map directly to the four visible modal steps:
|
|
31
|
+
|
|
32
|
+
| Step | Modal label | What happens |
|
|
33
|
+
|-------------|----------------------|------------------------------------------------------------------------------|
|
|
34
|
+
| `discover` | Confirming price | Probe the endpoint, parse the 402 challenge, render price + network. |
|
|
35
|
+
| `connect` | Connect wallet | Detect Phantom / EVM wallet, let the user pick and connect one. |
|
|
36
|
+
| `authorize` | Authorize payment | Produce a signed payment (EVM EIP-3009 typed-data, or Solana signed tx). |
|
|
37
|
+
| `verify` | Verify & complete | Re-send the request with `X-PAYMENT`; merchant settles; show receipt. |
|
|
38
|
+
|
|
39
|
+
Each step is rendered as a `.x402-step` element and carries `.x402-active`,
|
|
40
|
+
`.x402-done`, or `.x402-error` modifiers as it progresses. See
|
|
41
|
+
[theming](./theming.md) for styling hooks.
|
|
42
|
+
|
|
43
|
+
## Challenge discovery
|
|
44
|
+
|
|
45
|
+
When `pay()` first calls the endpoint (using the configured `method`, `body`, and
|
|
46
|
+
`headers`), it inspects the response for a payment challenge. It accepts any of:
|
|
47
|
+
|
|
48
|
+
1. **HTTP 402** with a JSON body describing the accepted payment(s).
|
|
49
|
+
2. **HTTP 402** with a `payment-required` response header (the body-less form).
|
|
50
|
+
3. **MCP-style HTTP 401** with a `payment-required` header — used by Model
|
|
51
|
+
Context Protocol servers that gate tools behind payment.
|
|
52
|
+
|
|
53
|
+
The parsed challenge contains one or more `accepts` entries. Each entry names a
|
|
54
|
+
`network` (e.g. an EVM chain like Base, or a Solana network), an asset, an
|
|
55
|
+
amount, the pay-to address, and protocol `extra`/`extensions` metadata. If the
|
|
56
|
+
challenge advertises [SIWX](./siwx.md), the modal can offer sign-in re-entry
|
|
57
|
+
instead of a fresh payment.
|
|
58
|
+
|
|
59
|
+
If the very first response is **not** a payment challenge — e.g. an immediate
|
|
60
|
+
`200` (the endpoint isn't paid) or any other non-`402` status — discovery
|
|
61
|
+
**throws**: the modal renders the error on the `discover` step rather than
|
|
62
|
+
silently succeeding, since pointing the modal at a free or non-x402 endpoint is
|
|
63
|
+
almost always a misconfiguration worth surfacing.
|
|
64
|
+
|
|
65
|
+
## Two signing paths
|
|
66
|
+
|
|
67
|
+
x402 supports multiple settlement rails. The modal implements two, and which one
|
|
68
|
+
runs is decided entirely by the `network` in the selected `accepts` entry.
|
|
69
|
+
|
|
70
|
+
### EVM path — browser-only (EIP-3009)
|
|
71
|
+
|
|
72
|
+
EVM stablecoin payments (e.g. USDC on Base) use **EIP-3009** "transfer with
|
|
73
|
+
authorization." The browser wallet (MetaMask or any injected EIP-1193 provider)
|
|
74
|
+
signs an EIP-712 typed-data authorization. This is a pure signature — **no funds
|
|
75
|
+
move at signing time and no server call is made.** The signed authorization
|
|
76
|
+
becomes the `X-PAYMENT` header; the merchant's facilitator submits it on-chain
|
|
77
|
+
when it settles.
|
|
78
|
+
|
|
79
|
+
Because the signature is generated entirely in the browser, **EVM-only sites need
|
|
80
|
+
nothing on the server side** of this package.
|
|
81
|
+
|
|
82
|
+
### Solana path — server-assisted (prepare / encode + Phantom)
|
|
83
|
+
|
|
84
|
+
Phantom signs *serialized transactions*, not arbitrary typed data, so the Solana
|
|
85
|
+
path needs a small backend to build the transaction the wallet will sign. The
|
|
86
|
+
flow:
|
|
87
|
+
|
|
88
|
+
1. Client posts the selected `accepts` entry and the buyer's address to the
|
|
89
|
+
checkout server: `POST /api/x402-checkout?action=prepare`.
|
|
90
|
+
2. The server builds a partially-signed SPL `transferChecked` v0 transaction.
|
|
91
|
+
The **fee payer is a facilitator sponsor account** (`accept.extra.feePayer`),
|
|
92
|
+
so the buyer needs only USDC — no SOL for gas.
|
|
93
|
+
3. The server returns `tx_base64` + `recent_blockhash`.
|
|
94
|
+
4. Phantom signs the transaction (`signTransaction`).
|
|
95
|
+
5. Client posts the signed tx to `?action=encode`; the server wraps it into a
|
|
96
|
+
base64 x402 v2 payment envelope and returns `x_payment`.
|
|
97
|
+
6. That envelope becomes the `X-PAYMENT` header on the retry.
|
|
98
|
+
|
|
99
|
+
See [server setup](./server-setup.md) for mounting `prepare`/`encode`.
|
|
100
|
+
|
|
101
|
+
## Retry, settle, and the 429 auto-retry
|
|
102
|
+
|
|
103
|
+
Once a signed payment exists, the modal re-issues the original request with the
|
|
104
|
+
`X-PAYMENT` header attached. Outcomes:
|
|
105
|
+
|
|
106
|
+
- **2xx** — the merchant accepted and settled the payment. The modal parses the
|
|
107
|
+
body (JSON or text), extracts any `payment`/receipt metadata from the response,
|
|
108
|
+
renders the receipt + result, and resolves the `pay()` promise.
|
|
109
|
+
- **HTTP 429 (throttled)** — the facilitator was rate-limited. **Payment is not
|
|
110
|
+
settled until the merchant call actually succeeds**, so it is safe to re-send
|
|
111
|
+
the *same* signed payment. The modal auto-retries up to **2 additional times**
|
|
112
|
+
with the identical `X-PAYMENT` payload before surfacing an error.
|
|
113
|
+
- **Other 4xx/5xx** — surfaced as an error in the modal (and via the
|
|
114
|
+
`x402:error` event), with the reservation rolled back if
|
|
115
|
+
[spending caps](./spending-caps.md) were in play.
|
|
116
|
+
|
|
117
|
+
This retry-on-429 is why the modal keeps the signed payment in memory rather than
|
|
118
|
+
re-prompting the wallet: re-signing is unnecessary and would annoy the user.
|
|
119
|
+
|
|
120
|
+
## Sequence diagram
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
User Modal Merchant Checkout server Wallet
|
|
124
|
+
| | | | |
|
|
125
|
+
| click | | | |
|
|
126
|
+
|----------->| pay(opts) | | |
|
|
127
|
+
| | GET/POST endpoint | | |
|
|
128
|
+
| |------------------->| | |
|
|
129
|
+
| | 402 + accepts | | |
|
|
130
|
+
| |<-------------------| | |
|
|
131
|
+
| | [discover] price | | |
|
|
132
|
+
| | | | |
|
|
133
|
+
| | [connect] pick wallet ------------------------------------>|
|
|
134
|
+
| |<----------------------------------------------- address |
|
|
135
|
+
| | | | |
|
|
136
|
+
| == EVM path (browser-only) == | | |
|
|
137
|
+
| | sign EIP-3009 typed data ----------------------------------->|
|
|
138
|
+
| |<-------------------------------------------- signature |
|
|
139
|
+
| | | | |
|
|
140
|
+
| == Solana path (server-assisted) == | |
|
|
141
|
+
| | POST ?action=prepare ------------------->| |
|
|
142
|
+
| |<------------------ tx_base64, blockhash --| |
|
|
143
|
+
| | signTransaction ------------------------------------------>|
|
|
144
|
+
| |<-------------------------------- signed tx |
|
|
145
|
+
| | POST ?action=encode --------------------->| |
|
|
146
|
+
| |<------------------------- x_payment ------| |
|
|
147
|
+
| | | | |
|
|
148
|
+
| | [verify] retry with X-PAYMENT | |
|
|
149
|
+
| |------------------->| | |
|
|
150
|
+
| | (429? re-send same payment, up to 2x) | |
|
|
151
|
+
| | 200 + result + receipt | |
|
|
152
|
+
| |<-------------------| | |
|
|
153
|
+
| receipt | | | |
|
|
154
|
+
|<-----------| resolve PayResult | | |
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Cancellation
|
|
158
|
+
|
|
159
|
+
If the user closes the modal at any point, `pay()` rejects with an `Error` whose
|
|
160
|
+
`.code === 'cancelled'`. Any spending-cap reservation made for the attempt is
|
|
161
|
+
rolled back. Callers should treat `cancelled` as a no-op, not a failure.
|
|
162
|
+
|
|
163
|
+
## Distribution shape
|
|
164
|
+
|
|
165
|
+
- **Client:** one ES module (`src/index.js`), also shipped minified
|
|
166
|
+
(`dist/x402.min.js`). It self-registers `window.X402` and auto-binds
|
|
167
|
+
`[data-x402-endpoint]` elements. Crypto helpers (`@solana/web3.js`,
|
|
168
|
+
noble hashes) are loaded on demand from CDN ESM and can be repointed for
|
|
169
|
+
strict CSP via [`configure`](./api-reference.md#configure).
|
|
170
|
+
- **Server:** optional, only for the Solana rail. Exposed at
|
|
171
|
+
`@three-ws/x402-payment-modal/server` with Express and Vercel adapters.
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# Server setup
|
|
2
|
+
|
|
3
|
+
The server module exists for **one reason: the Solana payment rail.** It exposes
|
|
4
|
+
`prepare`/`encode` endpoints that build and wrap the SPL transaction Phantom
|
|
5
|
+
signs. If your endpoint only accepts EVM stablecoins (e.g. USDC on Base), you do
|
|
6
|
+
**not** need any of this — see why below.
|
|
7
|
+
|
|
8
|
+
For the full request lifecycle, see [architecture](./architecture.md). For the
|
|
9
|
+
client side, see the [API reference](./api-reference.md).
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import {
|
|
13
|
+
prepareSolanaCheckout,
|
|
14
|
+
encodeX402Payment,
|
|
15
|
+
handleCheckout,
|
|
16
|
+
CheckoutError,
|
|
17
|
+
isSolanaNetwork,
|
|
18
|
+
X402_VERSION, // 2
|
|
19
|
+
NETWORK_SOLANA_MAINNET,
|
|
20
|
+
NETWORK_SOLANA_DEVNET,
|
|
21
|
+
} from '@three-ws/x402-payment-modal/server';
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Why EVM needs no server, but Solana does
|
|
25
|
+
|
|
26
|
+
- **EVM (EIP-3009):** the browser wallet signs an EIP-712 typed-data
|
|
27
|
+
authorization entirely client-side. No funds move at signing time, and no
|
|
28
|
+
server is contacted. The signature becomes the `X-PAYMENT` header directly.
|
|
29
|
+
- **Solana:** Phantom only signs *serialized transactions*, not arbitrary typed
|
|
30
|
+
data. Something has to build that transaction. The server builds a partially
|
|
31
|
+
signed `transferChecked` v0 transaction (`prepare`), the buyer signs it, then
|
|
32
|
+
the server wraps the signed tx into the x402 v2 envelope (`encode`). The fee
|
|
33
|
+
payer is a facilitator sponsor account, so the buyer needs only USDC — no SOL
|
|
34
|
+
for gas.
|
|
35
|
+
|
|
36
|
+
## Install the peer dependencies
|
|
37
|
+
|
|
38
|
+
The Solana helpers require these **optional** peer deps. Install them only if you
|
|
39
|
+
mount the Solana checkout:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install @solana/web3.js@^1.95 @solana/spl-token@^0.4
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
EVM-only sites can skip this entirely.
|
|
46
|
+
|
|
47
|
+
## Environment variables
|
|
48
|
+
|
|
49
|
+
| Variable | Purpose |
|
|
50
|
+
|-------------------|---------------------------------------------------------------|
|
|
51
|
+
| `SOLANA_RPC_URL` | Mainnet RPC endpoint used to build/serialize the transaction. |
|
|
52
|
+
|
|
53
|
+
You may also pass `rpcUrl` (and `devnetRpcUrl`) explicitly to the adapters or to
|
|
54
|
+
`prepareSolanaCheckout`; explicit options take precedence over the env var.
|
|
55
|
+
|
|
56
|
+
## Mounting with Express
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
import express from 'express';
|
|
60
|
+
import { x402CheckoutRouter } from '@three-ws/x402-payment-modal/server/express';
|
|
61
|
+
|
|
62
|
+
const app = express();
|
|
63
|
+
app.use(express.json());
|
|
64
|
+
|
|
65
|
+
app.use(
|
|
66
|
+
'/api/x402-checkout',
|
|
67
|
+
x402CheckoutRouter({
|
|
68
|
+
rpcUrl: process.env.SOLANA_RPC_URL,
|
|
69
|
+
// devnetRpcUrl: 'https://api.devnet.solana.com',
|
|
70
|
+
// origin: 'https://yourapp.com', // CORS allow-origin (default '*')
|
|
71
|
+
})
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
app.listen(3000);
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`x402CheckoutRouter({ rpcUrl?, devnetRpcUrl?, origin? })` returns an Express
|
|
78
|
+
`RequestHandler`. It sets permissive CORS by default (`origin: '*'`), answers
|
|
79
|
+
`OPTIONS` preflight, and requires `POST` for the actual calls.
|
|
80
|
+
|
|
81
|
+
## Mounting with Vercel / Next.js (pages API)
|
|
82
|
+
|
|
83
|
+
Create `api/x402-checkout.js` (or `pages/api/x402-checkout.js`) and re-export the
|
|
84
|
+
handler:
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
// api/x402-checkout.js
|
|
88
|
+
import { createVercelCheckoutHandler } from '@three-ws/x402-payment-modal/server/vercel';
|
|
89
|
+
|
|
90
|
+
export default createVercelCheckoutHandler({
|
|
91
|
+
rpcUrl: process.env.SOLANA_RPC_URL,
|
|
92
|
+
// devnetRpcUrl: process.env.SOLANA_DEVNET_RPC_URL,
|
|
93
|
+
// origin: 'https://yourapp.com',
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`createVercelCheckoutHandler()` is also the module's default export, so the
|
|
98
|
+
zero-config form works too:
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
// api/x402-checkout.js
|
|
102
|
+
export { default } from '@three-ws/x402-payment-modal/server/vercel';
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Like the Express adapter, it applies permissive CORS by default, handles
|
|
106
|
+
`OPTIONS`, and requires `POST`.
|
|
107
|
+
|
|
108
|
+
> Point the client at this endpoint with `configure({ checkoutOrigin, checkoutPath })`
|
|
109
|
+
> or the `data-x402-checkout-origin` / `data-x402-checkout-path` script
|
|
110
|
+
> attributes. See the [API reference](./api-reference.md#configure).
|
|
111
|
+
|
|
112
|
+
## From scratch with Node `http` + `handleCheckout`
|
|
113
|
+
|
|
114
|
+
`handleCheckout` is the framework-agnostic router. Pass it the `action`
|
|
115
|
+
(`'prepare'` or `'encode'`), the parsed JSON `body`, and optional `options`. It
|
|
116
|
+
returns `{ status, body }`, mapping a thrown `CheckoutError` to its `.status` and
|
|
117
|
+
any unexpected error to `502`. It accepts both camelCase and snake_case body
|
|
118
|
+
fields (`signed_tx_base64`/`signedTxBase64`, `resource_url`/`resourceUrl`,
|
|
119
|
+
`builder_code`/`builderCode`).
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
import { createServer } from 'node:http';
|
|
123
|
+
import { handleCheckout } from '@three-ws/x402-payment-modal/server';
|
|
124
|
+
|
|
125
|
+
const server = createServer((req, res) => {
|
|
126
|
+
const url = new URL(req.url, 'http://localhost');
|
|
127
|
+
const action = url.searchParams.get('action'); // 'prepare' | 'encode'
|
|
128
|
+
|
|
129
|
+
if (req.method === 'OPTIONS') {
|
|
130
|
+
res.writeHead(204, cors).end();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (req.method !== 'POST') {
|
|
134
|
+
res.writeHead(405, cors).end();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let raw = '';
|
|
139
|
+
req.on('data', (c) => (raw += c));
|
|
140
|
+
req.on('end', async () => {
|
|
141
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
142
|
+
const { status, body: out } = await handleCheckout({
|
|
143
|
+
action,
|
|
144
|
+
body,
|
|
145
|
+
options: { rpcUrl: process.env.SOLANA_RPC_URL },
|
|
146
|
+
});
|
|
147
|
+
res.writeHead(status, { 'content-type': 'application/json', ...cors });
|
|
148
|
+
res.end(JSON.stringify(out));
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const cors = {
|
|
153
|
+
'access-control-allow-origin': '*',
|
|
154
|
+
'access-control-allow-methods': 'POST, OPTIONS',
|
|
155
|
+
'access-control-allow-headers': 'content-type',
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
server.listen(3000);
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## CORS notes
|
|
162
|
+
|
|
163
|
+
Both adapters default to `origin: '*'` so a paywall served from one domain can
|
|
164
|
+
talk to a checkout server on another. For production, pass your site's origin:
|
|
165
|
+
|
|
166
|
+
```js
|
|
167
|
+
x402CheckoutRouter({ rpcUrl: process.env.SOLANA_RPC_URL, origin: 'https://yourapp.com' });
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Request / response shapes
|
|
171
|
+
|
|
172
|
+
### `?action=prepare`
|
|
173
|
+
|
|
174
|
+
`prepareSolanaCheckout({ accept, buyer, rpcUrl?, devnetRpcUrl? })` builds a
|
|
175
|
+
partially signed v0 transaction whose fee payer is `accept.extra.feePayer`.
|
|
176
|
+
|
|
177
|
+
Request:
|
|
178
|
+
|
|
179
|
+
```json
|
|
180
|
+
{
|
|
181
|
+
"accept": {
|
|
182
|
+
"network": "solana",
|
|
183
|
+
"asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
|
|
184
|
+
"maxAmountRequired": "50000",
|
|
185
|
+
"payTo": "So111SyntheticMerchantPlaceholder1111111111",
|
|
186
|
+
"extra": { "feePayer": "So111SyntheticFeePayerPlaceholder11111111" }
|
|
187
|
+
},
|
|
188
|
+
"buyer": "So111SyntheticBuyerPlaceholder111111111111"
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Response:
|
|
193
|
+
|
|
194
|
+
```json
|
|
195
|
+
{
|
|
196
|
+
"network": "solana",
|
|
197
|
+
"tx_base64": "AQAB...base64-serialized-v0-tx...",
|
|
198
|
+
"recent_blockhash": "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin"
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
> `asset` above is the real Solana USDC mint (`EPjFW…Dt1v`). All other addresses
|
|
203
|
+
> are synthetic placeholders. Substitute your own facilitator and merchant
|
|
204
|
+
> accounts.
|
|
205
|
+
|
|
206
|
+
### `?action=encode`
|
|
207
|
+
|
|
208
|
+
`encodeX402Payment({ accept, signedTxBase64, resourceUrl, builderCode? })` wraps
|
|
209
|
+
the buyer-signed transaction into a base64 x402 v2 envelope (`X402_VERSION === 2`).
|
|
210
|
+
|
|
211
|
+
Request:
|
|
212
|
+
|
|
213
|
+
```json
|
|
214
|
+
{
|
|
215
|
+
"accept": { "network": "solana", "asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" },
|
|
216
|
+
"signed_tx_base64": "AQAB...signed...",
|
|
217
|
+
"resource_url": "https://api.example.com/premium",
|
|
218
|
+
"builder_code": { "wallet": "examplewallet", "service": "example_api" }
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Response:
|
|
223
|
+
|
|
224
|
+
```json
|
|
225
|
+
{ "x_payment": "eyJ4NDAyVmVyc2lvbiI6Mn0...base64-envelope..." }
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The client puts `x_payment` into the `X-PAYMENT` header and retries the original
|
|
229
|
+
request.
|
|
230
|
+
|
|
231
|
+
## Helpers
|
|
232
|
+
|
|
233
|
+
| Export | Description |
|
|
234
|
+
|----------------------------|------------------------------------------------------------------------|
|
|
235
|
+
| `CheckoutError` | `Error` subclass with `.status` and `.code`; mapped to HTTP by router. |
|
|
236
|
+
| `isSolanaNetwork(network)` | `true` for Solana mainnet/devnet network identifiers. |
|
|
237
|
+
| `X402_VERSION` | `2` — the x402 envelope version produced by `encode`. |
|
|
238
|
+
| `NETWORK_SOLANA_MAINNET` | Canonical Solana mainnet network id. |
|
|
239
|
+
| `NETWORK_SOLANA_DEVNET` | Canonical Solana devnet network id. |
|
package/docs/siwx.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# SIWX — Sign-In-With-X re-entry
|
|
2
|
+
|
|
3
|
+
SIWX ("Sign-In-With-X", standardized as [CAIP-122](https://chainagnostic.org/CAIPs/caip-122))
|
|
4
|
+
lets a wallet that **already paid** for an endpoint get back in by **signing a
|
|
5
|
+
challenge instead of paying again**. It is the difference between a one-time
|
|
6
|
+
purchase and being charged on every page load.
|
|
7
|
+
|
|
8
|
+
This package implements the **client** side of SIWX. The server endpoint must
|
|
9
|
+
issue the challenge and verify the signed proof — see
|
|
10
|
+
[Server responsibilities](#server-responsibilities).
|
|
11
|
+
|
|
12
|
+
For the overall flow, see [architecture](./architecture.md).
|
|
13
|
+
|
|
14
|
+
## Why it matters
|
|
15
|
+
|
|
16
|
+
x402 charges per call. Without SIWX, a user who paid for `/premium` would pay
|
|
17
|
+
again the next time they hit it. With SIWX, the merchant can recognize a wallet
|
|
18
|
+
that has an active entitlement: the wallet proves ownership with a cheap,
|
|
19
|
+
gasless, off-chain signature, and the merchant grants access without a new
|
|
20
|
+
payment.
|
|
21
|
+
|
|
22
|
+
## How the server advertises SIWX
|
|
23
|
+
|
|
24
|
+
When the endpoint returns its **HTTP 402** challenge, it advertises SIWX support
|
|
25
|
+
by including an extension entry in the challenge body:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"x402Version": 2,
|
|
30
|
+
"accepts": [
|
|
31
|
+
{
|
|
32
|
+
"network": "base",
|
|
33
|
+
"asset": "0xUSDC...synthetic",
|
|
34
|
+
"maxAmountRequired": "50000",
|
|
35
|
+
"payTo": "0xMerchant...synthetic",
|
|
36
|
+
"extensions": {
|
|
37
|
+
"sign-in-with-x": {
|
|
38
|
+
"domain": "api.example.com",
|
|
39
|
+
"statement": "Sign in to re-enter your paid session.",
|
|
40
|
+
"nonce": "synthetic-nonce-abc123"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The exact challenge fields are defined by the x402 SIWX spec; the modal only
|
|
49
|
+
needs `extensions['sign-in-with-x']` to be present to offer sign-in.
|
|
50
|
+
|
|
51
|
+
## How the client submits proof
|
|
52
|
+
|
|
53
|
+
When the user signs the challenge, the modal sends the signed CAIP-122 proof back
|
|
54
|
+
to the endpoint as a base64-encoded JSON value in the **`SIGN-IN-WITH-X`**
|
|
55
|
+
request header, then retries the original request. If the merchant accepts the
|
|
56
|
+
proof, it returns the result with no payment required.
|
|
57
|
+
|
|
58
|
+
## Modal behavior
|
|
59
|
+
|
|
60
|
+
The modal adapts its layout to what the challenge offers:
|
|
61
|
+
|
|
62
|
+
1. **SIWX advertised + a compatible wallet present** — the modal **leads with
|
|
63
|
+
"Sign in with wallet"** as the primary (`.x402-pay-btn`) action and **demotes
|
|
64
|
+
pay to secondary** (`.x402-pay-secondary`). Signing in is cheaper, so it's the
|
|
65
|
+
default.
|
|
66
|
+
2. **User signs in** — on success the modal resolves [`PayResult`](./api-reference.md#payresult)
|
|
67
|
+
with the `siwx` field populated (`{ address, network }`) and **no `payment`
|
|
68
|
+
field**, and fires the [`x402:siwx-signed`](#the-x402siwx-signed-event) event.
|
|
69
|
+
3. **Sign-in rejected (`siwx_not_paid`)** — if the server answers the SIWX
|
|
70
|
+
attempt with a `401`/`402` carrying a `siwx_not_paid` reason (the wallet has
|
|
71
|
+
no active entitlement), the modal **falls back to the normal pay flow** and
|
|
72
|
+
shows a notice explaining that payment is required.
|
|
73
|
+
4. **SIWX not advertised** — the modal behaves as a plain pay modal; nothing
|
|
74
|
+
changes.
|
|
75
|
+
|
|
76
|
+
## The `x402:siwx-signed` event
|
|
77
|
+
|
|
78
|
+
A successful SIWX re-entry dispatches a bubbling `x402:siwx-signed`
|
|
79
|
+
`CustomEvent` on the clicked element:
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
const btn = document.querySelector('[data-x402-endpoint]');
|
|
83
|
+
|
|
84
|
+
btn.addEventListener('x402:siwx-signed', (e) => {
|
|
85
|
+
const { address, network } = e.detail;
|
|
86
|
+
console.log(`re-entered as ${address} on ${network}`);
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
You also get the same data programmatically from the resolved result:
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
const res = await pay({ endpoint: 'https://api.example.com/premium' });
|
|
94
|
+
|
|
95
|
+
if (res.siwx) {
|
|
96
|
+
console.log('re-entered via SIWX:', res.siwx.address);
|
|
97
|
+
} else if (res.payment) {
|
|
98
|
+
console.log('paid:', res.payment.transaction);
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Server responsibilities
|
|
103
|
+
|
|
104
|
+
This package does not store entitlements or verify signatures. Your endpoint
|
|
105
|
+
must:
|
|
106
|
+
|
|
107
|
+
1. **Advertise** the SIWX extension in the 402 challenge (see above).
|
|
108
|
+
2. **Verify** the CAIP-122 proof from the `SIGN-IN-WITH-X` header — check the
|
|
109
|
+
nonce, domain, expiry, and signature against the claimed address.
|
|
110
|
+
3. **Authorize** the request if that address has an active paid entitlement;
|
|
111
|
+
otherwise respond `401`/`402` with `siwx_not_paid` so the client falls back to
|
|
112
|
+
paying.
|
|
113
|
+
|
|
114
|
+
Implement these against the x402 SIWX specification. The modal handles
|
|
115
|
+
everything on the browser side: detecting the offer, prompting the signature,
|
|
116
|
+
encoding the header, retrying, and falling back.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Spending caps
|
|
2
|
+
|
|
3
|
+
Spending caps are client-side guardrails that stop a wallet from spending more
|
|
4
|
+
than you allow through the modal — per call, per hour, and per day. They make
|
|
5
|
+
unattended or agentic usage safer by bounding the blast radius of a bug or a
|
|
6
|
+
runaway loop.
|
|
7
|
+
|
|
8
|
+
They are passed to [`pay()`](./api-reference.md#payopts) via the `caps` option.
|
|
9
|
+
|
|
10
|
+
## Shape
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
interface SpendingCaps {
|
|
14
|
+
maxPerCall?: string | number;
|
|
15
|
+
maxPerHour?: string | number;
|
|
16
|
+
maxPerDay?: string | number;
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
All amounts are **atomic micro-USD** — i.e. millionths of a dollar
|
|
21
|
+
(`1_000_000` = 1 USDC). Strings are accepted to avoid floating-point loss on
|
|
22
|
+
large numbers.
|
|
23
|
+
|
|
24
|
+
| Field | Meaning |
|
|
25
|
+
|--------------|--------------------------------------------------------|
|
|
26
|
+
| `maxPerCall` | Maximum a single payment may cost. |
|
|
27
|
+
| `maxPerHour` | Maximum total across the current UTC hour bucket. |
|
|
28
|
+
| `maxPerDay` | Maximum total across the current UTC day bucket. |
|
|
29
|
+
|
|
30
|
+
## How enforcement works
|
|
31
|
+
|
|
32
|
+
- Spend is tracked in **`localStorage`, per wallet address.**
|
|
33
|
+
- Totals are **bucketed by UTC hour and UTC day**, so the windows roll over
|
|
34
|
+
cleanly and survive a page reload.
|
|
35
|
+
- Before a payment is signed, the modal **reserves** the amount against the
|
|
36
|
+
relevant buckets. If any cap would be exceeded, the payment is blocked and the
|
|
37
|
+
user sees an error explaining which limit was hit.
|
|
38
|
+
- If the payment **fails or is cancelled**, the reservation is **rolled back** so
|
|
39
|
+
a failed attempt never counts against the user's budget. (Cancellation is the
|
|
40
|
+
`pay()` rejection with `.code === 'cancelled'`.)
|
|
41
|
+
|
|
42
|
+
## Important caveat: stablecoins only
|
|
43
|
+
|
|
44
|
+
The drop-in script stays **zero-dependency and does not fetch live prices.** That
|
|
45
|
+
means it can only reason about value when 1 token ≈ 1 USD — i.e. **stablecoins
|
|
46
|
+
(USDC / USDT / DAI).** For those, atomic micro-USD caps are meaningful directly.
|
|
47
|
+
|
|
48
|
+
For **non-stable assets**, the modal cannot convert an amount to USD without a
|
|
49
|
+
price feed, so browser caps do **not** meaningfully bound spend. **Enforce caps
|
|
50
|
+
for non-stable assets on the server side**, where you can price the asset at
|
|
51
|
+
settlement time.
|
|
52
|
+
|
|
53
|
+
## Not a security boundary
|
|
54
|
+
|
|
55
|
+
Client-side caps are **advisory guardrails, not a security control.** They live in
|
|
56
|
+
`localStorage`, which a determined user can clear or edit, and they only cover
|
|
57
|
+
flows that go through this modal. Treat them as a convenience and a safety net for
|
|
58
|
+
honest usage. **Real spending limits belong on the server**, enforced where the
|
|
59
|
+
payment is verified and settled.
|
|
60
|
+
|
|
61
|
+
## Worked example
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
import { pay } from '@three-ws/x402-payment-modal';
|
|
65
|
+
|
|
66
|
+
// USDC, so atomic micro-USD caps apply directly:
|
|
67
|
+
// 0.10 USDC per call, 2.00 USDC/hour, 10.00 USDC/day
|
|
68
|
+
const res = await pay({
|
|
69
|
+
endpoint: 'https://api.example.com/premium',
|
|
70
|
+
method: 'POST',
|
|
71
|
+
body: { prompt: 'Summarize this article' },
|
|
72
|
+
merchant: 'Example API',
|
|
73
|
+
action: 'Generate summary',
|
|
74
|
+
caps: {
|
|
75
|
+
maxPerCall: 100_000, // 0.10 USDC
|
|
76
|
+
maxPerHour: 2_000_000, // 2.00 USDC
|
|
77
|
+
maxPerDay: 10_000_000, // 10.00 USDC
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
console.log(res.result);
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
If a call would push the current UTC-hour total over `maxPerHour`, the modal
|
|
85
|
+
blocks it before any wallet prompt and surfaces the reason; nothing is reserved.
|
|
86
|
+
|
|
87
|
+
## See also
|
|
88
|
+
|
|
89
|
+
- [API reference](./api-reference.md) — where `caps` fits in `PayOptions`.
|
|
90
|
+
- [Architecture](./architecture.md) — where the reservation/rollback sits in the
|
|
91
|
+
lifecycle.
|
|
92
|
+
- [Server setup](./server-setup.md) — for enforcing real limits server-side.
|