@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.
- package/CHANGELOG.md +71 -9
- package/CONTRIBUTING.md +79 -0
- package/LICENSE +38 -180
- package/README.md +238 -63
- package/dist/index.d.ts +14 -3
- package/dist/x402.js +564 -206
- package/dist/x402.min.js +308 -178
- package/docs/EXAMPLES.md +137 -0
- package/docs/api-reference.md +32 -5
- package/docs/architecture.md +7 -1
- package/docs/react.md +163 -0
- package/docs/server-setup.md +63 -6
- package/examples/README.md +2 -1
- package/examples/react/App.jsx +95 -0
- package/examples/react/README.md +34 -31
- package/examples/server-express/server.js +16 -9
- package/examples/solana-crypto-paywall/README.md +81 -0
- package/examples/solana-crypto-paywall/facilitator.mjs +170 -0
- package/examples/solana-crypto-paywall/package.json +17 -0
- package/examples/solana-crypto-paywall/public/index.html +506 -0
- package/examples/solana-crypto-paywall/server.mjs +279 -0
- package/package.json +126 -111
- package/react/index.d.ts +39 -0
- package/react/index.js +112 -0
- package/server/checkout.js +208 -66
- package/server/express.js +7 -4
- package/server/vercel.js +2 -2
- package/src/index.js +563 -205
- package/types/index.d.ts +14 -3
- package/types/server.d.ts +2 -1
- package/examples/react/X402Button.jsx +0 -84
package/server/checkout.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
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
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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 <
|
|
214
|
-
const info = await getMint(conn, mint);
|
|
215
|
-
mintDecimalsCache
|
|
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
|
-
|
|
220
|
-
|
|
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
|
|
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
|
|
244
|
-
const
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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).
|
|
386
|
-
//
|
|
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}
|
|
19
|
-
* @param {string} [options.
|
|
20
|
-
* @param {string}
|
|
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;
|