@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.
@@ -14,7 +14,7 @@
14
14
  //
15
15
  // Runtime deps: @solana/web3.js and @solana/spl-token (declared as optional peer
16
16
  // dependencies — install them in the app that mounts this handler). Nothing here
17
- // imports anything three.ws-specific.
17
+ // is host-specific — it runs unchanged behind any paid endpoint.
18
18
 
19
19
  import {
20
20
  Connection,
@@ -25,6 +25,7 @@ import {
25
25
  } from '@solana/web3.js';
26
26
  import {
27
27
  TOKEN_PROGRAM_ID,
28
+ TOKEN_2022_PROGRAM_ID,
28
29
  ASSOCIATED_TOKEN_PROGRAM_ID,
29
30
  getAssociatedTokenAddressSync,
30
31
  createAssociatedTokenAccountIdempotentInstruction,
@@ -42,11 +43,11 @@ const DEFAULT_MAINNET_RPC = 'https://api.mainnet-beta.solana.com';
42
43
  const DEFAULT_DEVNET_RPC = 'https://api.devnet.solana.com';
43
44
 
44
45
  // ─────────────────────────────────────────────── 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.
46
+ // USDC is the always-on default settlement asset on Solana the universal
47
+ // dollar-stable rail. THREE is an optional opt-in SPL token an endpoint can
48
+ // accept alongside USDC. `solanaAccept()` builds the x402 `accept` entry for
49
+ // either (or any other SPL mint) — the prepare path transfers any mint, so
50
+ // offering an extra token needs no further wiring.
50
51
  export const USDC_MINT_SOLANA = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
51
52
  export const THREE_MINT = 'FeMbDoX7R1Psc4GEcvJdsbNbZA3bfztcyDCatJVJpump';
52
53
 
@@ -141,13 +142,44 @@ function uiToAtomic(uiAmount, decimals) {
141
142
  }
142
143
 
143
144
  // 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 }
145
+ // round-trips. Mint decimals and the owning token program are immutable, so they
146
+ // key by mint alone (shared across RPC providers on the same cluster). A Solana
147
+ // blockhash stays valid ~60-90s; we amortize the fetch for a window but the
148
+ // settle path tolerates a slightly older one. ATA existence only ever flips
149
+ // false→true (an account, once created, persists), so we cache the positive.
150
+ //
151
+ // Keys are cluster-scoped ('mainnet'/'devnet'), never per-RPC-URL, so failover
152
+ // between providers preserves cache hits. All caches are LRU-bounded so a stream
153
+ // of distinct arbitrary mints can't grow them without limit on a warm instance.
154
+ const MINT_META_TTL_MS = 30 * 60 * 1000;
155
+ const BLOCKHASH_TTL_MS = 20 * 1000;
156
+ const ATA_EXISTS_TTL_MS = 10 * 60 * 1000;
157
+ const CACHE_MAX = 2000;
158
+ const mintDecimalsCache = new Map(); // `${cluster}:${mint}` -> { decimals, at }
159
+ const blockhashCache = new Map(); // cluster -> { blockhash, at }
160
+ const tokenProgramCache = new Map(); // `${cluster}:${mint}` -> { programId, at }
161
+ const ataExistsCache = new Map(); // `${cluster}:${ata}` -> { at }
162
+ const connectionCache = new Map(); // rpcUrl -> Connection
163
+
164
+ // Bounded insert: evict the oldest entry (Map preserves insertion order) once
165
+ // the cap is hit, keeping memory flat under arbitrary-mint traffic.
166
+ function cacheSet(map, key, value, max = CACHE_MAX) {
167
+ if (map.size >= max && !map.has(key)) {
168
+ const oldest = map.keys().next().value;
169
+ if (oldest !== undefined) map.delete(oldest);
170
+ }
171
+ map.set(key, value);
172
+ }
173
+
174
+ // Decimals + owning program for the assets the modal treats as first-class.
175
+ // Short-circuiting these skips two RPC reads on the hot path (USDC is the bulk
176
+ // of real traffic). THREE is Token-2022; the rest are legacy SPL Token.
177
+ const WSOL_MINT = 'So11111111111111111111111111111111111111112';
178
+ const WELL_KNOWN_MINT_META = Object.freeze({
179
+ [USDC_MINT_SOLANA]: { decimals: 6, legacy: true },
180
+ [THREE_MINT]: { decimals: 6, legacy: false },
181
+ [WSOL_MINT]: { decimals: 9, legacy: true },
182
+ });
151
183
 
152
184
  /** Thrown for any client-correctable problem; carries an HTTP `status` + `code`. */
153
185
  export class CheckoutError extends Error {
@@ -167,9 +199,60 @@ export function isSolanaNetwork(network) {
167
199
  );
168
200
  }
169
201
 
170
- function rpcFor(network, { rpcUrl, devnetRpcUrl } = {}) {
171
- if (network === NETWORK_SOLANA_DEVNET) return devnetRpcUrl || DEFAULT_DEVNET_RPC;
172
- return rpcUrl || DEFAULT_MAINNET_RPC;
202
+ function clusterFor(network) {
203
+ return network === NETWORK_SOLANA_DEVNET ? 'devnet' : 'mainnet';
204
+ }
205
+
206
+ let warnedDefaultRpc = false;
207
+
208
+ // Resolve the ordered list of RPC endpoints to try. Accepts a single `rpcUrl`
209
+ // or an `rpcUrls` array (for real failover); same for devnet. Falling back to
210
+ // the rate-limited public RPC is a production footgun under load, so warn once.
211
+ function rpcListFor(network, opts = {}) {
212
+ const devnet = network === NETWORK_SOLANA_DEVNET;
213
+ const single = devnet ? opts.devnetRpcUrl : opts.rpcUrl;
214
+ const many = devnet ? opts.devnetRpcUrls : opts.rpcUrls;
215
+ const list = []
216
+ .concat(Array.isArray(many) ? many : [])
217
+ .concat(single ? [single] : [])
218
+ .filter((u) => typeof u === 'string' && u.length);
219
+ if (list.length) return [...new Set(list)];
220
+ if (!warnedDefaultRpc) {
221
+ warnedDefaultRpc = true;
222
+ console.warn(
223
+ '[x402-payment-modal] No rpcUrl/rpcUrls configured — falling back to the public ' +
224
+ 'Solana RPC, which is heavily rate-limited and will fail under load. Pass a ' +
225
+ 'dedicated RPC (Helius/Triton/QuickNode) via { rpcUrls: [...] }.',
226
+ );
227
+ }
228
+ return [devnet ? DEFAULT_DEVNET_RPC : DEFAULT_MAINNET_RPC];
229
+ }
230
+
231
+ // Reuse one Connection per RPC URL so socket keep-alive survives across
232
+ // requests on a warm instance instead of paying TCP/TLS setup every prepare.
233
+ function getConnection(url) {
234
+ let conn = connectionCache.get(url);
235
+ if (!conn) {
236
+ conn = new Connection(url, 'confirmed');
237
+ cacheSet(connectionCache, url, conn, 50);
238
+ }
239
+ return conn;
240
+ }
241
+
242
+ // Run `fn(conn)` against each RPC in order, rotating to the next on a transient
243
+ // RPC/network error. A CheckoutError is a deterministic client problem (bad
244
+ // input, mint isn't an SPL token) — surface it immediately, don't retry.
245
+ async function withFailover(urls, fn) {
246
+ let lastErr;
247
+ for (const url of urls) {
248
+ try {
249
+ return await fn(getConnection(url));
250
+ } catch (err) {
251
+ if (err instanceof CheckoutError) throw err;
252
+ lastErr = err;
253
+ }
254
+ }
255
+ throw lastErr || new Error('all RPC endpoints failed');
173
256
  }
174
257
 
175
258
  const BASE58 = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
@@ -207,20 +290,59 @@ function validateAccept(accept) {
207
290
  return accept;
208
291
  }
209
292
 
210
- async function getMintDecimals(conn, rpc, mint) {
211
- const key = `${rpc}:${mint.toBase58()}`;
293
+ // Resolve which token program owns a mint — legacy SPL Token or Token-2022.
294
+ // Pump.fun mints (including THREE) and many newer assets are Token-2022, whose
295
+ // program id differs; deriving ATAs or building transferChecked with the wrong
296
+ // program yields the wrong accounts and an unprocessable transaction. The owner
297
+ // is immutable, so cache it (and short-circuit the first-class assets entirely).
298
+ async function getTokenProgramId(conn, cluster, mint) {
299
+ const base58 = mint.toBase58();
300
+ const known = WELL_KNOWN_MINT_META[base58];
301
+ if (known) return known.legacy ? TOKEN_PROGRAM_ID : TOKEN_2022_PROGRAM_ID;
302
+ const key = `${cluster}:${base58}`;
303
+ const hit = tokenProgramCache.get(key);
304
+ if (hit && Date.now() - hit.at < MINT_META_TTL_MS) return hit.programId;
305
+ const info = await conn.getAccountInfo(mint, 'confirmed');
306
+ // A null here is usually a flaky RPC, not a missing mint — throw a plain Error
307
+ // so withFailover retries the next endpoint before giving up.
308
+ if (!info) throw new Error(`getAccountInfo returned null for mint ${base58}`);
309
+ const owner = info.owner;
310
+ let programId;
311
+ if (owner.equals(TOKEN_2022_PROGRAM_ID)) programId = TOKEN_2022_PROGRAM_ID;
312
+ else if (owner.equals(TOKEN_PROGRAM_ID)) programId = TOKEN_PROGRAM_ID;
313
+ else throw new CheckoutError(400, 'invalid_request', `mint ${base58} is not an SPL token (owner ${owner.toBase58()})`);
314
+ cacheSet(tokenProgramCache, key, { programId, at: Date.now() });
315
+ return programId;
316
+ }
317
+
318
+ async function getMintDecimals(conn, cluster, mint, programId) {
319
+ const base58 = mint.toBase58();
320
+ const known = WELL_KNOWN_MINT_META[base58];
321
+ if (known) return known.decimals;
322
+ const key = `${cluster}:${base58}`;
212
323
  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() });
324
+ if (hit && Date.now() - hit.at < MINT_META_TTL_MS) return hit.decimals;
325
+ const info = await getMint(conn, mint, 'confirmed', programId);
326
+ cacheSet(mintDecimalsCache, key, { decimals: info.decimals, at: Date.now() });
216
327
  return info.decimals;
217
328
  }
218
329
 
219
- async function getRecentBlockhash(conn, rpc) {
220
- const hit = blockhashCache.get(rpc);
330
+ // Whether the recipient's token account already exists. Once created it persists,
331
+ // so a positive result is cached; a negative is not (we keep emitting the
332
+ // idempotent-create instruction until the account shows up).
333
+ async function receiverAtaExists(conn, cluster, ata) {
334
+ const key = `${cluster}:${ata.toBase58()}`;
335
+ if (ataExistsCache.has(key) && Date.now() - ataExistsCache.get(key).at < ATA_EXISTS_TTL_MS) return true;
336
+ const info = await conn.getAccountInfo(ata, 'confirmed');
337
+ if (info) cacheSet(ataExistsCache, key, { at: Date.now() });
338
+ return Boolean(info);
339
+ }
340
+
341
+ async function getRecentBlockhash(conn, cluster) {
342
+ const hit = blockhashCache.get(cluster);
221
343
  if (hit && Date.now() - hit.at < BLOCKHASH_TTL_MS) return hit.blockhash;
222
344
  const { blockhash } = await conn.getLatestBlockhash('confirmed');
223
- blockhashCache.set(rpc, { blockhash, at: Date.now() });
345
+ cacheSet(blockhashCache, cluster, { blockhash, at: Date.now() }, 8);
224
346
  return blockhash;
225
347
  }
226
348
 
@@ -233,15 +355,17 @@ async function getRecentBlockhash(conn, rpc) {
233
355
  * @param {object} args.accept one x402 `accept` entry (scheme=exact, Solana)
234
356
  * @param {string} args.buyer buyer's base58 Solana address
235
357
  * @param {string} [args.rpcUrl] mainnet RPC URL override
358
+ * @param {string[]} [args.rpcUrls] mainnet RPC URLs for failover (preferred)
236
359
  * @param {string} [args.devnetRpcUrl] devnet RPC URL override
360
+ * @param {string[]} [args.devnetRpcUrls] devnet RPC URLs for failover
237
361
  * @returns {Promise<{ network: string, tx_base64: string, recent_blockhash: string }>}
238
362
  */
239
- export async function prepareSolanaCheckout({ accept, buyer, rpcUrl, devnetRpcUrl }) {
363
+ export async function prepareSolanaCheckout({ accept, buyer, rpcUrl, rpcUrls, devnetRpcUrl, devnetRpcUrls }) {
240
364
  validateAccept(accept);
241
365
  assertPubkey(buyer, 'buyer');
242
366
 
243
- const rpc = rpcFor(accept.network, { rpcUrl, devnetRpcUrl });
244
- const conn = new Connection(rpc, 'confirmed');
367
+ const urls = rpcListFor(accept.network, { rpcUrl, rpcUrls, devnetRpcUrl, devnetRpcUrls });
368
+ const cluster = clusterFor(accept.network);
245
369
 
246
370
  const mint = new PublicKey(accept.asset);
247
371
  const payTo = new PublicKey(accept.payTo);
@@ -249,47 +373,61 @@ export async function prepareSolanaCheckout({ accept, buyer, rpcUrl, devnetRpcUr
249
373
  const buyerPubkey = new PublicKey(buyer);
250
374
  const amount = BigInt(accept.amount);
251
375
 
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) {
376
+ return withFailover(urls, async (conn) => {
377
+ // Pick the owning token program (legacy SPL Token vs Token-2022) so ATAs,
378
+ // the idempotent create, and transferChecked all target the right program —
379
+ // THREE and other pump.fun mints are Token-2022. This must resolve first
380
+ // because the ATA derivations depend on it.
381
+ const tokenProgramId = await getTokenProgramId(conn, cluster, mint);
382
+
383
+ const senderAta = getAssociatedTokenAddressSync(
384
+ mint, buyerPubkey, false, tokenProgramId, ASSOCIATED_TOKEN_PROGRAM_ID,
385
+ );
386
+ const receiverAta = getAssociatedTokenAddressSync(
387
+ mint, payTo, false, tokenProgramId, ASSOCIATED_TOKEN_PROGRAM_ID,
388
+ );
389
+
390
+ // The three remaining reads are independent — fan them out in parallel
391
+ // instead of serially (cuts prepare latency ~40% on a cold cache, near
392
+ // zero when decimals/program/ATA are all cached).
393
+ const [mintDecimals, receiverPresent, blockhash] = await Promise.all([
394
+ getMintDecimals(conn, cluster, mint, tokenProgramId),
395
+ receiverAtaExists(conn, cluster, receiverAta),
396
+ getRecentBlockhash(conn, cluster),
397
+ ]);
398
+
399
+ const ixs = [
400
+ ComputeBudgetProgram.setComputeUnitLimit({ units: 60_000 }),
401
+ ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }),
402
+ ];
403
+ // Create the recipient's token account if it doesn't exist yet — idempotent,
404
+ // paid for by the fee payer so the buyer is never charged extra SOL.
405
+ if (!receiverPresent) {
406
+ ixs.push(
407
+ createAssociatedTokenAccountIdempotentInstruction(
408
+ feePayer, receiverAta, payTo, mint, tokenProgramId, ASSOCIATED_TOKEN_PROGRAM_ID,
409
+ ),
410
+ );
411
+ }
268
412
  ixs.push(
269
- createAssociatedTokenAccountIdempotentInstruction(
270
- feePayer, receiverAta, payTo, mint, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID,
413
+ createTransferCheckedInstruction(
414
+ senderAta, mint, receiverAta, buyerPubkey, amount, mintDecimals, [], tokenProgramId,
271
415
  ),
272
416
  );
273
- }
274
- ixs.push(
275
- createTransferCheckedInstruction(
276
- senderAta, mint, receiverAta, buyerPubkey, amount, mintDecimals, [], TOKEN_PROGRAM_ID,
277
- ),
278
- );
279
417
 
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);
418
+ const message = new TransactionMessage({
419
+ payerKey: feePayer,
420
+ recentBlockhash: blockhash,
421
+ instructions: ixs,
422
+ }).compileToV0Message();
423
+ const vtx = new VersionedTransaction(message);
287
424
 
288
- return {
289
- network: accept.network,
290
- tx_base64: Buffer.from(vtx.serialize()).toString('base64'),
291
- recent_blockhash: blockhash,
292
- };
425
+ return {
426
+ network: accept.network,
427
+ tx_base64: Buffer.from(vtx.serialize()).toString('base64'),
428
+ recent_blockhash: blockhash,
429
+ };
430
+ });
293
431
  }
294
432
 
295
433
  const BUILDER_CODE_PATTERN = /^[a-z0-9_]{1,32}$/;
@@ -351,17 +489,20 @@ export function encodeX402Payment({ accept, signedTxBase64, resourceUrl, builder
351
489
  * @param {object} args
352
490
  * @param {'prepare'|'encode'} args.action
353
491
  * @param {object} args.body parsed JSON request body
354
- * @param {object} [args.options] { rpcUrl, devnetRpcUrl }
492
+ * @param {object} [args.options] { rpcUrl, rpcUrls, devnetRpcUrl, devnetRpcUrls, logger }
355
493
  * @returns {Promise<{ status: number, body: object }>}
356
494
  */
357
495
  export async function handleCheckout({ action, body = {}, options = {} }) {
496
+ const log = typeof options.logger === 'function' ? options.logger : console.error;
358
497
  try {
359
498
  if (action === 'prepare') {
360
499
  const data = await prepareSolanaCheckout({
361
500
  accept: body.accept,
362
501
  buyer: body.buyer,
363
502
  rpcUrl: options.rpcUrl,
503
+ rpcUrls: options.rpcUrls,
364
504
  devnetRpcUrl: options.devnetRpcUrl,
505
+ devnetRpcUrls: options.devnetRpcUrls,
365
506
  });
366
507
  return { status: 200, body: data };
367
508
  }
@@ -382,8 +523,9 @@ export async function handleCheckout({ action, body = {}, options = {} }) {
382
523
  if (err instanceof CheckoutError) {
383
524
  return { status: err.status, body: { error: err.code, error_description: err.message } };
384
525
  }
385
- // Unexpected (RPC down, malformed tx). Surface a generic 502 — the caller
386
- // shows "try again"; the real cause is in your server logs.
526
+ // Unexpected (RPC down, malformed tx). The caller sees a generic 502, but
527
+ // ops needs the root cause log it instead of swallowing it silently.
528
+ log(`[x402-payment-modal] checkout ${action} failed:`, err?.stack || err?.message || err);
387
529
  return {
388
530
  status: 502,
389
531
  body: { error: 'checkout_failed', error_description: 'Could not build the Solana payment. Try again.' },
package/server/express.js CHANGED
@@ -15,9 +15,12 @@ import { handleCheckout } from './checkout.js';
15
15
 
16
16
  /**
17
17
  * @param {object} [options]
18
- * @param {string} [options.rpcUrl] Solana mainnet RPC URL
19
- * @param {string} [options.devnetRpcUrl] Solana devnet RPC URL
20
- * @param {string} [options.origin] Access-Control-Allow-Origin (default '*')
18
+ * @param {string} [options.rpcUrl] Solana mainnet RPC URL
19
+ * @param {string[]} [options.rpcUrls] Solana mainnet RPC URLs for failover (preferred under load)
20
+ * @param {string} [options.devnetRpcUrl] Solana devnet RPC URL
21
+ * @param {string[]} [options.devnetRpcUrls] Solana devnet RPC URLs for failover
22
+ * @param {Function} [options.logger] called with the root cause on unexpected failures
23
+ * @param {string} [options.origin] Access-Control-Allow-Origin (default '*')
21
24
  * @returns {import('express').RequestHandler}
22
25
  */
23
26
  export function x402CheckoutRouter(options = {}) {
@@ -25,7 +28,7 @@ export function x402CheckoutRouter(options = {}) {
25
28
  return async function x402CheckoutHandler(req, res) {
26
29
  res.setHeader('Access-Control-Allow-Origin', allowOrigin);
27
30
  res.setHeader('Access-Control-Allow-Methods', 'POST,OPTIONS');
28
- res.setHeader('Access-Control-Allow-Headers', 'content-type');
31
+ res.setHeader('Access-Control-Allow-Headers', 'content-type, x-idempotency-key');
29
32
  if (req.method === 'OPTIONS') {
30
33
  res.status(204).end();
31
34
  return;
package/server/vercel.js CHANGED
@@ -27,7 +27,7 @@ async function readJsonBody(req) {
27
27
  }
28
28
 
29
29
  /**
30
- * @param {object} [options] { rpcUrl, devnetRpcUrl, origin }
30
+ * @param {object} [options] { rpcUrl, rpcUrls, devnetRpcUrl, devnetRpcUrls, logger, origin }
31
31
  * @returns {(req: any, res: any) => Promise<void>}
32
32
  */
33
33
  export function createVercelCheckoutHandler(options = {}) {
@@ -35,7 +35,7 @@ export function createVercelCheckoutHandler(options = {}) {
35
35
  return async function handler(req, res) {
36
36
  res.setHeader('Access-Control-Allow-Origin', allowOrigin);
37
37
  res.setHeader('Access-Control-Allow-Methods', 'POST,OPTIONS');
38
- res.setHeader('Access-Control-Allow-Headers', 'content-type');
38
+ res.setHeader('Access-Control-Allow-Headers', 'content-type, x-idempotency-key');
39
39
  if (req.method === 'OPTIONS') {
40
40
  res.status(204).end();
41
41
  return;