@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,89 @@
1
+ // Runnable Express server for @three-ws/x402-payment-modal.
2
+ //
3
+ // What it does:
4
+ // 1. Mounts the Solana checkout router at /api/x402-checkout. The browser
5
+ // modal posts here to build and settle Solana USDC transfers. (EVM
6
+ // payments sign in-browser and never hit this server.)
7
+ // 2. Serves a trivial static page from ./public so you can click through the
8
+ // flow in a real browser.
9
+ // 3. Exposes ONE demo paid endpoint, GET /api/paid/hello, that returns a
10
+ // correct x402 v2 challenge so you can see the 402 → pay loop end to end.
11
+ //
12
+ // Run:
13
+ // npm install
14
+ // SOLANA_RPC_URL="https://your-rpc" npm start
15
+ // open http://localhost:3000
16
+
17
+ import express from 'express';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { dirname, join } from 'node:path';
20
+ import { x402CheckoutRouter } from '@three-ws/x402-payment-modal/server/express';
21
+ import { solanaAccept } from '@three-ws/x402-payment-modal/server';
22
+
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+ const PORT = process.env.PORT || 3000;
25
+
26
+ // A public Solana mainnet RPC works for a quick try, but it is rate-limited.
27
+ // Use a dedicated RPC (Helius, Triton, QuickNode, …) for anything real.
28
+ const SOLANA_RPC_URL =
29
+ process.env.SOLANA_RPC_URL || 'https://api.mainnet-beta.solana.com';
30
+
31
+ const app = express();
32
+
33
+ // Required by the checkout router — it reads JSON request bodies.
34
+ app.use(express.json());
35
+
36
+ // The checkout endpoint the browser modal talks to for Solana payments.
37
+ app.use('/api/x402-checkout', x402CheckoutRouter({ rpcUrl: SOLANA_RPC_URL }));
38
+
39
+ // ── Demo paid endpoint ─────────────────────────────────────────────────────
40
+ //
41
+ // A real x402 resource answers with HTTP 402 + a challenge until it receives a
42
+ // valid X-PAYMENT header, then serves the content. Here we only implement the
43
+ // challenge half so you can watch the modal react to a 402.
44
+ //
45
+ // IMPORTANT: verifying and settling the X-PAYMENT payload against an x402
46
+ // facilitator is OUT OF SCOPE for this demo. In production you would verify the
47
+ // payment proof (and idempotency) before returning 200. See the docs:
48
+ // https://github.com/three-ws/x402-payment-modal/tree/main/docs
49
+ //
50
+ // Synthetic placeholders below — replace payTo / feePayer with YOUR addresses.
51
+ const DEMO_PAY_TO = 'So11111111111111111111111111111111111111112'; // replace me
52
+ const DEMO_FEE_PAYER = 'So11111111111111111111111111111111111111112'; // replace me
53
+
54
+ app.get('/api/paid/hello', (req, res) => {
55
+ const hasPayment = Boolean(req.get('X-PAYMENT'));
56
+
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.
61
+ const common = { payTo: DEMO_PAY_TO, feePayer: DEMO_FEE_PAYER, maxTimeoutSeconds: 60 };
62
+ return res.status(402).json({
63
+ x402Version: 2,
64
+ error: 'Payment required',
65
+ resource: {
66
+ url: `${req.protocol}://${req.get('host')}/api/paid/hello`,
67
+ description: 'A friendly hello — pay in USDC or THREE.',
68
+ mimeType: 'application/json',
69
+ },
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
+ ],
74
+ });
75
+ }
76
+
77
+ // A real implementation verifies the X-PAYMENT proof here before responding.
78
+ return res.json({ message: 'Hello — thanks for paying on Solana!' });
79
+ });
80
+
81
+ // Static demo page (examples/server-express/public/index.html).
82
+ app.use(express.static(join(__dirname, 'public')));
83
+
84
+ app.listen(PORT, () => {
85
+ console.log(`x402 checkout example running at http://localhost:${PORT}`);
86
+ console.log(` checkout router → /api/x402-checkout`);
87
+ console.log(` demo paid route → /api/paid/hello`);
88
+ console.log(` Solana RPC → ${SOLANA_RPC_URL}`);
89
+ });
package/package.json ADDED
@@ -0,0 +1,113 @@
1
+ {
2
+ "name": "@three-ws/x402-payment-modal",
3
+ "version": "1.1.0",
4
+ "description": "Drop-in x402 payment modal for any paid HTTP endpoint. One ES module, zero runtime dependencies — handles wallet connect (Phantom/Solana + EVM via EIP-3009), the 402 → sign → settle flow, SIWX re-entry, client-side spending caps, and a receipt. Vanilla JS, no bundler required.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "module": "./src/index.js",
8
+ "types": "./types/index.d.ts",
9
+ "browser": "./dist/x402.min.js",
10
+ "unpkg": "./dist/x402.min.js",
11
+ "jsdelivr": "./dist/x402.min.js",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./types/index.d.ts",
15
+ "import": "./src/index.js",
16
+ "default": "./src/index.js"
17
+ },
18
+ "./min": {
19
+ "types": "./types/index.d.ts",
20
+ "default": "./dist/x402.min.js"
21
+ },
22
+ "./server": {
23
+ "types": "./types/server.d.ts",
24
+ "import": "./server/checkout.js",
25
+ "default": "./server/checkout.js"
26
+ },
27
+ "./server/express": {
28
+ "import": "./server/express.js",
29
+ "default": "./server/express.js"
30
+ },
31
+ "./server/vercel": {
32
+ "import": "./server/vercel.js",
33
+ "default": "./server/vercel.js"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "dist",
40
+ "types",
41
+ "server",
42
+ "examples",
43
+ "docs",
44
+ "README.md",
45
+ "TUTORIAL.md",
46
+ "CHANGELOG.md",
47
+ "LICENSE"
48
+ ],
49
+ "scripts": {
50
+ "build": "node build.mjs",
51
+ "test": "node --test \"test/*.test.js\"",
52
+ "prepublishOnly": "npm run build"
53
+ },
54
+ "keywords": [
55
+ "x402",
56
+ "payments",
57
+ "micropayments",
58
+ "paywall",
59
+ "pay-per-call",
60
+ "usdc",
61
+ "stablecoin",
62
+ "solana",
63
+ "phantom",
64
+ "evm",
65
+ "base",
66
+ "metamask",
67
+ "eip-3009",
68
+ "siwx",
69
+ "wallet",
70
+ "checkout",
71
+ "modal",
72
+ "agentic",
73
+ "web3",
74
+ "402",
75
+ "three-ws"
76
+ ],
77
+ "author": "three.ws <support@three.ws> (https://three.ws)",
78
+ "license": "Apache-2.0",
79
+ "homepage": "https://three.ws",
80
+ "repository": {
81
+ "type": "git",
82
+ "url": "git+https://github.com/nirholas/three.ws.git",
83
+ "directory": "x402-payment-modal"
84
+ },
85
+ "bugs": {
86
+ "url": "https://github.com/nirholas/three.ws/issues"
87
+ },
88
+ "peerDependencies": {
89
+ "@solana/web3.js": "^1.95.0",
90
+ "@solana/spl-token": "^0.4.0"
91
+ },
92
+ "peerDependenciesMeta": {
93
+ "@solana/web3.js": {
94
+ "optional": true
95
+ },
96
+ "@solana/spl-token": {
97
+ "optional": true
98
+ }
99
+ },
100
+ "devDependencies": {
101
+ "esbuild": "^0.21.0"
102
+ },
103
+ "engines": {
104
+ "node": ">=18"
105
+ },
106
+ "publishConfig": {
107
+ "access": "public"
108
+ },
109
+ "sideEffects": [
110
+ "./src/index.js",
111
+ "./dist/x402.min.js"
112
+ ]
113
+ }
@@ -0,0 +1,68 @@
1
+ # Server — Solana checkout helpers
2
+
3
+ The modal's **EVM** path signs EIP-3009 typed-data in the browser and never calls
4
+ your server. The **Solana** path needs a small endpoint because Phantom signs
5
+ serialized transactions but doesn't build instructions: this module builds the
6
+ SPL `transferChecked` the buyer signs, then wraps the signed transaction into the
7
+ base64 `X-PAYMENT` envelope the x402 facilitator expects.
8
+
9
+ > If you only accept Base/EVM USDC, you don't need any of this.
10
+
11
+ ## Install peer deps
12
+
13
+ ```bash
14
+ npm install @solana/web3.js @solana/spl-token
15
+ ```
16
+
17
+ ## Mount it
18
+
19
+ **Express**
20
+
21
+ ```js
22
+ import express from 'express';
23
+ import { x402CheckoutRouter } from '@three-ws/x402-payment-modal/server/express';
24
+
25
+ const app = express();
26
+ app.use(express.json());
27
+ app.use('/api/x402-checkout', x402CheckoutRouter({ rpcUrl: process.env.SOLANA_RPC_URL }));
28
+ ```
29
+
30
+ **Vercel / Next.js** — `api/x402-checkout.js`
31
+
32
+ ```js
33
+ export { default } from '@three-ws/x402-payment-modal/server/vercel';
34
+ // or, with options:
35
+ // import { createVercelCheckoutHandler } from '@three-ws/x402-payment-modal/server/vercel';
36
+ // export default createVercelCheckoutHandler({ rpcUrl: process.env.SOLANA_RPC_URL });
37
+ ```
38
+
39
+ **Anything else** — use the core router:
40
+
41
+ ```js
42
+ import { handleCheckout } from '@three-ws/x402-payment-modal/server';
43
+
44
+ const { status, body } = await handleCheckout({
45
+ action: url.searchParams.get('action'), // 'prepare' | 'encode'
46
+ body: await readJson(req),
47
+ options: { rpcUrl: process.env.SOLANA_RPC_URL },
48
+ });
49
+ ```
50
+
51
+ ## Exports
52
+
53
+ | Export | Description |
54
+ | --- | --- |
55
+ | `prepareSolanaCheckout({ accept, buyer, rpcUrl?, devnetRpcUrl? })` | Build the partially-signed v0 transaction the buyer signs. |
56
+ | `encodeX402Payment({ accept, signedTxBase64, resourceUrl, builderCode? })` | Wrap the signed tx into the base64 `X-PAYMENT` envelope. |
57
+ | `handleCheckout({ action, body, options? })` | Route `prepare`/`encode`; returns `{ status, body }`. |
58
+ | `CheckoutError` | `Error` subclass with `.status` and `.code`. |
59
+ | `isSolanaNetwork(network)` | CAIP-2 / `solana` alias check. |
60
+ | `X402_VERSION`, `NETWORK_SOLANA_MAINNET`, `NETWORK_SOLANA_DEVNET` | Constants. |
61
+
62
+ Adapters: `./express` → `x402CheckoutRouter(options)`, `./vercel` →
63
+ `createVercelCheckoutHandler(options)` (default export is a ready handler). Both
64
+ set permissive CORS (`origin: '*'`) by default and handle the `OPTIONS`
65
+ preflight; pass `{ origin }` to restrict it.
66
+
67
+ See [../docs/server-setup.md](../docs/server-setup.md) for the request/response
68
+ shapes and environment variables.
@@ -0,0 +1,392 @@
1
+ // @three-ws/x402-payment-modal/server — Solana checkout helpers.
2
+ //
3
+ // The modal's EVM path signs EIP-3009 typed-data entirely in the browser and
4
+ // never calls your server. The Solana path is different: Phantom only *signs*
5
+ // serialized transactions — it does not build instructions. So the modal needs a
6
+ // tiny server endpoint that (a) builds the SPL transfer the buyer should sign,
7
+ // and (b) wraps the signed transaction into the base64 `X-PAYMENT` envelope the
8
+ // x402 facilitator expects.
9
+ //
10
+ // This module is framework-agnostic. `prepareSolanaCheckout` and
11
+ // `encodeX402Payment` are pure functions; `handleCheckout` routes the two
12
+ // actions and returns `{ status, body }`. Thin adapters for Express and Vercel
13
+ // live in ./express.js and ./vercel.js.
14
+ //
15
+ // Runtime deps: @solana/web3.js and @solana/spl-token (declared as optional peer
16
+ // dependencies — install them in the app that mounts this handler). Nothing here
17
+ // imports anything three.ws-specific.
18
+
19
+ import {
20
+ Connection,
21
+ PublicKey,
22
+ TransactionMessage,
23
+ VersionedTransaction,
24
+ ComputeBudgetProgram,
25
+ } from '@solana/web3.js';
26
+ import {
27
+ TOKEN_PROGRAM_ID,
28
+ ASSOCIATED_TOKEN_PROGRAM_ID,
29
+ getAssociatedTokenAddressSync,
30
+ createAssociatedTokenAccountIdempotentInstruction,
31
+ createTransferCheckedInstruction,
32
+ getMint,
33
+ } from '@solana/spl-token';
34
+
35
+ export const X402_VERSION = 2;
36
+
37
+ // CAIP-2 network identifiers for Solana (genesis-hash prefixes).
38
+ export const NETWORK_SOLANA_MAINNET = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp';
39
+ export const NETWORK_SOLANA_DEVNET = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1';
40
+
41
+ const DEFAULT_MAINNET_RPC = 'https://api.mainnet-beta.solana.com';
42
+ const DEFAULT_DEVNET_RPC = 'https://api.devnet.solana.com';
43
+
44
+ // ─────────────────────────────────────────────── Well-known Solana tokens ────
45
+ // The two settlement assets the modal treats as first-class on Solana. USDC is
46
+ // the universal dollar-stable rail; THREE is the three.ws utility token, so any
47
+ // endpoint can let holders pay in THREE alongside USDC. `solanaAccept()` builds
48
+ // the x402 `accept` entry for either (or any other SPL mint) — the prepare path
49
+ // already transfers any mint, so offering THREE needs no further wiring.
50
+ export const USDC_MINT_SOLANA = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
51
+ export const THREE_MINT = 'FeMbDoX7R1Psc4GEcvJdsbNbZA3bfztcyDCatJVJpump';
52
+
53
+ export const WELL_KNOWN_SOLANA_TOKENS = Object.freeze({
54
+ usdc: { mint: USDC_MINT_SOLANA, symbol: 'USDC', name: 'USD Coin', decimals: 6 },
55
+ three: { mint: THREE_MINT, symbol: 'THREE', name: 'THREE', decimals: 6 },
56
+ });
57
+
58
+ /**
59
+ * Build one x402 Solana `accept` entry for a merchant's 402 challenge.
60
+ *
61
+ * Pass `token: 'usdc' | 'three'` for a well-known asset, or an explicit `mint`
62
+ * (+ optional `decimals`/`name`) for any other SPL token. Supply the price as
63
+ * either `amount` (atomic integer string) or `uiAmount` (human units, converted
64
+ * with the token's decimals). `feePayer` is the facilitator sponsor that pays
65
+ * the SOL network fee so buyers need only the token itself.
66
+ *
67
+ * @param {object} args
68
+ * @param {'usdc'|'three'} [args.token] well-known token shortcut
69
+ * @param {string} [args.mint] explicit SPL mint (base58); overrides token
70
+ * @param {string} args.payTo recipient address (base58)
71
+ * @param {string} args.feePayer facilitator sponsor address (base58)
72
+ * @param {string|number|bigint} [args.amount] atomic integer amount
73
+ * @param {string|number} [args.uiAmount] human amount (e.g. 0.25) — converted via decimals
74
+ * @param {number} [args.decimals] token decimals (default: well-known or 6)
75
+ * @param {string} [args.name] display name for the modal (default: well-known symbol)
76
+ * @param {string} [args.network] CAIP-2 network (default: Solana mainnet)
77
+ * @param {number} [args.maxTimeoutSeconds]
78
+ * @returns {SolanaAccept}
79
+ */
80
+ export function solanaAccept({
81
+ token,
82
+ mint,
83
+ payTo,
84
+ feePayer,
85
+ amount,
86
+ uiAmount,
87
+ decimals,
88
+ name,
89
+ network = NETWORK_SOLANA_MAINNET,
90
+ maxTimeoutSeconds,
91
+ } = {}) {
92
+ const known = token ? WELL_KNOWN_SOLANA_TOKENS[String(token).toLowerCase()] : null;
93
+ if (token && !known) {
94
+ throw new CheckoutError(400, 'invalid_request', `unknown token '${token}'. Use 'usdc', 'three', or pass an explicit mint.`);
95
+ }
96
+ const asset = mint || known?.mint;
97
+ if (!asset) {
98
+ throw new CheckoutError(400, 'invalid_request', "solanaAccept needs a token ('usdc'|'three') or an explicit mint");
99
+ }
100
+ assertPubkey(asset, 'mint');
101
+ assertPubkey(payTo, 'payTo');
102
+ assertPubkey(feePayer, 'feePayer');
103
+
104
+ const dec = Number.isFinite(decimals) ? decimals : (known?.decimals ?? 6);
105
+ let atomic;
106
+ if (amount != null) {
107
+ atomic = BigInt(amount).toString();
108
+ } else if (uiAmount != null) {
109
+ atomic = uiToAtomic(uiAmount, dec);
110
+ } else {
111
+ throw new CheckoutError(400, 'invalid_request', 'solanaAccept needs amount (atomic) or uiAmount (human)');
112
+ }
113
+
114
+ const accept = {
115
+ scheme: 'exact',
116
+ network,
117
+ amount: atomic,
118
+ asset,
119
+ payTo,
120
+ extra: {
121
+ name: name || known?.name || 'USDC',
122
+ decimals: dec,
123
+ feePayer,
124
+ },
125
+ };
126
+ if (maxTimeoutSeconds != null) accept.maxTimeoutSeconds = maxTimeoutSeconds;
127
+ return accept;
128
+ }
129
+
130
+ // Convert a human token amount (e.g. "0.25", 1.5) to an atomic integer string
131
+ // without floating-point drift: split on the decimal point and pad/truncate.
132
+ function uiToAtomic(uiAmount, decimals) {
133
+ const s = String(uiAmount).trim();
134
+ if (!/^\d+(\.\d+)?$/.test(s)) {
135
+ throw new CheckoutError(400, 'invalid_request', `uiAmount must be a non-negative number, got '${uiAmount}'`);
136
+ }
137
+ const [whole, frac = ''] = s.split('.');
138
+ const fracPadded = (frac + '0'.repeat(decimals)).slice(0, decimals);
139
+ const atomic = BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fracPadded || '0');
140
+ return atomic.toString();
141
+ }
142
+
143
+ // Short-lived caches so repeated prepare calls don't re-issue identical RPC
144
+ // round-trips. Mint decimals are effectively immutable; a Solana blockhash stays
145
+ // valid for ~60-90s, so a few seconds of reuse cuts redundant traffic without
146
+ // handing out a blockhash too stale for the buyer's signed tx to land.
147
+ const MINT_DECIMALS_TTL_MS = 5 * 60 * 1000;
148
+ const BLOCKHASH_TTL_MS = 8 * 1000;
149
+ const mintDecimalsCache = new Map(); // `${rpc}:${mint}` -> { decimals, at }
150
+ const blockhashCache = new Map(); // rpc -> { blockhash, at }
151
+
152
+ /** Thrown for any client-correctable problem; carries an HTTP `status` + `code`. */
153
+ export class CheckoutError extends Error {
154
+ constructor(status, code, message) {
155
+ super(message);
156
+ this.name = 'CheckoutError';
157
+ this.status = status;
158
+ this.code = code;
159
+ }
160
+ }
161
+
162
+ export function isSolanaNetwork(network) {
163
+ return (
164
+ network === NETWORK_SOLANA_MAINNET ||
165
+ network === NETWORK_SOLANA_DEVNET ||
166
+ network === 'solana'
167
+ );
168
+ }
169
+
170
+ function rpcFor(network, { rpcUrl, devnetRpcUrl } = {}) {
171
+ if (network === NETWORK_SOLANA_DEVNET) return devnetRpcUrl || DEFAULT_DEVNET_RPC;
172
+ return rpcUrl || DEFAULT_MAINNET_RPC;
173
+ }
174
+
175
+ const BASE58 = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
176
+ function assertPubkey(value, field) {
177
+ if (typeof value !== 'string' || !BASE58.test(value)) {
178
+ throw new CheckoutError(400, 'invalid_request', `${field} must be a base58 Solana address`);
179
+ }
180
+ return value;
181
+ }
182
+
183
+ // Validate the subset of the x402 `accept` entry the Solana path relies on.
184
+ // `extra.feePayer` is required — it is the facilitator's sponsor account that
185
+ // pays the SOL network fee so the buyer needs no SOL, only USDC.
186
+ function validateAccept(accept) {
187
+ if (!accept || typeof accept !== 'object') {
188
+ throw new CheckoutError(400, 'invalid_request', 'accept object is required');
189
+ }
190
+ if (accept.scheme !== 'exact') {
191
+ throw new CheckoutError(400, 'invalid_request', `unsupported scheme: ${accept.scheme}`);
192
+ }
193
+ if (!isSolanaNetwork(accept.network)) {
194
+ throw new CheckoutError(
195
+ 400,
196
+ 'unsupported_network',
197
+ `prepare only builds Solana transactions; got network=${accept.network}. EVM clients sign EIP-712 typed data locally and don't need this endpoint.`,
198
+ );
199
+ }
200
+ if (typeof accept.amount !== 'string' || !/^\d+$/.test(accept.amount)) {
201
+ throw new CheckoutError(400, 'invalid_request', 'accept.amount must be an atomic integer string');
202
+ }
203
+ assertPubkey(accept.asset, 'accept.asset');
204
+ assertPubkey(accept.payTo, 'accept.payTo');
205
+ const feePayer = accept.extra?.feePayer;
206
+ assertPubkey(feePayer, 'accept.extra.feePayer');
207
+ return accept;
208
+ }
209
+
210
+ async function getMintDecimals(conn, rpc, mint) {
211
+ const key = `${rpc}:${mint.toBase58()}`;
212
+ const hit = mintDecimalsCache.get(key);
213
+ if (hit && Date.now() - hit.at < MINT_DECIMALS_TTL_MS) return hit.decimals;
214
+ const info = await getMint(conn, mint);
215
+ mintDecimalsCache.set(key, { decimals: info.decimals, at: Date.now() });
216
+ return info.decimals;
217
+ }
218
+
219
+ async function getRecentBlockhash(conn, rpc) {
220
+ const hit = blockhashCache.get(rpc);
221
+ if (hit && Date.now() - hit.at < BLOCKHASH_TTL_MS) return hit.blockhash;
222
+ const { blockhash } = await conn.getLatestBlockhash('confirmed');
223
+ blockhashCache.set(rpc, { blockhash, at: Date.now() });
224
+ return blockhash;
225
+ }
226
+
227
+ /**
228
+ * Build the partially-signed v0 SPL `transferChecked` the buyer's Phantom wallet
229
+ * should add its signature to. The fee payer is `accept.extra.feePayer` (the
230
+ * facilitator's sponsor), so the buyer pays only USDC.
231
+ *
232
+ * @param {object} args
233
+ * @param {object} args.accept one x402 `accept` entry (scheme=exact, Solana)
234
+ * @param {string} args.buyer buyer's base58 Solana address
235
+ * @param {string} [args.rpcUrl] mainnet RPC URL override
236
+ * @param {string} [args.devnetRpcUrl] devnet RPC URL override
237
+ * @returns {Promise<{ network: string, tx_base64: string, recent_blockhash: string }>}
238
+ */
239
+ export async function prepareSolanaCheckout({ accept, buyer, rpcUrl, devnetRpcUrl }) {
240
+ validateAccept(accept);
241
+ assertPubkey(buyer, 'buyer');
242
+
243
+ const rpc = rpcFor(accept.network, { rpcUrl, devnetRpcUrl });
244
+ const conn = new Connection(rpc, 'confirmed');
245
+
246
+ const mint = new PublicKey(accept.asset);
247
+ const payTo = new PublicKey(accept.payTo);
248
+ const feePayer = new PublicKey(accept.extra.feePayer);
249
+ const buyerPubkey = new PublicKey(buyer);
250
+ const amount = BigInt(accept.amount);
251
+
252
+ const senderAta = getAssociatedTokenAddressSync(
253
+ mint, buyerPubkey, false, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID,
254
+ );
255
+ const receiverAta = getAssociatedTokenAddressSync(
256
+ mint, payTo, false, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID,
257
+ );
258
+ const mintDecimals = await getMintDecimals(conn, rpc, mint);
259
+
260
+ const ixs = [
261
+ ComputeBudgetProgram.setComputeUnitLimit({ units: 60_000 }),
262
+ ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }),
263
+ ];
264
+ // Create the recipient's token account if it doesn't exist yet — idempotent,
265
+ // paid for by the fee payer so the buyer is never charged extra SOL.
266
+ const receiverInfo = await conn.getAccountInfo(receiverAta);
267
+ if (!receiverInfo) {
268
+ ixs.push(
269
+ createAssociatedTokenAccountIdempotentInstruction(
270
+ feePayer, receiverAta, payTo, mint, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID,
271
+ ),
272
+ );
273
+ }
274
+ ixs.push(
275
+ createTransferCheckedInstruction(
276
+ senderAta, mint, receiverAta, buyerPubkey, amount, mintDecimals, [], TOKEN_PROGRAM_ID,
277
+ ),
278
+ );
279
+
280
+ const blockhash = await getRecentBlockhash(conn, rpc);
281
+ const message = new TransactionMessage({
282
+ payerKey: feePayer,
283
+ recentBlockhash: blockhash,
284
+ instructions: ixs,
285
+ }).compileToV0Message();
286
+ const vtx = new VersionedTransaction(message);
287
+
288
+ return {
289
+ network: accept.network,
290
+ tx_base64: Buffer.from(vtx.serialize()).toString('base64'),
291
+ recent_blockhash: blockhash,
292
+ };
293
+ }
294
+
295
+ const BUILDER_CODE_PATTERN = /^[a-z0-9_]{1,32}$/;
296
+ function sanitizeBuilderCode(builderCode) {
297
+ if (!builderCode || typeof builderCode !== 'object') return null;
298
+ const a = builderCode.a;
299
+ if (typeof a !== 'string' || !BUILDER_CODE_PATTERN.test(a)) return null;
300
+ const out = { a };
301
+ if (typeof builderCode.w === 'string' && BUILDER_CODE_PATTERN.test(builderCode.w)) out.w = builderCode.w;
302
+ if (Array.isArray(builderCode.s)) {
303
+ const s = builderCode.s.filter((x) => typeof x === 'string' && BUILDER_CODE_PATTERN.test(x)).slice(0, 32);
304
+ if (s.length) out.s = s;
305
+ }
306
+ return out;
307
+ }
308
+
309
+ /**
310
+ * Wrap a buyer-signed Solana transaction into the base64 `X-PAYMENT` envelope.
311
+ *
312
+ * @param {object} args
313
+ * @param {object} args.accept the same accept entry used in prepare
314
+ * @param {string} args.signedTxBase64 base64 of the fully buyer-signed v0 tx
315
+ * @param {string} args.resourceUrl absolute URL of the paid resource
316
+ * @param {object} [args.builderCode] optional ERC-8021 builder-code echo
317
+ * @returns {{ x_payment: string }}
318
+ */
319
+ export function encodeX402Payment({ accept, signedTxBase64, resourceUrl, builderCode }) {
320
+ validateAccept(accept);
321
+ if (typeof signedTxBase64 !== 'string' || signedTxBase64.length < 40) {
322
+ throw new CheckoutError(400, 'invalid_request', 'signedTxBase64 is required');
323
+ }
324
+ let url;
325
+ try {
326
+ url = new URL(resourceUrl).href;
327
+ } catch {
328
+ throw new CheckoutError(400, 'invalid_request', 'resourceUrl must be an absolute URL');
329
+ }
330
+
331
+ const payload = {
332
+ x402Version: X402_VERSION,
333
+ scheme: 'exact',
334
+ network: accept.network,
335
+ resource: { url, mimeType: 'application/json' },
336
+ accepted: accept,
337
+ payload: { transaction: signedTxBase64 },
338
+ };
339
+ const echo = sanitizeBuilderCode(builderCode);
340
+ if (echo) payload.extensions = { 'builder-code': echo };
341
+
342
+ return { x_payment: Buffer.from(JSON.stringify(payload), 'utf8').toString('base64') };
343
+ }
344
+
345
+ /**
346
+ * Route an action ('prepare' | 'encode') to its handler and return a plain
347
+ * `{ status, body }` pair the framework adapters serialize as JSON. Accepts the
348
+ * camelCase fields the helpers use *and* the snake_case fields the browser modal
349
+ * POSTs (`signed_tx_base64`, `resource_url`, `builder_code`).
350
+ *
351
+ * @param {object} args
352
+ * @param {'prepare'|'encode'} args.action
353
+ * @param {object} args.body parsed JSON request body
354
+ * @param {object} [args.options] { rpcUrl, devnetRpcUrl }
355
+ * @returns {Promise<{ status: number, body: object }>}
356
+ */
357
+ export async function handleCheckout({ action, body = {}, options = {} }) {
358
+ try {
359
+ if (action === 'prepare') {
360
+ const data = await prepareSolanaCheckout({
361
+ accept: body.accept,
362
+ buyer: body.buyer,
363
+ rpcUrl: options.rpcUrl,
364
+ devnetRpcUrl: options.devnetRpcUrl,
365
+ });
366
+ return { status: 200, body: data };
367
+ }
368
+ if (action === 'encode') {
369
+ const data = encodeX402Payment({
370
+ accept: body.accept,
371
+ signedTxBase64: body.signedTxBase64 ?? body.signed_tx_base64,
372
+ resourceUrl: body.resourceUrl ?? body.resource_url,
373
+ builderCode: body.builderCode ?? body.builder_code,
374
+ });
375
+ return { status: 200, body: data };
376
+ }
377
+ return {
378
+ status: 404,
379
+ body: { error: 'not_found', error_description: `unknown action: ${action ?? '(none)'}` },
380
+ };
381
+ } catch (err) {
382
+ if (err instanceof CheckoutError) {
383
+ return { status: err.status, body: { error: err.code, error_description: err.message } };
384
+ }
385
+ // Unexpected (RPC down, malformed tx). Surface a generic 502 — the caller
386
+ // shows "try again"; the real cause is in your server logs.
387
+ return {
388
+ status: 502,
389
+ body: { error: 'checkout_failed', error_description: 'Could not build the Solana payment. Try again.' },
390
+ };
391
+ }
392
+ }