mppx 0.8.6 → 0.8.8

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 (108) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/cli/internal.js +2 -2
  3. package/dist/cli/internal.js.map +1 -1
  4. package/dist/cli/plugins/evm.d.ts +2 -0
  5. package/dist/cli/plugins/evm.d.ts.map +1 -0
  6. package/dist/cli/plugins/evm.js +51 -0
  7. package/dist/cli/plugins/evm.js.map +1 -0
  8. package/dist/cli/plugins/index.d.ts +1 -0
  9. package/dist/cli/plugins/index.d.ts.map +1 -1
  10. package/dist/cli/plugins/index.js +1 -0
  11. package/dist/cli/plugins/index.js.map +1 -1
  12. package/dist/cli/plugins/stripe.d.ts.map +1 -1
  13. package/dist/cli/plugins/stripe.js +3 -2
  14. package/dist/cli/plugins/stripe.js.map +1 -1
  15. package/dist/cli/validate/challenge.d.ts +2 -0
  16. package/dist/cli/validate/challenge.d.ts.map +1 -1
  17. package/dist/cli/validate/challenge.js +191 -84
  18. package/dist/cli/validate/challenge.js.map +1 -1
  19. package/dist/cli/validate/discovery.d.ts +1 -0
  20. package/dist/cli/validate/discovery.d.ts.map +1 -1
  21. package/dist/cli/validate/discovery.js +99 -35
  22. package/dist/cli/validate/discovery.js.map +1 -1
  23. package/dist/cli/validate/helpers.d.ts +14 -1
  24. package/dist/cli/validate/helpers.d.ts.map +1 -1
  25. package/dist/cli/validate/helpers.js +0 -13
  26. package/dist/cli/validate/helpers.js.map +1 -1
  27. package/dist/cli/validate/index.d.ts +1 -0
  28. package/dist/cli/validate/index.d.ts.map +1 -1
  29. package/dist/cli/validate/index.js +102 -153
  30. package/dist/cli/validate/index.js.map +1 -1
  31. package/dist/cli/validate/payment.d.ts +4 -1
  32. package/dist/cli/validate/payment.d.ts.map +1 -1
  33. package/dist/cli/validate/payment.js +312 -106
  34. package/dist/cli/validate/payment.js.map +1 -1
  35. package/dist/evm/Chains.d.ts +4 -0
  36. package/dist/evm/Chains.d.ts.map +1 -1
  37. package/dist/evm/Chains.js +4 -0
  38. package/dist/evm/Chains.js.map +1 -1
  39. package/dist/internal/AcceptPayment.d.ts.map +1 -1
  40. package/dist/internal/AcceptPayment.js +7 -1
  41. package/dist/internal/AcceptPayment.js.map +1 -1
  42. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  43. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  44. package/dist/stripe/server/internal/html.gen.js +1 -1
  45. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  46. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  47. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  48. package/dist/tempo/server/internal/html.gen.js +1 -1
  49. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  50. package/dist/tempo/server/internal/request-body.d.ts.map +1 -1
  51. package/dist/tempo/server/internal/request-body.js +2 -0
  52. package/dist/tempo/server/internal/request-body.js.map +1 -1
  53. package/dist/tempo/session/server/ChannelStore.js +4 -4
  54. package/dist/tempo/session/server/ChannelStore.js.map +1 -1
  55. package/dist/tempo/session/server/CredentialVerification.d.ts +8 -0
  56. package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -1
  57. package/dist/tempo/session/server/CredentialVerification.js +21 -1
  58. package/dist/tempo/session/server/CredentialVerification.js.map +1 -1
  59. package/dist/tempo/session/server/Session.d.ts.map +1 -1
  60. package/dist/tempo/session/server/Session.js +1 -0
  61. package/dist/tempo/session/server/Session.js.map +1 -1
  62. package/dist/validation/core.d.ts +75 -0
  63. package/dist/validation/core.d.ts.map +1 -0
  64. package/dist/validation/core.js +269 -0
  65. package/dist/validation/core.js.map +1 -0
  66. package/dist/validation/index.d.ts +3 -0
  67. package/dist/validation/index.d.ts.map +1 -0
  68. package/dist/validation/index.js +2 -0
  69. package/dist/validation/index.js.map +1 -0
  70. package/dist/x402/Assets.d.ts +9 -0
  71. package/dist/x402/Assets.d.ts.map +1 -1
  72. package/dist/x402/Assets.js +38 -0
  73. package/dist/x402/Assets.js.map +1 -1
  74. package/package.json +9 -4
  75. package/src/cli/internal.ts +2 -2
  76. package/src/cli/plugins/evm.ts +55 -0
  77. package/src/cli/plugins/index.ts +1 -0
  78. package/src/cli/plugins/stripe.ts +3 -2
  79. package/src/cli/validate/challenge.ts +345 -127
  80. package/src/cli/validate/discovery.test.ts +307 -0
  81. package/src/cli/validate/discovery.ts +104 -38
  82. package/src/cli/validate/helpers.ts +15 -13
  83. package/src/cli/validate/index.ts +121 -242
  84. package/src/cli/validate/payment.ts +413 -144
  85. package/src/cli/validate.test.ts +237 -3
  86. package/src/client/internal/Fetch.test.ts +142 -2
  87. package/src/evm/Chains.ts +6 -0
  88. package/src/evm/PublicInterface.test-d.ts +7 -0
  89. package/src/internal/AcceptPayment.test.ts +45 -0
  90. package/src/internal/AcceptPayment.ts +11 -1
  91. package/src/stripe/server/internal/html/package.json +1 -1
  92. package/src/stripe/server/internal/html.gen.ts +1 -1
  93. package/src/tempo/server/internal/html/package.json +1 -1
  94. package/src/tempo/server/internal/html.gen.ts +1 -1
  95. package/src/tempo/server/internal/request-body.test.ts +9 -0
  96. package/src/tempo/server/internal/request-body.ts +2 -0
  97. package/src/tempo/session/server/ChannelStore.test.ts +17 -0
  98. package/src/tempo/session/server/ChannelStore.ts +4 -4
  99. package/src/tempo/session/server/CredentialVerification.test.ts +118 -1
  100. package/src/tempo/session/server/CredentialVerification.ts +30 -0
  101. package/src/tempo/session/server/Session.integration.test.ts +5 -0
  102. package/src/tempo/session/server/Session.test.ts +119 -53
  103. package/src/tempo/session/server/Session.ts +1 -0
  104. package/src/validation/core.ts +374 -0
  105. package/src/validation/index.ts +11 -0
  106. package/src/x402/Assets.test.ts +42 -0
  107. package/src/x402/Assets.ts +40 -0
  108. package/src/x402/Exact.localnet.test.ts +475 -0
@@ -1,34 +1,49 @@
1
- import { createClient, http } from 'viem'
1
+ import * as childProcess from 'node:child_process'
2
+
3
+ import { type Address, type Chain, createClient, erc20Abi, http } from 'viem'
2
4
  import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts'
3
- import { waitForTransactionReceipt } from 'viem/actions'
5
+ import { readContract, waitForTransactionReceipt } from 'viem/actions'
6
+ import * as viemChains from 'viem/chains'
4
7
  import { Actions } from 'viem/tempo'
5
8
  import { tempoModerato, tempo as tempoMainnetChain } from 'viem/tempo/chains'
6
9
 
7
10
  import * as Challenge from '../../Challenge.js'
8
11
  import * as Mppx from '../../client/Mppx.js'
9
12
  import * as Constants from '../../Constants.js'
13
+ import type { AnyClient } from '../../Method.js'
10
14
  import * as Receipt from '../../Receipt.js'
11
15
  import { tempo as tempoMethods } from '../../tempo/client/index.js'
12
16
  import { chainId as tempoChainIds } from '../../tempo/internal/defaults.js'
13
17
  import { resolveAccount, resolveAccountName } from '../account.js'
14
- import { loadConfig, selectChallenge } from '../internal.js'
18
+ import type { Config } from '../config.js'
19
+ import { loadConfig, resolvePlugin } from '../internal.js'
15
20
  import { fetchTokenInfo, confirm, pc } from '../utils.js'
21
+ import { buildUrl } from './discovery.js'
16
22
  import type { CheckResult, EndpointSpec } from './helpers.js'
17
- import { buildUrl, check, fail, fetchWithTimeout, formatBytes, skip, warn } from './helpers.js'
23
+ import {
24
+ check,
25
+ fail,
26
+ fetchWithTimeout,
27
+ formatBytes,
28
+ isValidIntegerAmount,
29
+ skip,
30
+ warn,
31
+ } from './helpers.js'
18
32
 
19
33
  async function provisionAndPayTestnet(
20
34
  challenge: Challenge.Challenge,
21
35
  verbose: boolean,
22
- ): Promise<{ methods: import('../../Method.js').AnyClient[] } | undefined> {
36
+ silent?: boolean,
37
+ ): Promise<{ methods: AnyClient[] } | undefined> {
23
38
  try {
24
- 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...'))
25
40
  const key = generatePrivateKey()
26
41
  const account = privateKeyToAccount(key)
27
42
 
28
43
  const client = createClient({ chain: tempoModerato, transport: http() })
29
44
  const hashes = await Actions.faucet.fund(client, { account })
30
45
  await Promise.all(hashes.map((hash) => waitForTransactionReceipt(client, { hash })))
31
- console.log(pc.dim(` Using wallet: ${account.address}`))
46
+ if (!silent) console.log(pc.dim(` Using wallet: ${account.address}`))
32
47
 
33
48
  const methods = [...tempoMethods({ account })]
34
49
  return { methods }
@@ -54,17 +69,77 @@ async function resolveWalletAddress(): Promise<string | undefined> {
54
69
  }
55
70
  }
56
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
+
86
+ async function fetchEvmTokenInfo(
87
+ chain: Chain,
88
+ token: Address,
89
+ account: Address,
90
+ ): Promise<{ balance: bigint; symbol: string | undefined }> {
91
+ const client = createClient({ chain, transport: http() })
92
+ const [balance, symbol] = await Promise.all([
93
+ readContract(client, {
94
+ address: token,
95
+ abi: erc20Abi,
96
+ functionName: 'balanceOf',
97
+ args: [account],
98
+ }),
99
+ readContract(client, { address: token, abi: erc20Abi, functionName: 'symbol' }).catch(
100
+ () => undefined,
101
+ ),
102
+ ])
103
+ return { balance, symbol: symbol ?? undefined }
104
+ }
105
+
106
+ function resolveEvmChain(chainId: number): Chain | undefined {
107
+ const all = Object.values(viemChains) as Chain[]
108
+ return all.find((c) => c.id === chainId)
109
+ }
110
+
111
+ function formatAmount(amount: bigint, decimals: number): string {
112
+ return `$${(Number(amount) / 10 ** decimals).toFixed(2)}`
113
+ }
114
+
115
+ function methodSetupHint(challenge: Challenge.Challenge): string {
116
+ switch (challenge.method) {
117
+ case Constants.Methods.evm:
118
+ return 'no EVM payment plugin yet'
119
+ case 'solana':
120
+ return 'no Solana payment plugin yet'
121
+ default:
122
+ return `no plugin for ${challenge.method}/${challenge.intent}`
123
+ }
124
+ }
57
125
  export async function validatePaymentFlow(
58
126
  baseUrl: string,
59
127
  endpoint: EndpointSpec,
60
128
  verbose: boolean,
61
- options?: { body?: string | undefined; query?: string[] | undefined; yes?: boolean | undefined },
129
+ options: {
130
+ body?: string | undefined
131
+ query?: string[] | undefined
132
+ yes?: boolean | undefined
133
+ silent?: boolean | undefined
134
+ interactive?: boolean | undefined
135
+ onResults?: (results: CheckResult[]) => void
136
+ },
62
137
  ): Promise<CheckResult[]> {
63
138
  const results: CheckResult[] = []
64
- const url = buildUrl(baseUrl, endpoint, options?.query)
139
+ const url = buildUrl(baseUrl, endpoint, options.query)
65
140
  const fetchHeaders: Record<string, string> = {}
66
141
  let fetchBody: string | undefined
67
- if (options?.body) {
142
+ if (options.body) {
68
143
  fetchBody = options.body
69
144
  fetchHeaders['content-type'] = 'application/json'
70
145
  }
@@ -87,33 +162,40 @@ export async function validatePaymentFlow(
87
162
  return results
88
163
  }
89
164
 
90
- // Parse challenge
91
- let challenge: Challenge.Challenge
165
+ // Parse all challenges
166
+ let challenges: Challenge.Challenge[]
92
167
  try {
93
- challenge = Challenge.fromResponse(challengeResponse)
168
+ challenges = Challenge.fromResponseList(challengeResponse)
94
169
  } catch (error) {
95
170
  results.push(fail('Payment: parse challenge', (error as Error).message))
96
171
  return results
97
172
  }
98
-
99
- // Detect network
100
- const request = challenge.request as Record<string, unknown>
101
- const methodDetails = request.methodDetails as Record<string, unknown> | undefined
102
- const isTestnet =
103
- typeof methodDetails?.chainId === 'number' && methodDetails.chainId !== tempoChainIds.mainnet
173
+ if (challenges.length === 0) {
174
+ results.push(fail('Payment: parse challenge', 'No Payment challenges in response'))
175
+ return results
176
+ }
104
177
 
105
178
  // Testnet Tempo: always use ephemeral wallet (zero-setup, free money)
106
- if (isTestnet && challenge.method === Constants.Methods.tempo) {
107
- const provisioned = await provisionAndPayTestnet(challenge, verbose)
179
+ const tempoTestnetChallenge = challenges.find((ch) => {
180
+ if (ch.method !== Constants.Methods.tempo) return false
181
+ const req = ch.request as Record<string, unknown>
182
+ const md = req.methodDetails as Record<string, unknown> | undefined
183
+ return typeof md?.chainId === 'number' && md.chainId !== tempoChainIds.mainnet
184
+ })
185
+
186
+ if (tempoTestnetChallenge) {
187
+ const provisioned = await provisionAndPayTestnet(tempoTestnetChallenge, verbose, options.silent)
108
188
  if (provisioned) {
109
189
  const fakeResp = new Response(null, {
110
190
  status: 402,
111
- headers: { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) },
191
+ headers: {
192
+ [Constants.Headers.wwwAuthenticate]: Challenge.serialize(tempoTestnetChallenge),
193
+ },
112
194
  })
113
195
  try {
114
196
  const mppx = Mppx.create({ methods: provisioned.methods, polyfill: false })
115
197
  const cred = await mppx.createCredential(fakeResp)
116
- results.push(check('Payment: credential created', 'ephemeral testnet wallet'))
198
+ results.push(check('Payment: submitted', 'ephemeral testnet wallet'))
117
199
  return await sendAndValidateResponse(
118
200
  results,
119
201
  url,
@@ -122,7 +204,7 @@ export async function validatePaymentFlow(
122
204
  fetchHeaders,
123
205
  fetchBody,
124
206
  verbose,
125
- true,
207
+ tempoModerato,
126
208
  )
127
209
  } catch (error) {
128
210
  results.push(fail('Payment: create credential', (error as Error).message))
@@ -136,99 +218,191 @@ export async function validatePaymentFlow(
136
218
  }
137
219
  }
138
220
 
139
- // Mainnet: use configured wallet
221
+ // Try each challenge method (skip testnet challenge already handled above)
140
222
  const loaded = await loadConfig().catch(() => undefined)
141
- const selected = selectChallenge([challenge], loaded?.config)
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_')
226
+ const supportedPaymentMethods: Set<string> = new Set([
227
+ Constants.Methods.tempo,
228
+ Constants.Methods.evm,
229
+ Constants.Methods.stripe,
230
+ ])
231
+
232
+ for (const challenge of challenges) {
233
+ if (challenge === tempoTestnetChallenge) continue
234
+ if (!supportedPaymentMethods.has(challenge.method)) continue
235
+ if (!options.silent) console.log('')
236
+
237
+ const flushStart = results.length
238
+ const flush = () => {
239
+ if (options.onResults && results.length > flushStart)
240
+ options.onResults(results.slice(flushStart))
241
+ }
242
+ const tag = `Payment [${challenge.method}]`
142
243
 
143
- if (!selected) {
144
- results.push(
145
- skip(
146
- 'Payment: no configured method',
147
- `Need ${challenge.method}/${challenge.intent}`,
148
- 'Run "mppx account create" to create a local wallet for payment testing. The wallet is stored on your machine and only used by mppx.',
149
- ),
150
- )
151
- return results
244
+ try {
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
275
+ }
276
+ } finally {
277
+ flush()
278
+ }
152
279
  }
153
280
 
154
- const { plugin, method: directMethod } = selected
281
+ return results
282
+ }
155
283
 
156
- if (!plugin && !directMethod) {
157
- results.push(skip('Payment: no plugin available', `${challenge.method}/${challenge.intent}`))
158
- return results
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
159
300
  }
301
+ }
160
302
 
161
- const requiredAmount = request.amount ? BigInt(request.amount as string) : undefined
162
- // Display only -- defaults to 6 (standard for USDC/PathUSD tokens)
163
- const decimals = (request.decimals as number | undefined) ?? 6
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
164
316
  const currency = request.currency as string | undefined
165
317
 
166
- // Pre-flight balance check
318
+ // Resolve wallet
167
319
  let walletAddress: string | undefined
168
- if (challenge.method === Constants.Methods.tempo && requiredAmount && currency) {
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
+ }
327
+
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
+ }
335
+
336
+ let tokenSymbol: string | undefined
337
+ if (requiredAmount && currency && paymentChain) {
169
338
  try {
170
- walletAddress = await resolveWalletAddress()
171
- if (walletAddress) {
339
+ let balance: bigint
340
+ if (challenge.method === Constants.Methods.tempo) {
172
341
  const client = createClient({ chain: tempoMainnetChain, transport: http() })
173
- const tokenInfo = await fetchTokenInfo(
174
- client,
175
- currency as `0x${string}`,
176
- walletAddress as `0x${string}`,
342
+ const info = await fetchTokenInfo(client, currency as Address, walletAddress as Address)
343
+ balance = info.balance
344
+ tokenSymbol = info.symbol
345
+ } else {
346
+ const info = await fetchEvmTokenInfo(
347
+ paymentChain,
348
+ currency as Address,
349
+ walletAddress as Address,
177
350
  )
178
- const requiredDisplay = `$${(Number(requiredAmount) / 10 ** decimals).toFixed(2)}`
179
- const balanceDisplay = `$${(Number(tokenInfo.balance) / 10 ** tokenInfo.decimals).toFixed(2)}`
180
-
181
- if (tokenInfo.balance < requiredAmount) {
182
- console.log(pc.dim(` Wallet: ${walletAddress}`))
183
- console.log(pc.dim(` Balance: ${balanceDisplay} ${tokenInfo.symbol}`))
184
- const hint = `Wallet ${walletAddress} has ${balanceDisplay} but endpoint requires ${requiredDisplay}. Fund this wallet to run payment tests, or use a testnet server.`
185
- results.push(
186
- skip(
187
- 'Payment: insufficient balance',
188
- `Have ${balanceDisplay}, need ${requiredDisplay}`,
189
- hint,
190
- ),
191
- )
192
- return results
193
- }
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}.`,
363
+ ),
364
+ )
365
+ return
194
366
  }
195
367
  } catch (e) {
196
368
  if (verbose) console.log(pc.dim(` Balance check skipped: ${(e as Error).message}`))
197
369
  }
198
370
  }
199
371
 
200
- // Prompt before paying on mainnet (unless --yes or non-interactive)
201
- const amountDisplay = requiredAmount
202
- ? `$${(Number(requiredAmount) / 10 ** decimals).toFixed(2)}`
203
- : 'unknown amount'
204
- if (walletAddress) console.log(pc.dim(` Using wallet: ${walletAddress}`))
205
- const isInteractive = process.stdin.isTTY ?? false
206
- if (!options?.yes && !isInteractive) {
207
- results.push(
208
- skip('Payment: skipped', 'Non-interactive mode. Use --yes to approve mainnet payments.'),
209
- )
210
- return results
211
- }
212
- if (!options?.yes) {
213
- console.log('')
214
- const ok = await confirm(
215
- ` ${pc.yellow('Mainnet payment:')} Will transfer ${amountDisplay} to ${String(request.recipient).slice(0, 10)}... Continue?`,
216
- false,
217
- )
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)
218
387
  if (!ok) {
219
- results.push(skip('Payment: skipped by user', 'mainnet payment declined'))
220
- return results
388
+ results.push(skip(tag, 'declined'))
389
+ return
221
390
  }
222
391
  } else {
223
- console.log(
224
- pc.dim(` Auto-approved: ${amountDisplay} to ${String(request.recipient).slice(0, 10)}...`),
225
- )
392
+ if (!options.silent) console.log(pc.dim(` Auto-approved: ${paymentDesc}`))
226
393
  }
227
394
 
228
- // Setup plugin or use direct method
229
- let methods: import('../../Method.js').AnyClient[]
230
- let createCredentialFn: ((response: Response) => Promise<string>) | undefined
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
+ }
231
403
 
404
+ let methods: AnyClient[]
405
+ let createCredentialFn: ((response: Response) => Promise<string>) | undefined
232
406
  if (plugin) {
233
407
  try {
234
408
  const pluginResult = await plugin.setup({
@@ -239,74 +413,169 @@ export async function validatePaymentFlow(
239
413
  methods = pluginResult.methods
240
414
  createCredentialFn = pluginResult.createCredential
241
415
  } catch (error) {
242
- const msg = (error as Error).message
243
- if (msg.includes('No account found') || msg.includes('not found')) {
244
- results.push(skip('Payment: no wallet configured', msg))
245
- } else {
246
- results.push(fail('Payment: wallet setup', msg))
247
- }
248
- return results
416
+ results.push(skip(tag, (error as Error).message))
417
+ return
249
418
  }
250
419
  } else {
251
420
  methods = [directMethod!]
252
421
  }
253
422
 
254
- // Create credential
255
- let credential: string
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 })
511
+
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
+ ),
532
+ )
533
+ }
534
+ return
535
+ }
536
+
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> {
256
556
  const fakeResponse = new Response(null, {
257
557
  status: 402,
258
558
  headers: { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) },
259
559
  })
260
560
  try {
561
+ let credential: string
261
562
  if (createCredentialFn) {
262
563
  credential = await createCredentialFn(fakeResponse)
263
564
  } else {
264
565
  const mppx = Mppx.create({ methods, polyfill: false })
265
566
  credential = await mppx.createCredential(fakeResponse)
266
567
  }
568
+ results.push(check(`${tag}: submitted`))
569
+ return credential
267
570
  } catch (error) {
268
571
  const msg = (error as Error).message
269
- const isInsufficientBalance =
270
- msg.toLowerCase().includes('insufficientbalance') ||
271
- msg.toLowerCase().includes('insufficient')
272
- if (isInsufficientBalance) {
273
- const match = msg.match(/available:\s*(\d+),\s*required:\s*(\d+)/)
274
- const available = match ? BigInt(match[1]!) : undefined
275
- const required = match ? BigInt(match[2]!) : requiredAmount
276
- const fromMatch = msg.match(/from:\s*(0x[0-9a-fA-F]{40})/)
277
- const fromAddr = fromMatch?.[1]
278
- const requiredDisplay = required
279
- ? `$${(Number(required) / 10 ** decimals).toFixed(2)}`
280
- : 'unknown'
281
- const availableDisplay =
282
- available !== undefined ? `$${(Number(available) / 10 ** decimals).toFixed(2)}` : undefined
283
- const detail = availableDisplay
284
- ? `Have ${availableDisplay}, need ${requiredDisplay}`
285
- : `Endpoint requires ${requiredDisplay}`
286
- const hint = fromAddr
287
- ? `Wallet ${fromAddr} needs at least ${requiredDisplay}. This is a local wallet created by "mppx account create". Fund it to run payment tests, or point at a testnet server for free validation.`
288
- : `Fund your mppx wallet with at least ${requiredDisplay} to run payment tests, or use a testnet server.`
289
- results.push(skip('Payment: insufficient balance', detail, hint))
290
- return results
572
+ if (msg.toLowerCase().includes('insufficient')) {
573
+ results.push(skip(tag, `insufficient balance: ${msg}`))
574
+ } else {
575
+ results.push(fail(tag, msg))
291
576
  }
292
- results.push(fail('Payment: create credential', msg))
293
- return results
577
+ return undefined
294
578
  }
295
-
296
- results.push(check('Payment: credential created'))
297
-
298
- // Prepare and send
299
- plugin?.prepareCredentialRequest?.({ challenge, credential, headers: fetchHeaders })
300
- return await sendAndValidateResponse(
301
- results,
302
- url,
303
- endpoint,
304
- credential,
305
- fetchHeaders,
306
- fetchBody,
307
- verbose,
308
- isTestnet,
309
- )
310
579
  }
311
580
 
312
581
  async function sendAndValidateResponse(
@@ -317,7 +586,7 @@ async function sendAndValidateResponse(
317
586
  baseHeaders: Record<string, string>,
318
587
  fetchBody: string | undefined,
319
588
  verbose: boolean,
320
- isTestnet?: boolean,
589
+ explorerChain?: Chain | undefined,
321
590
  ): Promise<CheckResult[]> {
322
591
  let paymentResponse: Response
323
592
  try {
@@ -475,10 +744,10 @@ async function sendAndValidateResponse(
475
744
  try {
476
745
  const receipt = Receipt.deserialize(receiptHeader)
477
746
  if (receipt.reference && /^0x[0-9a-fA-F]{64}$/.test(receipt.reference)) {
478
- const chain = isTestnet ? tempoModerato : tempoMainnetChain
479
- const explorerUrl = chain.blockExplorers?.default?.url
747
+ const explorerUrl = explorerChain?.blockExplorers?.default?.url
480
748
  if (explorerUrl) {
481
- results.push(check('On-chain transaction', `${explorerUrl}/receipt/${receipt.reference}`))
749
+ const path = explorerUrl.includes('tempo') ? 'receipt' : 'tx'
750
+ results.push(check('On-chain transaction', `${explorerUrl}/${path}/${receipt.reference}`))
482
751
  }
483
752
  }
484
753
  } catch {}