@three-ws/x402-payment-modal 1.1.0 → 1.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.
@@ -1,7 +1,9 @@
1
- # React example — `<X402Button>`
1
+ # React example
2
2
 
3
- A drop-in React component that wraps `pay()` from
4
- [`@three-ws/x402-payment-modal`](https://www.npmjs.com/package/@three-ws/x402-payment-modal).
3
+ Uses the package's **shipped** React wrapper
4
+ [`@three-ws/x402-payment-modal/react`](../../docs/react.md) — not a hand-rolled
5
+ component. You get `<X402Button>` (a drop-in button) and `useX402()` (a headless
6
+ hook), both SSR-safe.
5
7
 
6
8
  ## Install
7
9
 
@@ -9,12 +11,14 @@ A drop-in React component that wraps `pay()` from
9
11
  npm i @three-ws/x402-payment-modal
10
12
  ```
11
13
 
14
+ `react` is an optional peer dependency you already have.
15
+
12
16
  ## Use it
13
17
 
14
- Copy [`X402Button.jsx`](./X402Button.jsx) into your project, then:
18
+ [`App.jsx`](./App.jsx) shows both the button and the hook. The button form:
15
19
 
16
20
  ```jsx
17
- import X402Button from './X402Button';
21
+ import { X402Button } from '@three-ws/x402-payment-modal/react';
18
22
 
19
23
  export default function Demo() {
20
24
  return (
@@ -24,7 +28,8 @@ export default function Demo() {
24
28
  body={{ url: 'https://en.wikipedia.org/wiki/x402' }}
25
29
  merchant="Acme Summaries"
26
30
  action="Summarize article"
27
- label="Summarize for 0.01 USDC"
31
+ label="Summarize for $0.01"
32
+ caps={{ maxPerCall: 100_000 }} // 0.10 USDC, stablecoin caps only
28
33
  onResult={(r) => console.log('paid', r.payment)}
29
34
  onError={(e) => console.error('payment failed', e)}
30
35
  />
@@ -32,38 +37,36 @@ export default function Demo() {
32
37
  }
33
38
  ```
34
39
 
35
- ### Props
40
+ The headless form, for full control over the trigger UI:
36
41
 
37
- | Prop | Type | Notes |
38
- | ---------- | ---------- | ---------------------------------------------------------- |
39
- | `endpoint` | `string` | Required. The x402-enabled HTTP endpoint to call. |
40
- | `method` | `string` | HTTP method (default `GET`). |
41
- | `body` | `object` | Request body (sent as JSON). |
42
- | `merchant` | `string` | Display name shown in the modal. |
43
- | `action` | `string` | Short description of what the user is paying for. |
44
- | `label` | `string` | Button text (default `Pay`). `children` overrides it. |
45
- | `onResult` | `function` | Called with the `PayResult` on success. |
46
- | `onError` | `function` | Called on failure. **Not** called when the user cancels. |
42
+ ```jsx
43
+ import { useX402 } from '@three-ws/x402-payment-modal/react';
47
44
 
48
- `onResult` receives `{ ok, result, payment?, siwx?, response }`.
45
+ function Buy() {
46
+ const { pay, isPaying } = useX402({ merchant: 'Acme' });
47
+ return (
48
+ <button disabled={isPaying} onClick={() => pay({ endpoint: '/api/paid/premium' })}>
49
+ {isPaying ? 'Processing…' : 'Pay'}
50
+ </button>
51
+ );
52
+ }
53
+ ```
49
54
 
50
- ## SSR safety
55
+ ### `<X402Button>` props
51
56
 
52
- The package is browser-only (it renders a modal and connects to a wallet), so
53
- `X402Button` imports it lazily **inside the click handler**:
57
+ See the [React reference](../../docs/react.md#x402button) for the full table.
58
+ `onResult` receives `{ ok, result, payment?, siwx?, response }`; `onError` is
59
+ **not** called when the user cancels.
54
60
 
55
- ```js
56
- const { pay } = await import('@three-ws/x402-payment-modal');
57
- ```
61
+ ## SSR safety
58
62
 
59
- Nothing from the package runs during render or on the server, so the component
60
- is safe in Next.js, Remix, and other SSR frameworks without `dynamic`/`ssr:false`
61
- wrappers.
63
+ The wrapper dynamically imports the browser-only core on first payment, so nothing
64
+ from the package runs during render or on the server — safe in Next.js, Remix, and
65
+ Astro without a `dynamic`/`ssr:false` wrapper.
62
66
 
63
67
  ## Solana payments need a server
64
68
 
65
- EVM payments sign in the browser (EIP-3009) and need no backend. **Solana
66
- payments require a checkout endpoint** that builds and settles the transfer.
67
- Stand one up before going live — see
69
+ EVM payments sign in the browser (EIP-3009) and need no backend. **Solana payments
70
+ require a checkout endpoint** that builds and settles the transfer — see
68
71
  [`../../docs/server-setup.md`](../../docs/server-setup.md) and the runnable
69
- [`examples/server-express`](../server-express) server.
72
+ [`examples/server-express`](../server-express).
@@ -45,7 +45,7 @@ app.use('/api/x402-checkout', x402CheckoutRouter({ rpcUrl: SOLANA_RPC_URL }));
45
45
  // IMPORTANT: verifying and settling the X-PAYMENT payload against an x402
46
46
  // facilitator is OUT OF SCOPE for this demo. In production you would verify the
47
47
  // payment proof (and idempotency) before returning 200. See the docs:
48
- // https://github.com/three-ws/x402-payment-modal/tree/main/docs
48
+ // https://github.com/nirholas/x402-payment-modal/tree/main/docs
49
49
  //
50
50
  // Synthetic placeholders below — replace payTo / feePayer with YOUR addresses.
51
51
  const DEMO_PAY_TO = 'So11111111111111111111111111111111111111112'; // replace me
@@ -55,22 +55,29 @@ app.get('/api/paid/hello', (req, res) => {
55
55
  const hasPayment = Boolean(req.get('X-PAYMENT'));
56
56
 
57
57
  if (!hasPayment) {
58
- // No payment yet → answer with the x402 v2 challenge. We offer TWO Solana
59
- // tokens USDC and THREE — so the modal shows a token picker and the buyer
60
- // chooses which to pay in. `solanaAccept` builds each spec-shaped entry.
58
+ // No payment yet → answer with the x402 v2 challenge. USDC is the default
59
+ // settlement asset. `solanaAccept` builds the spec-shaped accept entry.
61
60
  const common = { payTo: DEMO_PAY_TO, feePayer: DEMO_FEE_PAYER, maxTimeoutSeconds: 60 };
61
+ const accepts = [
62
+ solanaAccept({ token: 'usdc', uiAmount: 0.01, ...common }), // $0.01 in USDC
63
+ ];
64
+ // OPTIONAL: offer a second SPL token so the modal shows a token picker and
65
+ // the buyer chooses which to pay in. Set ACCEPT_SECOND_TOKEN=three (or pass
66
+ // any explicit mint to solanaAccept) to enable it. USDC stays the default.
67
+ if (process.env.ACCEPT_SECOND_TOKEN) {
68
+ accepts.push(
69
+ solanaAccept({ token: process.env.ACCEPT_SECOND_TOKEN, uiAmount: 1000, ...common }),
70
+ );
71
+ }
62
72
  return res.status(402).json({
63
73
  x402Version: 2,
64
74
  error: 'Payment required',
65
75
  resource: {
66
76
  url: `${req.protocol}://${req.get('host')}/api/paid/hello`,
67
- description: 'A friendly hello — pay in USDC or THREE.',
77
+ description: 'A friendly hello — pay per call in USDC.',
68
78
  mimeType: 'application/json',
69
79
  },
70
- accepts: [
71
- solanaAccept({ token: 'usdc', uiAmount: 0.01, ...common }), // $0.01 in USDC
72
- solanaAccept({ token: 'three', uiAmount: 1000, ...common }), // or 1,000 THREE
73
- ],
80
+ accepts,
74
81
  });
75
82
  }
76
83
 
@@ -0,0 +1,81 @@
1
+ # Solana crypto paywall — `@three-ws/x402-payment-modal` demo
2
+
3
+ A complete, runnable demo that paywalls a **free crypto-price API** (CoinGecko)
4
+ behind an **x402 USDC payment on Solana**, served to the payment modal we ship on
5
+ npm. It exists to prove the published modal works end to end on a plain page:
6
+
7
+ ```
8
+ click ─▶ 402 challenge ─▶ Phantom connect ─▶ sign SPL transfer
9
+ ─▶ PayAI /verify ─▶ fetch CoinGecko ─▶ PayAI /settle ─▶ prices + receipt
10
+ ```
11
+
12
+ Three things make it a real test, not a mock:
13
+
14
+ 1. **The modal loads from npm.** `public/index.html` pulls
15
+ `https://unpkg.com/@three-ws/x402-payment-modal@1.2.0` — the exact artifact we
16
+ publish — so you're testing the shipped code, not local source.
17
+ 2. **The data is gated for real.** `/api/paid/crypto` returns live CoinGecko
18
+ prices **only** after the payment verifies *and* settles on-chain via the
19
+ [PayAI](https://facilitator.payai.network) facilitator. No payment, no data.
20
+ 3. **The payout wallet is set at runtime.** There is no `.env` and no source
21
+ constant for it — you start the server, paste a Solana address into the page,
22
+ and that address becomes the `payTo` in the 402 challenge.
23
+
24
+ ## Run it (inside this package's repo)
25
+
26
+ The server reuses the repo's `express` + `@solana/*` and imports the package's
27
+ checkout helpers from source, so no install is needed:
28
+
29
+ ```bash
30
+ node examples/solana-crypto-paywall/server.mjs
31
+ # ▸ open http://localhost:4021
32
+ ```
33
+
34
+ Then in the page:
35
+
36
+ 1. **Set your payout wallet** — paste a base58 Solana address, click *Save*.
37
+ 2. **Pick coins** — BTC / ETH / SOL / … (live from CoinGecko).
38
+ 3. **Pay & unlock** — approve $0.01 USDC in Phantom. Prices + an on-chain
39
+ receipt (with a Solscan link) appear when settlement lands.
40
+
41
+ > Paying is a **real Solana mainnet micropayment** — you need Phantom with a
42
+ > little USDC. The fee payer is covered by the facilitator, so you don't need SOL.
43
+
44
+ ## Run it standalone (outside the repo)
45
+
46
+ ```bash
47
+ npm install
48
+ # in server.mjs, change the two relative imports to the package subpaths:
49
+ # '../../server/express.js' → '@three-ws/x402-payment-modal/server/express'
50
+ # '../../server/checkout.js' → '@three-ws/x402-payment-modal/server'
51
+ npm start
52
+ ```
53
+
54
+ ## What's runtime vs. fixed
55
+
56
+ | Thing | Where it comes from |
57
+ | --- | --- |
58
+ | **Payout wallet (`payTo`)** | **Set at runtime from the page** — `POST /api/config`, in memory. No env. |
59
+ | Fee-payer sponsor | Public PayAI account `2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4` (pays SOL fees; not a secret, not the payout). Override with `X402_FEE_PAYER_SOLANA` only if you change facilitators. |
60
+ | Facilitator | `https://facilitator.payai.network` (no API key). Override with `X402_FACILITATOR_URL`. |
61
+ | Solana RPC | Public mainnet RPC. Override with `SOLANA_RPC_URL` for anything beyond a quick try. |
62
+ | Price | Fixed at `$0.01` USDC. |
63
+ | Port | `4021`, or `PORT`. |
64
+
65
+ ## Endpoints
66
+
67
+ | Method & path | Purpose |
68
+ | --- | --- |
69
+ | `GET /api/config` | Current payout wallet, fee payer, price, coin catalog. |
70
+ | `POST /api/config` `{ payTo }` | Set the payout wallet at runtime (validated Solana address). |
71
+ | `POST /api/paid/crypto` `{ ids }` | The paid resource. 402 until paid; verifies + settles, then returns live prices. |
72
+ | `ALL /api/x402-checkout` | The package's Solana `prepare`/`encode` router the modal calls. |
73
+
74
+ ## Files
75
+
76
+ - `server.mjs` — Express app: runtime config, the 402 → verify → fetch → settle
77
+ paid endpoint, and the package's checkout router.
78
+ - `facilitator.mjs` — minimal x402 `/verify` + `/settle` facilitator client (the
79
+ merchant half of the protocol), implementing the standard x402 v2 wire format.
80
+ - `public/index.html` — the demo page: runtime payout panel, coin picker, and
81
+ designed loading / empty / error / populated states with an on-chain receipt.
@@ -0,0 +1,170 @@
1
+ // Minimal x402 facilitator client — Solana `exact` scheme, USDC on mainnet.
2
+ //
3
+ // The browser modal signs the SPL transfer (Phantom) and posts it back as a
4
+ // base64 `X-PAYMENT` header. The merchant (this server) is responsible for the
5
+ // other half of the protocol: ask a facilitator to (1) /verify the signed
6
+ // payment matches what we offered, then (2) /settle it on-chain. PayAI's public
7
+ // facilitator co-signs as the fee payer and broadcasts — so the merchant never
8
+ // holds a private key and pays no gas.
9
+ //
10
+ // This is the standard x402 v2 verify/settle wire format, trimmed to exactly
11
+ // what the Solana exact path needs so the example stays self-contained and easy
12
+ // to read:
13
+ //
14
+ // POST {facilitator}/verify { x402Version, paymentPayload, paymentRequirements }
15
+ // → { isValid: boolean, invalidReason?, payer? }
16
+ // POST {facilitator}/settle { x402Version, paymentPayload, paymentRequirements }
17
+ // → { success: boolean, transaction?, network?, payer?, errorReason? }
18
+ //
19
+ // No API key required for PayAI's public Solana lane. Override the URL with
20
+ // X402_FACILITATOR_URL if you run your own.
21
+
22
+ import { createHash } from 'node:crypto';
23
+
24
+ export const X402_VERSION = 2;
25
+
26
+ // PayAI supports Solana + Base mainnet with no auth token. The reference
27
+ // x402.org facilitator only settles base-sepolia, so it can't stand in here.
28
+ const FACILITATOR_URL = (process.env.X402_FACILITATOR_URL || 'https://facilitator.payai.network').replace(/\/+$/, '');
29
+ const FACILITATOR_TOKEN = process.env.X402_FACILITATOR_TOKEN || null;
30
+ const TIMEOUT_MS = 20_000;
31
+
32
+ /** Decode the base64-JSON `X-PAYMENT` header into a v2 PaymentPayload. */
33
+ export function decodePaymentHeader(header) {
34
+ if (!header) {
35
+ throw httpError(402, 'payment_required', 'X-PAYMENT header required');
36
+ }
37
+ let json;
38
+ try {
39
+ json = Buffer.from(String(header), 'base64').toString('utf8');
40
+ } catch (err) {
41
+ throw httpError(400, 'invalid_payment', `X-PAYMENT base64 decode failed: ${err.message}`);
42
+ }
43
+ let payload;
44
+ try {
45
+ payload = JSON.parse(json);
46
+ } catch (err) {
47
+ throw httpError(400, 'invalid_payment', `X-PAYMENT JSON parse failed: ${err.message}`);
48
+ }
49
+ if (!payload || typeof payload !== 'object') {
50
+ throw httpError(400, 'invalid_payment', 'X-PAYMENT must decode to a JSON object');
51
+ }
52
+ return payload;
53
+ }
54
+
55
+ /**
56
+ * Verify a decoded payment against the requirement we advertised.
57
+ * Resolves to `{ payer }` when valid; throws a 402 when the facilitator
58
+ * rejects it (so the modal can re-prompt the buyer to pay).
59
+ */
60
+ export async function verifyPayment({ paymentPayload, requirement }) {
61
+ const result = await callFacilitator('/verify', {
62
+ x402Version: X402_VERSION,
63
+ paymentPayload,
64
+ paymentRequirements: requirement,
65
+ });
66
+ if (!result.isValid) {
67
+ throw httpError(402, 'invalid_payment', `payment rejected: ${result.invalidReason || 'unknown reason'}`);
68
+ }
69
+ // Defense-in-depth: a buggy/compromised facilitator must not be able to
70
+ // verify a payment for a different chain than the one we offered.
71
+ if (result.network && result.network !== requirement.network) {
72
+ throw httpError(502, 'facilitator_bad_response', `verify network mismatch: offered ${requirement.network}, got ${result.network}`);
73
+ }
74
+ return { payer: result.payer || null };
75
+ }
76
+
77
+ /**
78
+ * Settle the verified payment on-chain via the facilitator. Carries a
79
+ * deterministic Idempotency-Key so a retried settle (timeout / 5xx) is
80
+ * de-duplicated by the facilitator instead of charging the buyer twice.
81
+ */
82
+ export async function settlePayment({ paymentPayload, requirement }) {
83
+ const idempotencyKey = buildIdempotencyKey({ paymentPayload, requirement });
84
+ const result = await callFacilitator(
85
+ '/settle',
86
+ { x402Version: X402_VERSION, paymentPayload, paymentRequirements: requirement },
87
+ { idempotencyKey },
88
+ );
89
+ if (!result.success) {
90
+ throw httpError(502, 'settle_failed', `settlement failed: ${result.errorReason || 'unknown reason'}`);
91
+ }
92
+ return {
93
+ transaction: result.transaction || null,
94
+ network: result.network || requirement.network,
95
+ payer: result.payer || null,
96
+ };
97
+ }
98
+
99
+ /** Base64-JSON settlement receipt for the `X-PAYMENT-RESPONSE` header. */
100
+ export function encodePaymentResponse(settled) {
101
+ return Buffer.from(
102
+ JSON.stringify({
103
+ success: true,
104
+ transaction: settled.transaction,
105
+ network: settled.network,
106
+ payer: settled.payer,
107
+ }),
108
+ 'utf8',
109
+ ).toString('base64');
110
+ }
111
+
112
+ // ── internals ───────────────────────────────────────────────────────────────
113
+
114
+ function buildIdempotencyKey({ paymentPayload, requirement }) {
115
+ const material = JSON.stringify({
116
+ network: requirement?.network,
117
+ payTo: requirement?.payTo,
118
+ asset: requirement?.asset,
119
+ amount: requirement?.amount,
120
+ scheme: requirement?.scheme,
121
+ payload: paymentPayload,
122
+ });
123
+ return createHash('sha256').update(material).digest('hex').slice(0, 32);
124
+ }
125
+
126
+ async function callFacilitator(path, body, { idempotencyKey } = {}) {
127
+ const headers = { 'content-type': 'application/json', accept: 'application/json' };
128
+ if (FACILITATOR_TOKEN) headers.Authorization = `Bearer ${FACILITATOR_TOKEN}`;
129
+ if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
130
+
131
+ let res;
132
+ try {
133
+ res = await fetch(`${FACILITATOR_URL}${path}`, {
134
+ method: 'POST',
135
+ headers,
136
+ body: JSON.stringify(body),
137
+ signal: AbortSignal.timeout(TIMEOUT_MS),
138
+ });
139
+ } catch (err) {
140
+ throw httpError(502, 'facilitator_unreachable', `facilitator ${path} fetch failed: ${err.message}`);
141
+ }
142
+
143
+ const text = await res.text();
144
+ let data = {};
145
+ if (text) {
146
+ try {
147
+ data = JSON.parse(text);
148
+ } catch {
149
+ throw httpError(502, 'facilitator_bad_response', `facilitator ${path} returned non-JSON (status ${res.status})`);
150
+ }
151
+ }
152
+
153
+ if (!res.ok) {
154
+ // A rejected payment payload is a client problem, not an outage — normalize
155
+ // it so verifyPayment can re-issue a clean 402 instead of a 5xx.
156
+ if (path === '/verify' && (data.isValid === false || res.status === 400)) {
157
+ return { isValid: false, invalidReason: data.invalidReason || data.error || `payment rejected (status ${res.status})` };
158
+ }
159
+ const detail = data.error || data.errorReason || data.message || text.slice(0, 200) || `status ${res.status}`;
160
+ throw httpError(502, 'facilitator_error', `facilitator ${path} ${res.status}: ${detail}`);
161
+ }
162
+ return data;
163
+ }
164
+
165
+ function httpError(status, code, message) {
166
+ const err = new Error(message);
167
+ err.status = status;
168
+ err.code = code;
169
+ return err;
170
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "x402-solana-crypto-paywall",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "description": "Demo: a free crypto-price API paywalled with x402 on Solana, served to the @three-ws/x402-payment-modal. Payout wallet is set at runtime, not from env.",
6
+ "type": "module",
7
+ "scripts": {
8
+ "start": "node server.mjs",
9
+ "dev": "node server.mjs"
10
+ },
11
+ "dependencies": {
12
+ "@three-ws/x402-payment-modal": "*",
13
+ "@solana/web3.js": "^1.95.0",
14
+ "@solana/spl-token": "^0.4.0",
15
+ "express": "^4.19.0"
16
+ }
17
+ }