mppx 0.8.7 → 0.8.9

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/cli/plugins/stripe.d.ts.map +1 -1
  3. package/dist/cli/plugins/stripe.js +3 -2
  4. package/dist/cli/plugins/stripe.js.map +1 -1
  5. package/dist/cli/validate/challenge.d.ts.map +1 -1
  6. package/dist/cli/validate/challenge.js +10 -9
  7. package/dist/cli/validate/challenge.js.map +1 -1
  8. package/dist/cli/validate/index.d.ts.map +1 -1
  9. package/dist/cli/validate/index.js +5 -3
  10. package/dist/cli/validate/index.js.map +1 -1
  11. package/dist/cli/validate/payment.d.ts +3 -1
  12. package/dist/cli/validate/payment.d.ts.map +1 -1
  13. package/dist/cli/validate/payment.js +281 -146
  14. package/dist/cli/validate/payment.js.map +1 -1
  15. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  16. package/dist/tempo/internal/fee-payer.js +4 -1
  17. package/dist/tempo/internal/fee-payer.js.map +1 -1
  18. package/dist/tempo/session/precompile/Chain.d.ts +6 -6
  19. package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
  20. package/dist/tempo/session/precompile/Chain.js +4 -0
  21. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  22. package/dist/tempo/session/server/CredentialVerification.d.ts +2 -2
  23. package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -1
  24. package/dist/tempo/session/server/CredentialVerification.js +1 -1
  25. package/dist/tempo/session/server/CredentialVerification.js.map +1 -1
  26. package/dist/tempo/session/server/RequestState.d.ts +4 -4
  27. package/dist/tempo/session/server/RequestState.d.ts.map +1 -1
  28. package/dist/tempo/session/server/RequestState.js.map +1 -1
  29. package/dist/tempo/session/server/Session.d.ts.map +1 -1
  30. package/dist/tempo/session/server/Session.js +6 -3
  31. package/dist/tempo/session/server/Session.js.map +1 -1
  32. package/dist/tempo/session/server/Settlement.d.ts +5 -3
  33. package/dist/tempo/session/server/Settlement.d.ts.map +1 -1
  34. package/dist/tempo/session/server/Settlement.js +9 -2
  35. package/dist/tempo/session/server/Settlement.js.map +1 -1
  36. package/dist/validation/core.d.ts +4 -8
  37. package/dist/validation/core.d.ts.map +1 -1
  38. package/dist/validation/core.js +37 -24
  39. package/dist/validation/core.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/cli/plugins/stripe.ts +3 -2
  42. package/src/cli/validate/challenge.ts +13 -8
  43. package/src/cli/validate/index.ts +5 -3
  44. package/src/cli/validate/payment.ts +354 -168
  45. package/src/cli/validate.test.ts +94 -0
  46. package/src/tempo/internal/fee-payer.test.ts +4 -1
  47. package/src/tempo/internal/fee-payer.ts +4 -1
  48. package/src/tempo/session/precompile/Chain.test.ts +42 -0
  49. package/src/tempo/session/precompile/Chain.ts +12 -7
  50. package/src/tempo/session/server/CredentialVerification.ts +3 -3
  51. package/src/tempo/session/server/RequestState.ts +4 -3
  52. package/src/tempo/session/server/Session.ts +6 -3
  53. package/src/tempo/session/server/Settlement.test.ts +16 -0
  54. package/src/tempo/session/server/Settlement.ts +16 -5
  55. package/src/validation/core.ts +67 -41
@@ -1,3 +1,5 @@
1
+ import * as childProcess from 'node:child_process'
2
+
1
3
  import { type Address, type Chain, createClient, erc20Abi, http } from 'viem'
2
4
  import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts'
3
5
  import { readContract, waitForTransactionReceipt } from 'viem/actions'
@@ -8,10 +10,12 @@ import { tempoModerato, tempo as tempoMainnetChain } from 'viem/tempo/chains'
8
10
  import * as Challenge from '../../Challenge.js'
9
11
  import * as Mppx from '../../client/Mppx.js'
10
12
  import * as Constants from '../../Constants.js'
13
+ import type { AnyClient } from '../../Method.js'
11
14
  import * as Receipt from '../../Receipt.js'
12
15
  import { tempo as tempoMethods } from '../../tempo/client/index.js'
13
16
  import { chainId as tempoChainIds } from '../../tempo/internal/defaults.js'
14
17
  import { resolveAccount, resolveAccountName } from '../account.js'
18
+ import type { Config } from '../config.js'
15
19
  import { loadConfig, resolvePlugin } from '../internal.js'
16
20
  import { fetchTokenInfo, confirm, pc } from '../utils.js'
17
21
  import { buildUrl } from './discovery.js'
@@ -29,16 +33,17 @@ import {
29
33
  async function provisionAndPayTestnet(
30
34
  challenge: Challenge.Challenge,
31
35
  verbose: boolean,
32
- ): Promise<{ methods: import('../../Method.js').AnyClient[] } | undefined> {
36
+ silent?: boolean,
37
+ ): Promise<{ methods: AnyClient[] } | undefined> {
33
38
  try {
34
- console.log(pc.dim(' Provisioning testnet wallet and funding via faucet...'))
39
+ if (!silent) console.log(pc.dim(' Provisioning testnet wallet and funding via faucet...'))
35
40
  const key = generatePrivateKey()
36
41
  const account = privateKeyToAccount(key)
37
42
 
38
43
  const client = createClient({ chain: tempoModerato, transport: http() })
39
44
  const hashes = await Actions.faucet.fund(client, { account })
40
45
  await Promise.all(hashes.map((hash) => waitForTransactionReceipt(client, { hash })))
41
- console.log(pc.dim(` Using wallet: ${account.address}`))
46
+ if (!silent) console.log(pc.dim(` Using wallet: ${account.address}`))
42
47
 
43
48
  const methods = [...tempoMethods({ account })]
44
49
  return { methods }
@@ -64,6 +69,20 @@ async function resolveWalletAddress(): Promise<string | undefined> {
64
69
  }
65
70
  }
66
71
 
72
+ // Auto-detect Stripe test key from the Stripe CLI if installed and logged in.
73
+ function resolveStripeKey(verbose: boolean): string | undefined {
74
+ try {
75
+ const { execSync } = childProcess
76
+ const output = execSync('stripe config --list', { encoding: 'utf8', timeout: 5000 })
77
+ const match = output.match(/test_mode_api_key\s*=\s*'([^']+)'/)
78
+ if (match?.[1]) {
79
+ if (verbose) console.log(pc.dim(' Using Stripe test key from stripe CLI'))
80
+ return match[1]
81
+ }
82
+ } catch {}
83
+ return undefined
84
+ }
85
+
67
86
  async function fetchEvmTokenInfo(
68
87
  chain: Chain,
69
88
  token: Address,
@@ -89,6 +108,10 @@ function resolveEvmChain(chainId: number): Chain | undefined {
89
108
  return all.find((c) => c.id === chainId)
90
109
  }
91
110
 
111
+ function formatAmount(amount: bigint, decimals: number): string {
112
+ return `$${(Number(amount) / 10 ** decimals).toFixed(2)}`
113
+ }
114
+
92
115
  function methodSetupHint(challenge: Challenge.Challenge): string {
93
116
  switch (challenge.method) {
94
117
  case Constants.Methods.evm:
@@ -99,23 +122,24 @@ function methodSetupHint(challenge: Challenge.Challenge): string {
99
122
  return `no plugin for ${challenge.method}/${challenge.intent}`
100
123
  }
101
124
  }
102
-
103
125
  export async function validatePaymentFlow(
104
126
  baseUrl: string,
105
127
  endpoint: EndpointSpec,
106
128
  verbose: boolean,
107
- options?: {
129
+ options: {
108
130
  body?: string | undefined
109
131
  query?: string[] | undefined
110
132
  yes?: boolean | undefined
133
+ silent?: boolean | undefined
134
+ interactive?: boolean | undefined
111
135
  onResults?: (results: CheckResult[]) => void
112
136
  },
113
137
  ): Promise<CheckResult[]> {
114
138
  const results: CheckResult[] = []
115
- const url = buildUrl(baseUrl, endpoint, options?.query)
139
+ const url = buildUrl(baseUrl, endpoint, options.query)
116
140
  const fetchHeaders: Record<string, string> = {}
117
141
  let fetchBody: string | undefined
118
- if (options?.body) {
142
+ if (options.body) {
119
143
  fetchBody = options.body
120
144
  fetchHeaders['content-type'] = 'application/json'
121
145
  }
@@ -160,7 +184,7 @@ export async function validatePaymentFlow(
160
184
  })
161
185
 
162
186
  if (tempoTestnetChallenge) {
163
- const provisioned = await provisionAndPayTestnet(tempoTestnetChallenge, verbose)
187
+ const provisioned = await provisionAndPayTestnet(tempoTestnetChallenge, verbose, options.silent)
164
188
  if (provisioned) {
165
189
  const fakeResp = new Response(null, {
166
190
  status: 402,
@@ -196,200 +220,362 @@ export async function validatePaymentFlow(
196
220
 
197
221
  // Try each challenge method (skip testnet challenge already handled above)
198
222
  const loaded = await loadConfig().catch(() => undefined)
199
- const isInteractive = process.stdin.isTTY ?? false
223
+ const isInteractive = options.interactive
224
+ const stripeKey = process.env.MPPX_STRIPE_SECRET_KEY ?? resolveStripeKey(verbose)
225
+ const isStripeTestKey = stripeKey?.startsWith('sk_test_') || stripeKey?.startsWith('rk_test_')
200
226
  const supportedPaymentMethods: Set<string> = new Set([
201
227
  Constants.Methods.tempo,
202
228
  Constants.Methods.evm,
229
+ Constants.Methods.stripe,
203
230
  ])
204
231
 
205
232
  for (const challenge of challenges) {
206
233
  if (challenge === tempoTestnetChallenge) continue
207
234
  if (!supportedPaymentMethods.has(challenge.method)) continue
235
+ if (!options.silent) console.log('')
208
236
 
209
237
  const flushStart = results.length
210
238
  const flush = () => {
211
- if (options?.onResults && results.length > flushStart)
239
+ if (options.onResults && results.length > flushStart)
212
240
  options.onResults(results.slice(flushStart))
213
241
  }
214
242
  const tag = `Payment [${challenge.method}]`
215
243
 
216
244
  try {
217
- const request = challenge.request as Record<string, unknown>
218
- const requiredAmount = isValidIntegerAmount(request.amount)
219
- ? BigInt(request.amount as string)
220
- : undefined
221
- const methodDetails = request.methodDetails as Record<string, unknown> | undefined
222
- const decimals =
223
- (methodDetails?.decimals as number | undefined) ??
224
- (request.decimals as number | undefined) ??
225
- 6
226
- const currency = request.currency as string | undefined
227
-
228
- // Resolve wallet
229
- let walletAddress: string | undefined
230
- try {
231
- walletAddress = await resolveWalletAddress()
232
- } catch {}
233
-
234
- if (!walletAddress) {
235
- results.push(skip(tag, 'no wallet configured. Run "mppx account create" to create one.'))
236
- continue
245
+ switch (challenge.method) {
246
+ case Constants.Methods.tempo:
247
+ case Constants.Methods.evm:
248
+ await attemptCryptoPayment(challenge, tag, {
249
+ results,
250
+ url,
251
+ endpoint,
252
+ fetchHeaders,
253
+ fetchBody,
254
+ verbose,
255
+ loaded,
256
+ isInteractive,
257
+ options,
258
+ })
259
+ break
260
+ case Constants.Methods.stripe:
261
+ await attemptStripePayment(challenge, tag, {
262
+ results,
263
+ url,
264
+ endpoint,
265
+ fetchHeaders,
266
+ fetchBody,
267
+ verbose,
268
+ loaded,
269
+ isInteractive,
270
+ isStripeTestKey: isStripeTestKey ?? false,
271
+ stripeKey,
272
+ options,
273
+ })
274
+ break
237
275
  }
276
+ } finally {
277
+ flush()
278
+ }
279
+ }
238
280
 
239
- // Pre-flight balance check and chain resolution
240
- let insufficientBalance = false
241
- let tokenSymbol: string | undefined
242
- let paymentChain: Chain | undefined
243
- if (challenge.method === Constants.Methods.tempo) {
244
- paymentChain = tempoMainnetChain
245
- } else if (challenge.method === Constants.Methods.evm) {
246
- const chainId = methodDetails?.chainId as number | undefined
247
- if (chainId) paymentChain = resolveEvmChain(chainId)
248
- }
281
+ return results
282
+ }
249
283
 
250
- if (requiredAmount && currency && paymentChain) {
251
- try {
252
- let balance: bigint
253
- if (challenge.method === Constants.Methods.tempo) {
254
- const client = createClient({ chain: tempoMainnetChain, transport: http() })
255
- const info = await fetchTokenInfo(client, currency as Address, walletAddress as Address)
256
- balance = info.balance
257
- tokenSymbol = info.symbol
258
- } else {
259
- const info = await fetchEvmTokenInfo(
260
- paymentChain,
261
- currency as Address,
262
- walletAddress as Address,
263
- )
264
- balance = info.balance
265
- tokenSymbol = info.symbol
266
- }
267
- if (balance < requiredAmount) {
268
- const requiredDisplay = `$${(Number(requiredAmount) / 10 ** decimals).toFixed(2)}`
269
- const balanceDisplay = `$${(Number(balance) / 10 ** decimals).toFixed(2)}`
270
- const symbol = tokenSymbol ?? 'tokens'
271
- results.push(
272
- skip(
273
- tag,
274
- `insufficient balance (have ${balanceDisplay}, need ${requiredDisplay} ${symbol} on ${paymentChain.name})`,
275
- `Fund wallet ${walletAddress} with at least ${requiredDisplay} ${symbol} on ${paymentChain.name}.`,
276
- ),
277
- )
278
- insufficientBalance = true
279
- }
280
- } catch (e) {
281
- if (verbose) console.log(pc.dim(` Balance check skipped: ${(e as Error).message}`))
282
- }
283
- }
284
- if (insufficientBalance) continue
284
+ type PaymentContext = {
285
+ results: CheckResult[]
286
+ url: string
287
+ endpoint: EndpointSpec
288
+ fetchHeaders: Record<string, string>
289
+ fetchBody: string | undefined
290
+ verbose: boolean
291
+ loaded: { config: Config; path: string } | undefined
292
+ isInteractive: boolean | undefined
293
+ options: {
294
+ body?: string | undefined
295
+ query?: string[] | undefined
296
+ yes?: boolean | undefined
297
+ silent?: boolean | undefined
298
+ interactive?: boolean | undefined
299
+ onResults?: ((results: CheckResult[]) => void) | undefined
300
+ }
301
+ }
285
302
 
286
- // Prompt
287
- const amountDisplay = requiredAmount
288
- ? `$${(Number(requiredAmount) / 10 ** decimals).toFixed(2)}`
289
- : 'unknown amount'
290
- const tokenDisplay = tokenSymbol ?? (currency?.length === 3 ? currency.toUpperCase() : '')
291
- const chainName = paymentChain?.name
303
+ async function attemptCryptoPayment(
304
+ challenge: Challenge.Challenge,
305
+ tag: string,
306
+ ctx: PaymentContext,
307
+ ): Promise<void> {
308
+ const { results, url, endpoint, fetchHeaders, fetchBody, verbose, loaded, options } = ctx
309
+ const request = challenge.request as Record<string, unknown>
310
+ const methodDetails = request.methodDetails as Record<string, unknown> | undefined
311
+ const requiredAmount = isValidIntegerAmount(request.amount)
312
+ ? BigInt(request.amount as string)
313
+ : undefined
314
+ const decimals =
315
+ (methodDetails?.decimals as number | undefined) ?? (request.decimals as number | undefined) ?? 6
316
+ const currency = request.currency as string | undefined
317
+
318
+ // Resolve wallet
319
+ let walletAddress: string | undefined
320
+ try {
321
+ walletAddress = await resolveWalletAddress()
322
+ } catch {}
323
+ if (!walletAddress) {
324
+ results.push(skip(tag, 'no wallet configured. Run "mppx account create" to create one.'))
325
+ return
326
+ }
292
327
 
293
- if (walletAddress) console.log(pc.dim(` Using wallet: ${walletAddress}`))
328
+ // Pre-flight balance check and chain resolution
329
+ let paymentChain: Chain | undefined
330
+ if (challenge.method === Constants.Methods.tempo) paymentChain = tempoMainnetChain
331
+ else if (challenge.method === Constants.Methods.evm) {
332
+ const chainId = methodDetails?.chainId as number | undefined
333
+ if (chainId) paymentChain = resolveEvmChain(chainId)
334
+ }
294
335
 
295
- if (paymentChain?.testnet) {
296
- console.log(
297
- pc.dim(
298
- ` Auto-approved: ${amountDisplay}${tokenDisplay ? ` ${tokenDisplay}` : ''}${chainName ? ` on ${chainName}` : ''} (testnet)`,
299
- ),
300
- )
301
- } else if (!options?.yes && !isInteractive) {
302
- results.push(skip(tag, 'non-interactive mode, use --yes to approve'))
303
- continue
304
- } else if (!options?.yes) {
305
- console.log('')
306
- const ok = await confirm(
307
- ` ${pc.yellow('Pay')} ${amountDisplay}${tokenDisplay ? ` ${tokenDisplay}` : ''}${chainName ? ` on ${chainName}` : ''}. Continue?`,
308
- false,
309
- )
310
- if (!ok) {
311
- results.push(skip(tag, 'declined'))
312
- continue
313
- }
336
+ let tokenSymbol: string | undefined
337
+ if (requiredAmount && currency && paymentChain) {
338
+ try {
339
+ let balance: bigint
340
+ if (challenge.method === Constants.Methods.tempo) {
341
+ const client = createClient({ chain: tempoMainnetChain, transport: http() })
342
+ const info = await fetchTokenInfo(client, currency as Address, walletAddress as Address)
343
+ balance = info.balance
344
+ tokenSymbol = info.symbol
314
345
  } else {
315
- console.log(
316
- pc.dim(
317
- ` Auto-approved: ${amountDisplay}${tokenDisplay ? ` ${tokenDisplay}` : ''}${chainName ? ` on ${chainName}` : ''}`,
346
+ const info = await fetchEvmTokenInfo(
347
+ paymentChain,
348
+ currency as Address,
349
+ walletAddress as Address,
350
+ )
351
+ balance = info.balance
352
+ tokenSymbol = info.symbol
353
+ }
354
+ if (balance < requiredAmount) {
355
+ const requiredDisplay = formatAmount(requiredAmount, decimals)
356
+ const balanceDisplay = formatAmount(balance, decimals)
357
+ const symbol = tokenSymbol ?? 'tokens'
358
+ results.push(
359
+ skip(
360
+ tag,
361
+ `insufficient balance (have ${balanceDisplay}, need ${requiredDisplay} ${symbol} on ${paymentChain.name})`,
362
+ `Fund wallet ${walletAddress} with at least ${requiredDisplay} ${symbol} on ${paymentChain.name}.`,
318
363
  ),
319
364
  )
365
+ return
320
366
  }
367
+ } catch (e) {
368
+ if (verbose) console.log(pc.dim(` Balance check skipped: ${(e as Error).message}`))
369
+ }
370
+ }
321
371
 
322
- // Resolve payment methods
323
- let methods: import('../../Method.js').AnyClient[]
324
- let createCredentialFn: ((response: Response) => Promise<string>) | undefined
325
- let plugin: import('../plugins/plugin.js').Plugin | undefined
326
-
327
- const resolved = resolvePlugin(challenge, loaded?.config)
328
- plugin = resolved.plugin
329
- const directMethod = resolved.method
330
- if (!plugin && !directMethod) {
331
- results.push(skip(tag, methodSetupHint(challenge)))
332
- continue
333
- }
334
- if (plugin) {
335
- try {
336
- const pluginResult = await plugin.setup({
337
- challenge,
338
- options: { network: 'mainnet' },
339
- methodOpts: {},
340
- })
341
- methods = pluginResult.methods
342
- createCredentialFn = pluginResult.createCredential
343
- } catch (error) {
344
- results.push(skip(tag, (error as Error).message))
345
- continue
346
- }
347
- } else {
348
- methods = [directMethod!]
349
- }
372
+ // Prompt
373
+ const amountDisplay = requiredAmount ? formatAmount(requiredAmount, decimals) : 'unknown amount'
374
+ const tokenDisplay = tokenSymbol ?? (currency?.length === 3 ? currency.toUpperCase() : '')
375
+ const chainName = paymentChain?.name
376
+ const paymentDesc = `${amountDisplay}${tokenDisplay ? ` ${tokenDisplay}` : ''}${chainName ? ` on ${chainName}` : ''}`
377
+
378
+ if (!options.silent) console.log(pc.dim(` Attempting payment with wallet ${walletAddress}`))
379
+
380
+ if (paymentChain?.testnet) {
381
+ if (!options.silent) console.log(pc.dim(` Auto-approved: ${paymentDesc} (testnet)`))
382
+ } else if (!options.yes && !ctx.isInteractive) {
383
+ results.push(skip(tag, 'non-interactive mode, use --yes to approve'))
384
+ return
385
+ } else if (!options.yes) {
386
+ const ok = await confirm(` ${pc.yellow('Pay')} ${paymentDesc}. Continue?`, false)
387
+ if (!ok) {
388
+ results.push(skip(tag, 'declined'))
389
+ return
390
+ }
391
+ } else {
392
+ if (!options.silent) console.log(pc.dim(` Auto-approved: ${paymentDesc}`))
393
+ }
350
394
 
351
- // Create credential and send
352
- let credential: string
353
- const fakeResponse = new Response(null, {
354
- status: 402,
355
- headers: { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) },
395
+ // Resolve plugin and pay
396
+ const resolved = resolvePlugin(challenge, loaded?.config)
397
+ const plugin = resolved.plugin
398
+ const directMethod = resolved.method
399
+ if (!plugin && !directMethod) {
400
+ results.push(skip(tag, methodSetupHint(challenge)))
401
+ return
402
+ }
403
+
404
+ let methods: AnyClient[]
405
+ let createCredentialFn: ((response: Response) => Promise<string>) | undefined
406
+ if (plugin) {
407
+ try {
408
+ const pluginResult = await plugin.setup({
409
+ challenge,
410
+ options: { network: 'mainnet' },
411
+ methodOpts: {},
356
412
  })
357
- try {
358
- if (createCredentialFn) {
359
- credential = await createCredentialFn(fakeResponse)
360
- } else {
361
- const mppx = Mppx.create({ methods, polyfill: false })
362
- credential = await mppx.createCredential(fakeResponse)
363
- }
364
- } catch (error) {
365
- const msg = (error as Error).message
366
- if (msg.toLowerCase().includes('insufficient')) {
367
- results.push(skip(tag, `insufficient balance: ${msg}`))
368
- continue
369
- }
370
- results.push(fail(tag, msg))
371
- continue
372
- }
413
+ methods = pluginResult.methods
414
+ createCredentialFn = pluginResult.createCredential
415
+ } catch (error) {
416
+ results.push(skip(tag, (error as Error).message))
417
+ return
418
+ }
419
+ } else {
420
+ methods = [directMethod!]
421
+ }
422
+
423
+ const credential = await createAndSend(challenge, methods, createCredentialFn, tag, results)
424
+ if (!credential) return
425
+
426
+ plugin?.prepareCredentialRequest?.({ challenge, credential, headers: fetchHeaders })
427
+ await sendAndValidateResponse(
428
+ results,
429
+ url,
430
+ endpoint,
431
+ credential,
432
+ fetchHeaders,
433
+ fetchBody,
434
+ verbose,
435
+ paymentChain,
436
+ )
437
+ }
438
+
439
+ async function attemptStripePayment(
440
+ challenge: Challenge.Challenge,
441
+ tag: string,
442
+ ctx: PaymentContext & { isStripeTestKey: boolean; stripeKey?: string | undefined },
443
+ ): Promise<void> {
444
+ const {
445
+ results,
446
+ url,
447
+ endpoint,
448
+ fetchHeaders,
449
+ fetchBody,
450
+ verbose,
451
+ loaded,
452
+ options,
453
+ isStripeTestKey,
454
+ stripeKey,
455
+ } = ctx
456
+ const request = challenge.request as Record<string, unknown>
457
+ const requiredAmount = isValidIntegerAmount(request.amount)
458
+ ? BigInt(request.amount as string)
459
+ : undefined
460
+ const decimals = (request.decimals as number | undefined) ?? 2
461
+ const currency = request.currency as string | undefined
462
+
463
+ const amountDisplay = requiredAmount ? formatAmount(requiredAmount, decimals) : 'unknown amount'
464
+ const tokenDisplay = currency?.length === 3 ? currency.toUpperCase() : ''
465
+ const paymentDesc = `${amountDisplay}${tokenDisplay ? ` ${tokenDisplay}` : ''} via Stripe${isStripeTestKey ? ' (testmode)' : ''}`
466
+
467
+ if (isStripeTestKey) {
468
+ if (!options.silent) console.log(pc.dim(` Attempting: ${paymentDesc}`))
469
+ } else if (!options.yes && !ctx.isInteractive) {
470
+ results.push(skip(tag, 'non-interactive mode, use --yes to approve'))
471
+ return
472
+ } else if (!options.yes) {
473
+ const ok = await confirm(` ${pc.yellow('Pay')} ${paymentDesc}. Continue?`, false)
474
+ if (!ok) {
475
+ results.push(skip(tag, 'declined'))
476
+ return
477
+ }
478
+ } else {
479
+ if (!options.silent) console.log(pc.dim(` Auto-approved: ${paymentDesc}`))
480
+ }
481
+
482
+ // Resolve plugin
483
+ const resolved = resolvePlugin(challenge, loaded?.config)
484
+ const plugin = resolved.plugin
485
+ if (!plugin) {
486
+ results.push(skip(tag, 'no Stripe plugin available'))
487
+ return
488
+ }
489
+
490
+ let methods: AnyClient[]
491
+ let createCredentialFn: ((response: Response) => Promise<string>) | undefined
492
+ try {
493
+ const methodOpts: Record<string, string> = { paymentMethod: 'pm_card_visa' }
494
+ if (stripeKey) methodOpts.secretKey = stripeKey
495
+ const pluginResult = await plugin.setup({
496
+ challenge,
497
+ options: {},
498
+ methodOpts,
499
+ })
500
+ methods = pluginResult.methods
501
+ createCredentialFn = pluginResult.createCredential
502
+ } catch (error) {
503
+ results.push(skip(tag, (error as Error).message))
504
+ return
505
+ }
506
+
507
+ const credential = await createAndSend(challenge, methods, createCredentialFn, tag, results)
508
+ if (!credential) return
509
+
510
+ plugin.prepareCredentialRequest?.({ challenge, credential, headers: fetchHeaders })
373
511
 
374
- results.push(check(`${tag}: submitted`))
375
-
376
- plugin?.prepareCredentialRequest?.({ challenge, credential, headers: fetchHeaders })
377
- await sendAndValidateResponse(
378
- results,
379
- url,
380
- endpoint,
381
- credential,
382
- fetchHeaders,
383
- fetchBody,
384
- verbose,
385
- paymentChain,
512
+ // Stripe testmode: detect livemode rejection gracefully
513
+ if (isStripeTestKey) {
514
+ const resp = await fetchWithTimeout(
515
+ url,
516
+ {
517
+ method: endpoint.method,
518
+ headers: { ...fetchHeaders, [Constants.Headers.authorization]: credential },
519
+ body: fetchBody ?? null,
520
+ },
521
+ 30_000,
522
+ )
523
+ if (resp.status >= 200 && resp.status < 300) {
524
+ results.push(check(`${tag}: successful`, `HTTP ${resp.status}`))
525
+ } else {
526
+ results.push(
527
+ skip(
528
+ `${tag}: server is in livemode`,
529
+ undefined,
530
+ 'Run your server with a Stripe test key to automatically validate Stripe payments in testmode.',
531
+ ),
386
532
  )
387
- } finally {
388
- flush()
389
533
  }
534
+ return
390
535
  }
391
536
 
392
- return results
537
+ await sendAndValidateResponse(
538
+ results,
539
+ url,
540
+ endpoint,
541
+ credential,
542
+ fetchHeaders,
543
+ fetchBody,
544
+ verbose,
545
+ undefined,
546
+ )
547
+ }
548
+
549
+ async function createAndSend(
550
+ challenge: Challenge.Challenge,
551
+ methods: AnyClient[],
552
+ createCredentialFn: ((response: Response) => Promise<string>) | undefined,
553
+ tag: string,
554
+ results: CheckResult[],
555
+ ): Promise<string | undefined> {
556
+ const fakeResponse = new Response(null, {
557
+ status: 402,
558
+ headers: { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) },
559
+ })
560
+ try {
561
+ let credential: string
562
+ if (createCredentialFn) {
563
+ credential = await createCredentialFn(fakeResponse)
564
+ } else {
565
+ const mppx = Mppx.create({ methods, polyfill: false })
566
+ credential = await mppx.createCredential(fakeResponse)
567
+ }
568
+ results.push(check(`${tag}: submitted`))
569
+ return credential
570
+ } catch (error) {
571
+ const msg = (error as Error).message
572
+ if (msg.toLowerCase().includes('insufficient')) {
573
+ results.push(skip(tag, `insufficient balance: ${msg}`))
574
+ } else {
575
+ results.push(fail(tag, msg))
576
+ }
577
+ return undefined
578
+ }
393
579
  }
394
580
 
395
581
  async function sendAndValidateResponse(