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
@@ -677,3 +677,97 @@ describe('validate: multi-challenge', () => {
677
677
  expect(output).toContain('Amount is valid integer string (Got: 1.50)')
678
678
  })
679
679
  })
680
+
681
+ describe('validate: payment methods coverage', () => {
682
+ test(
683
+ 'every method in Constants.Methods produces a payment result',
684
+ { timeout: 15_000 },
685
+ async () => {
686
+ const methods = Object.values(Constants.Methods)
687
+ const challenges = methods.map((method) => ({
688
+ id: `${method}-id`,
689
+ realm: 'localhost',
690
+ method,
691
+ intent: 'charge',
692
+ request: { amount: '100', currency: 'usd', methodDetails: {} },
693
+ expires: new Date(Date.now() + 300_000).toISOString(),
694
+ })) as Challenge.Challenge[]
695
+
696
+ const header = challenges.map((c) => Challenge.serialize(c)).join(', ')
697
+ const server = await testServer((req, res) => {
698
+ const url = new URL(req.url!, 'http://localhost')
699
+ if (url.pathname === '/openapi.json') {
700
+ res.setHeader('Content-Type', 'application/json')
701
+ res.end(
702
+ JSON.stringify({
703
+ openapi: '3.1.0',
704
+ info: { title: 'T', version: '1' },
705
+ paths: {
706
+ '/api/test': {
707
+ post: { 'x-payment-info': { amount: '100' }, responses: { '402': {} } },
708
+ },
709
+ },
710
+ }),
711
+ )
712
+ return
713
+ }
714
+ res.writeHead(402, { [Constants.Headers.wwwAuthenticate]: header })
715
+ res.end()
716
+ })
717
+ const { output } = await serve(['validate', server.url, '--outputJson', '--yes'])
718
+ const jsonStart = output.indexOf('{')
719
+ const jsonEnd = output.lastIndexOf('}')
720
+ const result = JSON.parse(output.slice(jsonStart, jsonEnd + 1))
721
+ const paymentLabels = result.endpoints[0].payment.map((r: { label: string }) => r.label)
722
+ for (const method of methods) {
723
+ expect(paymentLabels.some((l: string) => l.includes(`[${method}]`))).toBe(true)
724
+ }
725
+ },
726
+ )
727
+ })
728
+
729
+ describe('validate: JSON mode', () => {
730
+ function mainnetChallenge() {
731
+ return makeChallenge({
732
+ request: {
733
+ amount: '10000',
734
+ currency: '0x20c0000000000000000000000000000000000000',
735
+ recipient: '0x1234567890123456789012345678901234567890',
736
+ methodDetails: { chainId: 4217 },
737
+ },
738
+ } as Partial<Challenge.Challenge>)
739
+ }
740
+
741
+ function parseJson(output: string) {
742
+ const jsonStart = output.indexOf('{')
743
+ const jsonEnd = output.lastIndexOf('}')
744
+ return JSON.parse(output.slice(jsonStart, jsonEnd + 1))
745
+ }
746
+
747
+ test('does not prompt (non-interactive)', { timeout: 15_000 }, async () => {
748
+ const server = await mppServer(mainnetChallenge())
749
+ const { output } = await serve(['validate', server.url, '--outputJson'])
750
+ const result = parseJson(output)
751
+ const allPayment = result.endpoints.flatMap((ep: { payment: unknown[] }) => ep.payment)
752
+ expect(allPayment.length).toBeGreaterThan(0)
753
+ expect(allPayment.every((r: { severity: string }) => r.severity === 'skip')).toBe(true)
754
+ })
755
+
756
+ test('no console leaks before JSON', { timeout: 15_000 }, async () => {
757
+ const server = await mppServer(mainnetChallenge())
758
+ const { output } = await serve(['validate', server.url, '--outputJson', '--yes'])
759
+ const jsonStart = output.indexOf('{')
760
+ expect(jsonStart).toBeGreaterThanOrEqual(0)
761
+ const beforeJson = output.slice(0, jsonStart)
762
+ expect(beforeJson).not.toContain('Using wallet')
763
+ expect(beforeJson).not.toContain('Auto-approved')
764
+ expect(beforeJson).not.toContain('Attempting')
765
+ })
766
+
767
+ test('includes suggestions', { timeout: 15_000 }, async () => {
768
+ const server = await mppServer(mainnetChallenge())
769
+ const { output } = await serve(['validate', server.url, '--outputJson', '--yes'])
770
+ const result = parseJson(output)
771
+ expect(Array.isArray(result.suggestions)).toBe(true)
772
+ })
773
+ })
@@ -565,7 +565,10 @@ describe('fillHostedFeePayerTransaction', () => {
565
565
  },
566
566
  },
567
567
  },
568
- (_key, value) => (typeof value === 'bigint' ? toHex(value) : value),
568
+ (key, value) => {
569
+ if (key === 'yParity') return toHex(value)
570
+ return typeof value === 'bigint' ? toHex(value) : value
571
+ },
569
572
  ),
570
573
  )
571
574
  })
@@ -224,7 +224,10 @@ export async function fillHostedFeePayerTransaction(parameters: {
224
224
 
225
225
  assertAllowedFeeToken({ feeToken: filled.feeToken }, allowedFeeTokens)
226
226
 
227
- const feePayerSignature = filled.feePayerSignature
227
+ const feePayerSignature = {
228
+ ...(filled.feePayerSignature as Record<string, unknown>),
229
+ yParity: Number((filled.feePayerSignature as { yParity?: unknown }).yParity),
230
+ }
228
231
  const feeToken = filled.feeToken
229
232
 
230
233
  // Recover the concrete sponsor address so the simulation can use a concrete
@@ -613,6 +613,48 @@ describe('precompile broadcastOpenTransaction', () => {
613
613
  expect(simulationIndex).toBeLessThan(broadcastIndex)
614
614
  })
615
615
 
616
+ test('hosted fee-payer relays a sender-signed open without local co-signing', async () => {
617
+ const rpcMethods: string[] = []
618
+ const serializedTransaction = await createOpenTransaction({ signed: true })
619
+ const transaction = Transaction.deserialize(
620
+ serializedTransaction as Transaction.TransactionSerializedTempo,
621
+ )
622
+ const payer = transaction.from!
623
+ const expiringNonceHash = Channel.computeExpiringNonceHash(
624
+ Channel.transactionForExpiringNonceHash({ feePayer: true, transaction }),
625
+ { sender: payer },
626
+ )
627
+ const expectedDescriptor = { ...descriptor, payer, expiringNonceHash }
628
+ const channelId = Channel.computeId({
629
+ ...expectedDescriptor,
630
+ chainId,
631
+ escrow: tip20ChannelEscrow,
632
+ })
633
+ const state = { settled: 0n, deposit, closeRequestedAt: 0 }
634
+
635
+ await Chain.broadcastOpenTransaction({
636
+ chainId,
637
+ client: createMockClient({
638
+ channel: { descriptor: expectedDescriptor, state },
639
+ receipt: receipt([openedLog({ channelId, expiringNonceHash })]),
640
+ rpcMethods,
641
+ }),
642
+ escrowContract: tip20ChannelEscrow,
643
+ expectedAuthorizedSigner: descriptor.authorizedSigner,
644
+ expectedChannelId: channelId,
645
+ expectedCurrency: descriptor.token,
646
+ expectedExpiringNonceHash: expiringNonceHash,
647
+ expectedOperator: descriptor.operator,
648
+ expectedPayee: descriptor.payee,
649
+ expectedPayer: payer,
650
+ feePayer: true,
651
+ serializedTransaction,
652
+ })
653
+
654
+ expect(rpcMethods).toContain('eth_sendRawTransaction')
655
+ expect(rpcMethods).not.toContain('eth_sendRawTransactionSync')
656
+ })
657
+
616
658
  test('rejects expiring nonce hash mismatches before broadcasting', async () => {
617
659
  const serializedTransaction = await createOpenTransaction()
618
660
 
@@ -406,7 +406,7 @@ type ParsedPrecompileCredentialTransaction = {
406
406
 
407
407
  function parsePrecompileCredentialTransaction(parameters: {
408
408
  escrowContract: Address
409
- feePayer?: Account | undefined
409
+ feePayer?: Account | true | undefined
410
410
  label: 'open' | 'topUp'
411
411
  serializedTransaction: Hex
412
412
  }): ParsedPrecompileCredentialTransaction {
@@ -668,8 +668,8 @@ export type SendCredentialTransactionParameters = {
668
668
  allowedFeeTokens?: readonly Address[] | undefined
669
669
  /** Human-readable transaction details used by fee-payer policy hooks. */
670
670
  details: Record<string, string>
671
- /** Fee-payer account used to co-sign Tempo transactions. */
672
- feePayer?: Account | undefined
671
+ /** Fee-payer account, or `true` when the client transport delegates co-signing to a hosted relay. */
672
+ feePayer?: Account | true | undefined
673
673
  /** Optional fee-payer policy enforced before co-signing. */
674
674
  feePayerPolicy?: Partial<FeePayer.Policy> | undefined
675
675
  /** Management transaction kind, used for validation errors. */
@@ -704,6 +704,11 @@ export async function sendCredentialTransaction(parameters: SendCredentialTransa
704
704
  throw new BadRequestError({ reason: 'Only Tempo (0x76/0x78) transactions are supported' })
705
705
  assertSenderSigned(transaction)
706
706
 
707
+ if (feePayer === true) {
708
+ const txHash = await sendTransaction(client, serializedTransaction)
709
+ return waitForSuccessfulReceipt(client, txHash)
710
+ }
711
+
707
712
  await simulateTempoTransaction(client, transaction)
708
713
 
709
714
  const sponsored = FeePayer.prepareSponsoredTransaction({
@@ -771,8 +776,8 @@ export type BroadcastOpenTransactionParameters = {
771
776
  expectedPayee: Address
772
777
  /** Payer expected to have signed the open transaction. */
773
778
  expectedPayer: Address
774
- /** Fee-payer account used to co-sign sponsored open transactions. */
775
- feePayer?: Account | undefined
779
+ /** Fee-payer account, or `true` when the client transport delegates co-signing to a hosted relay. */
780
+ feePayer?: Account | true | undefined
776
781
  /** Optional fee-payer policy enforced before co-signing. */
777
782
  feePayerPolicy?: Partial<FeePayer.Policy> | undefined
778
783
  /** Client-signed serialized open transaction. */
@@ -895,8 +900,8 @@ export type BroadcastTopUpTransactionParameters = {
895
900
  expectedChannelId: Hex
896
901
  /** Payment token expected for sponsored transaction fee token checks. */
897
902
  expectedCurrency: Address
898
- /** Fee-payer account used to co-sign sponsored top-up transactions. */
899
- feePayer?: Account | undefined
903
+ /** Fee-payer account, or `true` when the client transport delegates co-signing to a hosted relay. */
904
+ feePayer?: Account | true | undefined
900
905
  /** Optional fee-payer policy enforced before co-signing. */
901
906
  feePayerPolicy?: Partial<FeePayer.Policy> | undefined
902
907
  /** Client-signed serialized top-up transaction. */
@@ -334,8 +334,8 @@ export type VerifyCredentialPayloadParameters = {
334
334
  escrow: Address
335
335
  /** Operator address advertised in the HMAC-bound challenge details. */
336
336
  expectedOperator?: Address | undefined
337
- /** Optional fee-payer account for fee-sponsored management transactions. */
338
- feePayer?: viem_Account | undefined
337
+ /** Fee-payer account, or `true` when the client transport delegates co-signing to a hosted relay. */
338
+ feePayer?: viem_Account | true | undefined
339
339
  /** Optional policy for fee-sponsored close/open/top-up transactions. */
340
340
  feePayerPolicy?: Partial<FeePayer.Policy> | undefined
341
341
  /** Optional fee token override for close transactions. */
@@ -714,7 +714,7 @@ async function handleCloseCredential(
714
714
  account
715
715
  ? {
716
716
  account,
717
- ...(parameters.feePayer ? { feePayer: parameters.feePayer } : {}),
717
+ ...(typeof parameters.feePayer === 'object' ? { feePayer: parameters.feePayer } : {}),
718
718
  ...(parameters.feePayerPolicy ? { feePayerPolicy: parameters.feePayerPolicy } : {}),
719
719
  ...(parameters.feeToken ? { feeToken: parameters.feeToken } : {}),
720
720
  candidateFeeTokens: [channel.token],
@@ -27,6 +27,7 @@ import {
27
27
  resolveCredentialFeePayer,
28
28
  resolveRequestFeePayer,
29
29
  type ParameterFeePayer,
30
+ type ResolvedFeePayer,
30
31
  } from './Settlement.js'
31
32
 
32
33
  /** Inputs used to build server bootstrap hints for a reusable session channel. */
@@ -267,8 +268,8 @@ export type ResolveSessionPaymentRequestParameters = {
267
268
 
268
269
  /** Inputs used to resolve shared context for credential verification. */
269
270
  export type ResolveCredentialVerificationContextParameters = {
270
- /** Default configured fee-payer account, when enabled. */
271
- feePayer?: viem_Account | undefined
271
+ /** Configured local fee payer, hosted relay URL, or sponsorship flag. */
272
+ feePayer?: ParameterFeePayer
272
273
  /** Resolves a viem client for the challenge chain ID. */
273
274
  getClient(parameters: {
274
275
  chainId: number
@@ -294,7 +295,7 @@ export type CredentialVerificationContext = {
294
295
  /** Client for precompile reads and transaction broadcasts. */
295
296
  client: PrecompileChain.TransactionClient
296
297
  /** Fee payer authorized for this credential, when allowed. */
297
- feePayer?: viem_Account | undefined
298
+ feePayer?: ResolvedFeePayer
298
299
  /** Minimum allowed voucher delta in raw token units. */
299
300
  minVoucherDelta: bigint
300
301
  }
@@ -315,9 +315,11 @@ export function session<const parameters extends session.Parameters>(
315
315
 
316
316
  const store = ChannelStore.fromStore(rawStore)
317
317
  const lastOnChainVerified = new Map<Hex, number>()
318
- const { account, feePayer, recipient } = Account.resolve(parameters)
318
+ const { account, feePayer, feePayerUrl, recipient } = Account.resolve(parameters)
319
+ const configuredFeePayer = feePayer ?? feePayerUrl
319
320
  const getClient = Client.getResolver({
320
321
  chain: tempo_chain,
322
+ feePayerUrl,
321
323
  getClient: parameters.getClient,
322
324
  rpcUrl: defaults.rpcUrl,
323
325
  })
@@ -326,6 +328,7 @@ export function session<const parameters extends session.Parameters>(
326
328
  chainId: parameters.chainId,
327
329
  currency,
328
330
  decimals,
331
+ feePayer: parameters.feePayer,
329
332
  getClient: parameters.getClient,
330
333
  recipient,
331
334
  store: rawStore,
@@ -401,7 +404,7 @@ export function session<const parameters extends session.Parameters>(
401
404
  const payload = requireSessionCredentialPayload(credential.payload)
402
405
  const context = await resolveCredentialVerificationContext({
403
406
  decimals,
404
- feePayer,
407
+ feePayer: configuredFeePayer,
405
408
  getClient,
406
409
  minVoucherDelta: parameters.minVoucherDelta,
407
410
  request,
@@ -438,7 +441,7 @@ export function session<const parameters extends session.Parameters>(
438
441
  maybeSettleScheduled({
439
442
  account,
440
443
  client: context.client,
441
- feePayer: context.feePayer,
444
+ ...(typeof context.feePayer === 'object' ? { feePayer: context.feePayer } : {}),
442
445
  feePayerPolicy: parameters.feePayerPolicy,
443
446
  feeToken: parameters.feeToken,
444
447
  schedule: settlementSchedule,
@@ -23,6 +23,7 @@ describe('FeePayerResolution', () => {
23
23
  const requestFeePayer = privateKeyToAccount(
24
24
  '0x5de4111a56d1f1ad3c74c8a3be6fba32114d0f9f8e9e4b0d4d4f5a7833f1b6c9',
25
25
  )
26
+ const feePayerUrl = 'https://fee-payer.example/relay'
26
27
 
27
28
  function credential(): Credential.Credential {
28
29
  return {
@@ -60,6 +61,13 @@ describe('FeePayerResolution', () => {
60
61
  parameterFeePayer: true,
61
62
  }),
62
63
  ).toBe(true)
64
+
65
+ expect(
66
+ resolveRequestFeePayer({
67
+ credential: null,
68
+ parameterFeePayer: feePayerUrl,
69
+ }),
70
+ ).toBe(true)
63
71
  })
64
72
 
65
73
  test('does not advertise fee-payer support when request disables it', () => {
@@ -106,6 +114,14 @@ describe('FeePayerResolution', () => {
106
114
  }),
107
115
  ).toBe(defaultFeePayer)
108
116
 
117
+ expect(
118
+ resolveCredentialFeePayer({
119
+ feePayer: feePayerUrl,
120
+ methodDetails: { feePayer: true },
121
+ request: { feePayer: true },
122
+ }),
123
+ ).toBe(true)
124
+
109
125
  expect(
110
126
  resolveCredentialFeePayer({
111
127
  feePayer: defaultFeePayer,
@@ -32,6 +32,9 @@ import * as ChannelStore from './ChannelStore.js'
32
32
  /** Fee-payer parameter accepted by the server session method. */
33
33
  export type ParameterFeePayer = viem_Account | string | true | undefined
34
34
 
35
+ /** Resolved fee-payer mode for credential-time transaction submission. */
36
+ export type ResolvedFeePayer = viem_Account | true | undefined
37
+
35
38
  /** Minimum method details needed to decide credential-time fee sponsorship. */
36
39
  export type CredentialFeePayerMethodDetails = {
37
40
  /** Whether the challenge advertised fee-payer support. */
@@ -56,8 +59,8 @@ export type ResolveCredentialFeePayerParameters = {
56
59
  request: unknown
57
60
  /** Challenge method details echoed by the credential. */
58
61
  methodDetails: CredentialFeePayerMethodDetails
59
- /** Default fee-payer account resolved from server parameters. */
60
- feePayer?: viem_Account | undefined
62
+ /** Configured local fee payer, hosted relay URL, or sponsorship flag. */
63
+ feePayer?: ParameterFeePayer
61
64
  }
62
65
 
63
66
  /** Fee-payer value read from an untrusted credential challenge request. */
@@ -89,20 +92,28 @@ export function resolveRequestFeePayer(
89
92
 
90
93
  const account = typeof requestFeePayer === 'object' ? requestFeePayer : defaultFeePayer
91
94
  if (credential) return account ?? undefined
92
- if (account || defaultFeePayer || parameterFeePayer === true) return true
95
+ if (
96
+ account ||
97
+ defaultFeePayer ||
98
+ parameterFeePayer === true ||
99
+ typeof parameterFeePayer === 'string'
100
+ )
101
+ return true
93
102
  return undefined
94
103
  }
95
104
 
96
105
  /** Resolves the fee-payer account allowed for an incoming credential. */
97
106
  export function resolveCredentialFeePayer(
98
107
  parameters: ResolveCredentialFeePayerParameters,
99
- ): viem_Account | undefined {
108
+ ): ResolvedFeePayer {
100
109
  const { feePayer, methodDetails, request } = parameters
101
110
  const requestFeePayer = readRequestFeePayer(request)
102
111
  const requestAllowsFeePayer =
103
112
  requestFeePayer === undefined || requestFeePayer === true || typeof requestFeePayer === 'object'
104
113
  if (methodDetails.feePayer !== true || !requestAllowsFeePayer) return undefined
105
- return typeof requestFeePayer === 'object' ? requestFeePayer : feePayer
114
+ if (typeof requestFeePayer === 'object') return requestFeePayer
115
+ if (typeof feePayer === 'object') return feePayer
116
+ return typeof feePayer === 'string' ? true : undefined
106
117
  }
107
118
 
108
119
  /** Declarative server-side settlement cadence for automatic session settlement. */
@@ -1,3 +1,6 @@
1
+ import type { Chain } from 'viem'
2
+ import * as viemChains from 'viem/chains'
3
+
1
4
  import { validateChallenge, validateErrorHandling } from '../cli/validate/challenge.js'
2
5
  import {
3
6
  buildUrl,
@@ -25,6 +28,7 @@ export type ValidateOptions = {
25
28
  verbose?: boolean | undefined
26
29
  yes?: boolean | undefined
27
30
  skipPayment?: boolean | undefined
31
+ interactive?: boolean | undefined
28
32
  discoveryPath?: string | undefined
29
33
  onPaymentResults?: (results: CheckResult[]) => void
30
34
  }
@@ -50,14 +54,7 @@ export type ValidateResult = {
50
54
  discovery: DiscoveryResult
51
55
  endpoints: EndpointValidationResult[]
52
56
  summary: { passed: number; failed: number; warnings: number; skipped: number }
53
- flags: {
54
- sawMppEndpoint: boolean
55
- sawNonMppPaymentEndpoint: boolean
56
- sawMalformedChallenge: boolean
57
- sawTestnet: boolean
58
- sawMainnet: boolean
59
- paymentSucceeded: boolean
60
- }
57
+ suggestions: string[]
61
58
  }
62
59
 
63
60
  export type ValidateEvent =
@@ -69,8 +66,10 @@ export type ValidateEvent =
69
66
  results: CheckResult[]
70
67
  isMpp: boolean
71
68
  isTestnet: boolean
69
+ isMainnet: boolean
72
70
  isNonMppPayment: boolean
73
71
  isMalformedChallenge: boolean
72
+ methods: string[]
74
73
  }
75
74
  | { phase: 'errorHandling'; endpoint: EndpointSpec; results: CheckResult[] }
76
75
  | {
@@ -102,26 +101,25 @@ export async function* validateStream(options: ValidateOptions): AsyncGenerator<
102
101
  }
103
102
  }
104
103
 
105
- const { results: challengeResults, resolvedBody } = await validateChallenge(
106
- baseUrl,
107
- endpoint,
108
- verbose,
109
- {
110
- body,
111
- query: options.query,
112
- discoveryDoc: discovery.doc ?? undefined,
113
- },
114
- )
104
+ const {
105
+ results: challengeResults,
106
+ resolvedBody,
107
+ challenges: parsedChallenges,
108
+ } = await validateChallenge(baseUrl, endpoint, verbose, {
109
+ body,
110
+ query: options.query,
111
+ discoveryDoc: discovery.doc ?? undefined,
112
+ })
115
113
  const effectiveBody = resolvedBody ?? body
116
114
  const isMpp = challengeResults.some(
117
115
  (r) => r.severity === 'pass' && r.label === 'Challenge parseable',
118
116
  )
119
- const isTestnet = challengeResults.some(
120
- (r) =>
121
- r.severity === 'pass' &&
122
- r.label.endsWith('Valid currency address') &&
123
- r.detail === 'testnet',
124
- )
117
+ const chainIds = (parsedChallenges ?? [])
118
+ .filter((ch) => ch.method !== 'stripe')
119
+ .map((ch) => ((ch.request as Record<string, unknown>).methodDetails as any)?.chainId)
120
+ .filter((id): id is number => typeof id === 'number')
121
+ const isTestnet = chainIds.some(chainIsTestnet)
122
+ const isMainnet = chainIds.some((id) => !chainIsTestnet(id))
125
123
  const isNonMppPayment =
126
124
  !isMpp && challengeResults.some((r) => r.label === 'Not an MPP endpoint')
127
125
  const hasPaymentScheme = challengeResults.some(
@@ -132,14 +130,18 @@ export async function* validateStream(options: ValidateOptions): AsyncGenerator<
132
130
  )
133
131
  const isMalformedChallenge = !isMpp && hasPaymentScheme && challengeFailed
134
132
 
133
+ const methods = parsedChallenges?.map((c) => c.method) ?? []
134
+
135
135
  yield {
136
136
  phase: 'challenge',
137
137
  endpoint,
138
138
  results: challengeResults,
139
139
  isMpp,
140
140
  isTestnet,
141
+ isMainnet,
141
142
  isNonMppPayment,
142
143
  isMalformedChallenge,
144
+ methods,
143
145
  }
144
146
 
145
147
  if (isMpp) {
@@ -155,6 +157,8 @@ export async function* validateStream(options: ValidateOptions): AsyncGenerator<
155
157
  body: effectiveBody,
156
158
  query: options.query,
157
159
  yes: options.yes,
160
+ silent: !onResults,
161
+ interactive: options.interactive,
158
162
  ...(onResults && { onResults }),
159
163
  })
160
164
  const succeeded = paymentResults.some(
@@ -175,10 +179,6 @@ export async function validate(options: ValidateOptions): Promise<ValidateResult
175
179
 
176
180
  let sawTestnet = false
177
181
  let sawMainnet = false
178
- let paymentSucceeded = false
179
- let sawMppEndpoint = false
180
- let sawNonMppPaymentEndpoint = false
181
- let sawMalformedChallenge = false
182
182
 
183
183
  for await (const event of validateStream(options)) {
184
184
  switch (event.phase) {
@@ -198,19 +198,15 @@ export async function validate(options: ValidateOptions): Promise<ValidateResult
198
198
  case 'challenge':
199
199
  if (current) current.challenge = event.results
200
200
  if (event.isMpp) {
201
- sawMppEndpoint = true
202
201
  if (event.isTestnet) sawTestnet = true
203
- else sawMainnet = true
202
+ if (event.isMainnet) sawMainnet = true
204
203
  }
205
- if (event.isNonMppPayment) sawNonMppPaymentEndpoint = true
206
- if (event.isMalformedChallenge) sawMalformedChallenge = true
207
204
  break
208
205
  case 'errorHandling':
209
206
  if (current) current.errorHandling = event.results
210
207
  break
211
208
  case 'payment':
212
209
  if (current) current.payment = event.results
213
- if (event.succeeded) paymentSucceeded = true
214
210
  break
215
211
  }
216
212
  }
@@ -235,20 +231,50 @@ export async function validate(options: ValidateOptions): Promise<ValidateResult
235
231
 
236
232
  if (!discovery) throw new Error('Discovery phase did not complete')
237
233
 
234
+ const suggestions = computeSuggestions(endpointResults, { sawTestnet, sawMainnet })
235
+
238
236
  return {
239
237
  url: baseUrl,
240
238
  discovery,
241
239
  endpoints: endpointResults,
242
240
  summary,
243
- flags: {
244
- sawMppEndpoint,
245
- sawNonMppPaymentEndpoint,
246
- sawMalformedChallenge,
247
- sawTestnet,
248
- sawMainnet,
249
- paymentSucceeded,
250
- },
241
+ suggestions,
242
+ }
243
+ }
244
+
245
+ function computeSuggestions(
246
+ endpoints: EndpointValidationResult[],
247
+ flags: { sawTestnet: boolean; sawMainnet: boolean },
248
+ ): string[] {
249
+ const steps: string[] = []
250
+ const allResults = endpoints.flatMap((ep) => [...ep.challenge, ...ep.payment])
251
+
252
+ const sawCryptoMainnet = flags.sawMainnet
253
+ const sawCryptoTestnet = flags.sawTestnet
254
+ const sawStripeLive = allResults.some(
255
+ (r) => r.label.includes('[stripe]') && r.label.includes('livemode'),
256
+ )
257
+
258
+ if (sawCryptoMainnet && !sawCryptoTestnet) {
259
+ steps.push(
260
+ 'Validate your testnet server to test without real funds (Tempo wallets are automatically provisioned).',
261
+ )
262
+ } else if (sawCryptoTestnet && !sawCryptoMainnet) {
263
+ steps.push('Validate your mainnet server to confirm real payments work end-to-end.')
251
264
  }
265
+ if (sawStripeLive) {
266
+ steps.push(
267
+ 'Validate your testmode Stripe server to test card payments end-to-end (auto-pays with test card).',
268
+ )
269
+ }
270
+
271
+ return steps
272
+ }
273
+
274
+ const allChains = Object.values(viemChains) as Chain[]
275
+
276
+ function chainIsTestnet(chainId: number): boolean {
277
+ return allChains.find((c) => c.id === chainId)?.testnet === true
252
278
  }
253
279
 
254
280
  async function runDiscovery(baseUrl: string, options: ValidateOptions): Promise<DiscoveryResult> {