@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.
@@ -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.