diff --git a/.changeset/validate-error-reporting.md b/.changeset/validate-error-reporting.md new file mode 100644 index 00000000..64f5936c --- /dev/null +++ b/.changeset/validate-error-reporting.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +`mppx validate`: Multi-challenge support (parses all payment methods from WWW-Authenticate), per-method field validation for Stripe/EVM/Tempo, streaming CLI output, fixed URL resolution for subpaths, and improved error reporting for malformed challenges. diff --git a/src/cli/validate.test.ts b/src/cli/validate.test.ts index c700abbb..ca483277 100644 --- a/src/cli/validate.test.ts +++ b/src/cli/validate.test.ts @@ -159,6 +159,12 @@ describe('validate: discovery', () => { expect(output).toContain('Document found and parseable') }) + test('discovers openapi.json at root when subpath given', { timeout: 15_000 }, async () => { + const server = await mppServer(makeChallenge()) + const { output } = await serve(['validate', `${server.url}/mpp`]) + expect(output).toContain('Document found and parseable') + }) + test('reports invalid JSON', { timeout: 15_000 }, async () => { const server = await testServer((req, res) => { if (req.url?.includes('openapi.json')) { @@ -537,3 +543,137 @@ describe('validate: summary', () => { expect(output).toContain('failed') }) }) + +describe('validate: multi-challenge', () => { + function makeStripeChallenge(): Challenge.Challenge { + return { + id: 'stripe-id-123', + realm: 'localhost', + method: 'stripe', + intent: 'charge', + request: { + amount: '100', + currency: 'usd', + methodDetails: { + networkId: 'profile_test123', + paymentMethodTypes: ['card'], + }, + }, + expires: new Date(Date.now() + 300_000).toISOString(), + } as Challenge.Challenge + } + + function makeEvmChallenge(): Challenge.Challenge { + return { + id: 'evm-id-123', + realm: 'localhost', + method: 'evm', + intent: 'charge', + request: { + amount: '10000', + currency: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + recipient: '0x1234567890123456789012345678901234567890', + methodDetails: { chainId: 8453, credentialTypes: ['authorization'], decimals: 6 }, + }, + expires: new Date(Date.now() + 300_000).toISOString(), + } as Challenge.Challenge + } + + function multiChallengeServer(challenges: Challenge.Challenge[]) { + const header = challenges.map((c) => Challenge.serialize(c)).join(', ') + return testServer((req, res) => { + const url = new URL(req.url!, 'http://localhost') + if (url.pathname === '/openapi.json') { + res.setHeader('Content-Type', 'application/json') + res.end(makeDiscoveryDoc({ '/api/test': {} })) + return + } + res.writeHead(402, { [Constants.Headers.wwwAuthenticate]: header }) + res.end() + }) + } + + test('parses multiple challenges', { timeout: 15_000 }, async () => { + const server = await multiChallengeServer([makeChallenge(), makeStripeChallenge()]) + const { output } = await serve(['validate', server.url]) + expect(output).toContain('2 methods: tempo/charge, stripe/charge') + }) + + test('validates Stripe fields', { timeout: 15_000 }, async () => { + const server = await multiChallengeServer([makeStripeChallenge()]) + const { output } = await serve(['validate', server.url]) + expect(output).toContain('Amount is valid integer string') + expect(output).toContain('Valid currency code (USD)') + expect(output).toContain('Has networkId') + expect(output).toContain('Has paymentMethodTypes (card)') + }) + + test('validates EVM fields', { timeout: 15_000 }, async () => { + const server = await multiChallengeServer([makeEvmChallenge()]) + const { output } = await serve(['validate', server.url]) + expect(output).toContain('Valid recipient address') + expect(output).toContain('Valid currency address') + expect(output).toContain('Amount is valid integer string') + expect(output).toContain('Has chainId (8453)') + }) + + test('tags method names when multiple challenges present', { timeout: 15_000 }, async () => { + const server = await multiChallengeServer([ + makeChallenge(), + makeStripeChallenge(), + makeEvmChallenge(), + ]) + const { output } = await serve(['validate', server.url]) + expect(output).toContain('[tempo] Valid recipient address') + expect(output).toContain('[stripe] Valid currency code') + expect(output).toContain('[evm] Has chainId') + }) + + test('no tags with single challenge', { timeout: 15_000 }, async () => { + const server = await multiChallengeServer([makeChallenge()]) + const { output } = await serve(['validate', server.url]) + expect(output).toContain('Valid recipient address') + expect(output).not.toContain('[tempo]') + }) + + test('Tempo requires recipient or splits', { timeout: 15_000 }, async () => { + const noRecipient = makeChallenge({ + request: { + amount: '10000', + currency: '0x20c0000000000000000000000000000000000000', + methodDetails: { chainId: 42431 }, + }, + } as Partial) + const server = await multiChallengeServer([noRecipient]) + const { output } = await serve(['validate', server.url]) + expect(output).toContain('Missing recipient (and no splits defined)') + }) + + test('Tempo accepts splits without recipient', { timeout: 15_000 }, async () => { + const withSplits = makeChallenge({ + request: { + amount: '10000', + currency: '0x20c0000000000000000000000000000000000000', + methodDetails: { + chainId: 42431, + splits: [ + { amount: '5000', recipient: '0x1234567890123456789012345678901234567890' }, + { amount: '5000', recipient: '0x0987654321098765432109876543210987654321' }, + ], + }, + }, + } as Partial) + const server = await multiChallengeServer([withSplits]) + const { output } = await serve(['validate', server.url]) + expect(output).toContain('Uses splits (no single recipient)') + expect(output).not.toContain('Missing recipient') + }) + + test('fails on invalid Stripe amount', { timeout: 15_000 }, async () => { + const bad = makeStripeChallenge() + ;(bad.request as Record).amount = '1.50' + const server = await multiChallengeServer([bad]) + const { output } = await serve(['validate', server.url]) + expect(output).toContain('Amount is valid integer string (Got: 1.50)') + }) +}) diff --git a/src/cli/validate/challenge.ts b/src/cli/validate/challenge.ts index 4b3a918b..a320a47a 100644 --- a/src/cli/validate/challenge.ts +++ b/src/cli/validate/challenge.ts @@ -33,7 +33,11 @@ export async function validateChallenge( query?: string[] | undefined discoveryDoc?: Record | undefined }, -): Promise<{ results: CheckResult[]; resolvedBody?: string | undefined }> { +): Promise<{ + results: CheckResult[] + resolvedBody?: string | undefined + challenges?: Challenge.Challenge[] | undefined +}> { const results: CheckResult[] = [] const url = buildUrl(baseUrl, endpoint, options?.query) const fetchHeaders: Record = {} @@ -125,160 +129,331 @@ export async function validateChallenge( } results.push(check('WWW-Authenticate header present', 'Payment scheme')) - // Parse challenge - let challenge: Challenge.Challenge + // Parse all challenges + let challenges: Challenge.Challenge[] try { - challenge = Challenge.fromResponse(response) + challenges = Challenge.fromResponseList(response) } catch (error) { - results.push(fail('Challenge parseable', (error as Error).message)) + const msg = (error as Error).message + results.push( + fail( + 'Challenge parseable', + msg, + 'The Payment challenge must include a request="" parameter containing JSON-encoded payment details.', + ), + ) + const rawParams = wwwAuth.slice(wwwAuth.indexOf(' ') + 1) + results.push( + warn('Received', rawParams.length > 200 ? rawParams.slice(0, 200) + '...' : rawParams), + ) return { results } } - results.push(check('Challenge parseable', `${challenge.method}/${challenge.intent}`)) - // Validate required fields - if (!challenge.id) + if (challenges.length === 0) { + results.push(fail('Challenge parseable', 'No Payment challenges found in header')) + return { results } + } + + const methodList = challenges.map((c) => `${c.method}/${c.intent}`).join(', ') + results.push( + check( + 'Challenge parseable', + challenges.length === 1 ? methodList : `${challenges.length} methods: ${methodList}`, + ), + ) + + // Validate common fields across all challenges + const serverHost = new URL(baseUrl).hostname + validateIds(challenges, results) + validateRealms(challenges, results) + validateExpiration(challenges, results) + validateRealmMatchesHost(challenges, serverHost, results) + + // Method-specific validation + const hasMultipleMethods = challenges.length > 1 + for (const ch of challenges) { + const request = ch.request as Record + validateMethodFields(ch, request, results, hasMultipleMethods) + } + + if (verbose) { + for (const ch of challenges) { + console.log( + pc.dim(` Challenge (${ch.method}/${ch.intent}): ${JSON.stringify(ch, null, 2)}`), + ) + } + } + + return { results, resolvedBody: fetchBody, challenges } +} + +function validateIds(challenges: Challenge.Challenge[], results: CheckResult[]): void { + const missing = challenges.filter((ch) => !ch.id) + if (missing.length === 0) { + results.push(check('Challenge has id')) + } else { results.push( fail( 'Challenge has id', - undefined, - 'Every challenge must include a unique id field. Generate a random string or hash per challenge.', + `${missing.length} missing`, + 'Every challenge must include a unique id field.', ), ) - else results.push(check('Challenge has id')) + } +} - if (!challenge.realm) +function validateRealms(challenges: Challenge.Challenge[], results: CheckResult[]): void { + const missing = challenges.filter((ch) => !ch.realm) + if (missing.length === 0) { + results.push(check('Challenge has realm')) + } else { results.push( fail( 'Challenge has realm', - undefined, - "Set realm to your server's hostname. It tells clients who they are paying.", + `${missing.length} missing`, + "Set realm to your server's hostname.", + ), + ) + } +} + +function validateExpiration(challenges: Challenge.Challenge[], results: CheckResult[]): void { + const now = new Date() + for (const ch of challenges) { + if (!ch.expires) { + results.push( + warn( + 'Challenge has expiration', + 'missing expires field', + 'Add an expires field (ISO 8601) to prevent replay attacks.', + ), + ) + return + } + if (new Date(ch.expires) <= now) { + results.push( + fail( + 'Challenge expires in the future', + `Expired at ${ch.expires}`, + 'The expires timestamp must be in the future.', + ), + ) + return + } + } + const soonest = Math.min( + ...challenges.map((ch) => new Date(ch.expires!).getTime() - now.getTime()), + ) + results.push(check('Challenge expires in the future', `${Math.round(soonest / 60000)}m from now`)) +} + +function validateRealmMatchesHost( + challenges: Challenge.Challenge[], + serverHost: string, + results: CheckResult[], +): void { + const realms = [...new Set(challenges.map((ch) => ch.realm ?? ''))] + const badRealms = realms.filter((r) => r && r !== serverHost && !serverHost.endsWith(`.${r}`)) + if (badRealms.length > 0) { + results.push( + warn( + 'Realm matches server hostname', + `realm="${badRealms[0]}" vs host="${serverHost}"`, + 'Set the realm to your production hostname (or base domain) in the challenge.', ), ) - else results.push(check('Challenge has realm')) + } else { + results.push(check('Realm matches server hostname')) + } +} + +function validateMethodFields( + challenge: Challenge.Challenge, + request: Record, + results: CheckResult[], + hasMultipleMethods: boolean, +): void { + const tag = hasMultipleMethods ? `[${challenge.method}] ` : '' - if (!challenge.method) + if (challenge.method === Constants.Methods.tempo) + validateTempoFields(request, tag, results, challenge) + else if (challenge.method === Constants.Methods.stripe) + validateStripeFields(request, tag, results) + else if (challenge.method === Constants.Methods.evm) validateEvmFields(request, tag, results) +} + +function validateTempoFields( + request: Record, + tag: string, + results: CheckResult[], + challenge: Challenge.Challenge, +): void { + const methodDetails = request.methodDetails as Record | undefined + const hasSplits = Array.isArray(methodDetails?.splits) && methodDetails.splits.length > 0 + + if (isValidAddress(request.recipient)) { + results.push(check(`${tag}Valid recipient address`)) + } else if (request.recipient === undefined && hasSplits) { + results.push(check(`${tag}Uses splits (no single recipient)`)) + } else if (request.recipient === undefined) { results.push( fail( - 'Challenge has method', - undefined, - 'Set method to the payment method (e.g. "tempo", "stripe").', + `${tag}Valid recipient address`, + 'Missing recipient (and no splits defined)', + 'Set request.recipient to a valid 0x address, or use methodDetails.splits for multiple recipients.', ), ) - if (!challenge.intent) + } else { results.push( fail( - 'Challenge has intent', - undefined, - 'Set intent to the payment type (e.g. "charge", "session").', + `${tag}Valid recipient address`, + `Got: ${String(request.recipient)}`, + 'Set request.recipient to a valid 0x-prefixed 40-hex-char address.', ), ) + } - // Semantic checks - if (challenge.expires) { - const expiresDate = new Date(challenge.expires) - const now = new Date() - if (expiresDate <= now) { - results.push( - fail( - 'Challenge expires in the future', - `Expired at ${challenge.expires}`, - 'The expires timestamp must be in the future when the challenge is issued. Use a 5-10 minute window from the current time.', - ), - ) - } else { - const diffMs = expiresDate.getTime() - now.getTime() - const diffMin = Math.round(diffMs / 60000) - results.push(check('Challenge expires in the future', `${diffMin}m from now`)) - } + if (isValidAddress(request.currency)) { + const network = detectTestnet(challenge) ? 'testnet' : 'mainnet' + results.push(check(`${tag}Valid currency address`, network)) } else { results.push( - warn( - 'Challenge has expiration', - 'No expires field set', - 'Add an expires field (ISO 8601 datetime) to prevent replay attacks. Recommended: 5 minutes from issuance.', + fail( + `${tag}Valid currency address`, + `Got: ${String(request.currency)}`, + 'Set request.currency to a valid token address.', ), ) } - // Realm check (allow subdomain matches) - try { - const serverHost = new URL(baseUrl).hostname - const realm = challenge.realm ?? '' - const matches = serverHost === realm || serverHost.endsWith(`.${realm}`) - if (matches) { - results.push(check('Realm matches server hostname')) - } else { - results.push( - warn( - 'Realm matches server hostname', - `realm="${realm}" vs host="${serverHost}"`, - 'Set the realm to your production hostname (or base domain) in the challenge. Clients use realm to verify they are paying the right server.', - ), - ) - } - } catch {} + validateAmount( + request, + tag, + results, + 'token\'s smallest unit (e.g. "10000" = $0.01 for 6-decimal tokens)', + ) +} - // Method-specific validation - const request = challenge.request as Record - if (challenge.method === Constants.Methods.tempo) { - if (isValidAddress(request.recipient)) { - results.push(check('Valid recipient address')) - } else { - results.push( - fail( - 'Valid recipient address', - `Got: ${String(request.recipient)}`, - 'Set request.recipient to a valid 0x-prefixed 40-hex-char address. This is where payment will be sent.', - ), - ) - } +function validateStripeFields( + request: Record, + tag: string, + results: CheckResult[], +): void { + validateAmount(request, tag, results, 'currency\'s smallest unit (e.g. "100" = $1.00 for USD)') - if (isValidAddress(request.currency)) { - const isTestnet = detectTestnet(challenge) - const network = isTestnet ? 'testnet' : 'mainnet' - results.push(check('Valid currency address', `${network}`)) - } else { - results.push( - fail( - 'Valid currency address', - `Got: ${String(request.currency)}`, - 'Set request.currency to a valid token address. Common: "0x20c0000000000000000000000000000000000000" (PathUSD).', - ), - ) - } + const currency = request.currency as string | undefined + const validCurrency = typeof currency === 'string' && /^[a-z]{3}$/i.test(currency) + if (validCurrency) { + results.push(check(`${tag}Valid currency code`, currency.toUpperCase())) + } else { + results.push( + fail( + `${tag}Valid currency code`, + currency ? `Got: ${currency}` : 'missing', + 'Must be a three-letter ISO currency code (e.g. "usd").', + ), + ) + } - if (isValidIntegerAmount(request.amount)) { - results.push(check('Amount is valid integer string')) - } else if (request.amount === undefined || request.amount === null) { - results.push( - warn( - 'Amount is valid integer string', - 'No amount (dynamic pricing?)', - 'Set request.amount to a string of digits in the token\'s smallest unit (e.g. "10000" = $0.01 for 6-decimal tokens).', - ), - ) - } else { - results.push( - fail( - 'Amount is valid integer string', - `Got: ${String(request.amount)}`, - 'request.amount must be a string of digits (no decimals, no prefix). Example: "10000" for $0.01 with 6-decimal tokens.', - ), - ) - } - } else if (challenge.method === Constants.Methods.stripe) { - if (request.amount !== undefined) { - results.push(check('Stripe challenge has amount')) - } else { - results.push(warn('Stripe challenge has amount', 'No amount field')) - } + const methodDetails = request.methodDetails as Record | undefined + const networkId = methodDetails?.networkId as string | undefined + if (networkId) { + results.push(check(`${tag}Has networkId`, networkId.slice(0, 20))) + } else { + results.push( + fail( + `${tag}Has networkId`, + 'Missing methodDetails.networkId', + 'Set methodDetails.networkId to your Stripe Business Network profile ID.', + ), + ) } - if (verbose) { - console.log(pc.dim(` Challenge: ${JSON.stringify(challenge, null, 2)}`)) + const pmTypes = methodDetails?.paymentMethodTypes as string[] | undefined + if (Array.isArray(pmTypes) && pmTypes.length > 0) { + results.push(check(`${tag}Has paymentMethodTypes`, pmTypes.join(', '))) + } else { + results.push( + fail( + `${tag}Has paymentMethodTypes`, + 'Missing or empty', + 'Set methodDetails.paymentMethodTypes to an array (e.g. ["card"]).', + ), + ) + } +} + +function validateEvmFields( + request: Record, + tag: string, + results: CheckResult[], +): void { + if (isValidAddress(request.recipient)) { + results.push(check(`${tag}Valid recipient address`)) + } else { + results.push( + fail( + `${tag}Valid recipient address`, + `Got: ${String(request.recipient)}`, + 'Set request.recipient to a valid 0x-prefixed Ethereum address.', + ), + ) + } + + if (isValidAddress(request.currency)) { + results.push(check(`${tag}Valid currency address`)) + } else { + results.push( + fail( + `${tag}Valid currency address`, + `Got: ${String(request.currency)}`, + 'Set request.currency to a valid ERC-20 token contract address.', + ), + ) + } + + validateAmount(request, tag, results, "token's smallest unit") + + const methodDetails = request.methodDetails as Record | undefined + const chainId = methodDetails?.chainId as number | undefined + if (typeof chainId === 'number' && chainId > 0) { + results.push(check(`${tag}Has chainId`, String(chainId))) + } else { + results.push( + fail( + `${tag}Has chainId`, + 'Missing methodDetails.chainId', + 'Set methodDetails.chainId to the EVM chain ID.', + ), + ) } +} - return { results, resolvedBody: fetchBody } +function validateAmount( + request: Record, + tag: string, + results: CheckResult[], + unitHint: string, +): void { + if (isValidIntegerAmount(request.amount)) { + results.push(check(`${tag}Amount is valid integer string`)) + } else if (request.amount === undefined || request.amount === null) { + results.push( + fail( + `${tag}Amount is valid integer string`, + 'Missing amount', + `Set request.amount to a string of digits in the ${unitHint}.`, + ), + ) + } else { + results.push( + fail( + `${tag}Amount is valid integer string`, + `Got: ${String(request.amount)}`, + 'request.amount must be a string of digits (no decimals, no prefix).', + ), + ) + } } export async function validateErrorHandling( diff --git a/src/cli/validate/discovery.test.ts b/src/cli/validate/discovery.test.ts index 84e0d850..a7c900a9 100644 --- a/src/cli/validate/discovery.test.ts +++ b/src/cli/validate/discovery.test.ts @@ -70,6 +70,25 @@ describe('buildUrl', () => { expect(buildUrl('http://localhost', endpoint)).toBe('http://localhost/simple') }) }) + + describe('subpath base URL', () => { + test('preserves base URL subpath', () => { + const endpoint = { method: 'POST', path: '/plan' } + expect(buildUrl('http://localhost/mpp', endpoint)).toBe('http://localhost/mpp/plan') + }) + + test('preserves nested subpath', () => { + const endpoint = { method: 'GET', path: '/users/123' } + expect(buildUrl('http://localhost/api/v1', endpoint)).toBe( + 'http://localhost/api/v1/users/123', + ) + }) + + test('works with trailing slash on base', () => { + const endpoint = { method: 'POST', path: '/plan' } + expect(buildUrl('http://localhost/mpp/', endpoint)).toBe('http://localhost/mpp/plan') + }) + }) }) describe('extractEndpointsFromDiscovery', () => { diff --git a/src/cli/validate/discovery.ts b/src/cli/validate/discovery.ts index 5704a51c..efc618a3 100644 --- a/src/cli/validate/discovery.ts +++ b/src/cli/validate/discovery.ts @@ -4,7 +4,8 @@ import { fetchWithTimeout, HTTP_METHODS } from './helpers.js' export async function fetchDiscoveryDoc( baseUrl: string, ): Promise<{ doc: unknown; raw: string } | { error: string }> { - const url = new URL('/openapi.json', baseUrl).href + // Trailing slash makes URL treat baseUrl as a directory, so relative resolution appends rather than replaces the last segment. + const url = new URL('openapi.json', baseUrl.replace(/\/?$/, '/')).href try { const response = await fetchWithTimeout(url, {}) if (!response.ok) return { error: `HTTP ${response.status}` } @@ -91,7 +92,9 @@ export function buildUrl(baseUrl: string, endpoint: EndpointSpec, query?: string if (endpoint.parameters) { path = substitutePathParams(path, endpoint.parameters) } - let url = new URL(path, baseUrl).href + // Strip leading slash so URL resolves relative to baseUrl's path, not root. + const relativePath = path.startsWith('/') ? path.slice(1) : path + let url = new URL(relativePath, baseUrl.replace(/\/?$/, '/')).href if (query) { const u = new URL(url) for (const q of query) { diff --git a/src/cli/validate/index.ts b/src/cli/validate/index.ts index 83b431f2..19f3af27 100644 --- a/src/cli/validate/index.ts +++ b/src/cli/validate/index.ts @@ -53,6 +53,7 @@ const validate = Cli.create('validate', { const counts: Counts = { passed: 0, failed: 0, warnings: 0, skipped: 0 } let sawMppEndpoint = false let sawNonMppPaymentEndpoint = false + let sawMalformedChallenge = false let sawTestnet = false let sawMainnet = false let paymentSucceeded = false @@ -107,6 +108,7 @@ const validate = Cli.create('validate', { else sawMainnet = true } if (event.isNonMppPayment) sawNonMppPaymentEndpoint = true + if (event.isMalformedChallenge) sawMalformedChallenge = true break case 'errorHandling': console.log(pc.dim(' Error Handling')) @@ -126,6 +128,7 @@ const validate = Cli.create('validate', { { sawMppEndpoint, sawNonMppPaymentEndpoint, + sawMalformedChallenge, sawTestnet, sawMainnet, paymentSucceeded, @@ -142,6 +145,7 @@ function printSummary( flags: { sawMppEndpoint: boolean sawNonMppPaymentEndpoint: boolean + sawMalformedChallenge: boolean sawTestnet: boolean sawMainnet: boolean paymentSucceeded: boolean @@ -150,7 +154,23 @@ function printSummary( ): void { if (!flags.sawMppEndpoint && endpointsLength > 0) { console.log('') - if (flags.sawNonMppPaymentEndpoint) { + if (flags.sawMalformedChallenge) { + console.log( + pc.yellow( + ` Payment scheme detected but challenge format is invalid on ${endpointsLength} endpoint(s).`, + ), + ) + console.log( + pc.dim( + ' The server uses WWW-Authenticate: Payment but the challenge parameters do not conform to MPP.', + ), + ) + console.log( + pc.dim( + ' Fix: encode payment details as base64url JSON in a request="..." parameter. See errors above.', + ), + ) + } else if (flags.sawNonMppPaymentEndpoint) { console.log( pc.yellow( ` No MPP endpoints found. Tested ${endpointsLength} endpoint(s) but none use WWW-Authenticate: Payment.`, diff --git a/src/cli/validate/payment.ts b/src/cli/validate/payment.ts index a98a3333..54496dc1 100644 --- a/src/cli/validate/payment.ts +++ b/src/cli/validate/payment.ts @@ -1,4 +1,4 @@ -import { createClient, http } from 'viem' +import { type Address, createClient, http } from 'viem' import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' import { waitForTransactionReceipt } from 'viem/actions' import { Actions } from 'viem/tempo' @@ -11,11 +11,19 @@ import * as Receipt from '../../Receipt.js' import { tempo as tempoMethods } from '../../tempo/client/index.js' import { chainId as tempoChainIds } from '../../tempo/internal/defaults.js' import { resolveAccount, resolveAccountName } from '../account.js' -import { loadConfig, selectChallenge } from '../internal.js' +import { loadConfig, resolvePlugin } from '../internal.js' import { fetchTokenInfo, confirm, pc } from '../utils.js' import { buildUrl } from './discovery.js' import type { CheckResult, EndpointSpec } from './helpers.js' -import { check, fail, fetchWithTimeout, formatBytes, skip, warn } from './helpers.js' +import { + check, + fail, + fetchWithTimeout, + formatBytes, + isValidIntegerAmount, + skip, + warn, +} from './helpers.js' async function provisionAndPayTestnet( challenge: Challenge.Challenge, @@ -55,11 +63,27 @@ async function resolveWalletAddress(): Promise { } } +function methodSetupHint(challenge: Challenge.Challenge): string { + switch (challenge.method) { + case Constants.Methods.evm: + return 'no EVM payment plugin yet' + case 'solana': + return 'no Solana payment plugin yet' + default: + return `no plugin for ${challenge.method}/${challenge.intent}` + } +} + export async function validatePaymentFlow( baseUrl: string, endpoint: EndpointSpec, verbose: boolean, - options?: { body?: string | undefined; query?: string[] | undefined; yes?: boolean | undefined }, + options?: { + body?: string | undefined + query?: string[] | undefined + yes?: boolean | undefined + onResults?: (results: CheckResult[]) => void + }, ): Promise { const results: CheckResult[] = [] const url = buildUrl(baseUrl, endpoint, options?.query) @@ -88,33 +112,40 @@ export async function validatePaymentFlow( return results } - // Parse challenge - let challenge: Challenge.Challenge + // Parse all challenges + let challenges: Challenge.Challenge[] try { - challenge = Challenge.fromResponse(challengeResponse) + challenges = Challenge.fromResponseList(challengeResponse) } catch (error) { results.push(fail('Payment: parse challenge', (error as Error).message)) return results } - - // Detect network - const request = challenge.request as Record - const methodDetails = request.methodDetails as Record | undefined - const isTestnet = - typeof methodDetails?.chainId === 'number' && methodDetails.chainId !== tempoChainIds.mainnet + if (challenges.length === 0) { + results.push(fail('Payment: parse challenge', 'No Payment challenges in response')) + return results + } // Testnet Tempo: always use ephemeral wallet (zero-setup, free money) - if (isTestnet && challenge.method === Constants.Methods.tempo) { - const provisioned = await provisionAndPayTestnet(challenge, verbose) + const tempoTestnetChallenge = challenges.find((ch) => { + if (ch.method !== Constants.Methods.tempo) return false + const req = ch.request as Record + const md = req.methodDetails as Record | undefined + return typeof md?.chainId === 'number' && md.chainId !== tempoChainIds.mainnet + }) + + if (tempoTestnetChallenge) { + const provisioned = await provisionAndPayTestnet(tempoTestnetChallenge, verbose) if (provisioned) { const fakeResp = new Response(null, { status: 402, - headers: { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) }, + headers: { + [Constants.Headers.wwwAuthenticate]: Challenge.serialize(tempoTestnetChallenge), + }, }) try { const mppx = Mppx.create({ methods: provisioned.methods, polyfill: false }) const cred = await mppx.createCredential(fakeResp) - results.push(check('Payment: credential created', 'ephemeral testnet wallet')) + results.push(check('Payment: submitted', 'ephemeral testnet wallet')) return await sendAndValidateResponse( results, url, @@ -123,7 +154,7 @@ export async function validatePaymentFlow( fetchHeaders, fetchBody, verbose, - true, + tempoModerato, ) } catch (error) { results.push(fail('Payment: create credential', (error as Error).message)) @@ -137,177 +168,172 @@ export async function validatePaymentFlow( } } - // Mainnet: use configured wallet + // Try each challenge method (skip testnet challenge already handled above) const loaded = await loadConfig().catch(() => undefined) - const selected = selectChallenge([challenge], loaded?.config) + const isInteractive = process.stdin.isTTY ?? false + const supportedPaymentMethods: Set = new Set([Constants.Methods.tempo]) - if (!selected) { - results.push( - skip( - 'Payment: no configured method', - `Need ${challenge.method}/${challenge.intent}`, - 'Run "mppx account create" to create a local wallet for payment testing. The wallet is stored on your machine and only used by mppx.', - ), - ) - return results - } + for (const challenge of challenges) { + if (challenge === tempoTestnetChallenge) continue + if (!supportedPaymentMethods.has(challenge.method)) continue - const { plugin, method: directMethod } = selected + const flushStart = results.length + const flush = () => { + if (options?.onResults && results.length > flushStart) + options.onResults(results.slice(flushStart)) + } + const tag = `Payment [${challenge.method}]` - if (!plugin && !directMethod) { - results.push(skip('Payment: no plugin available', `${challenge.method}/${challenge.intent}`)) - return results - } + try { + const request = challenge.request as Record + const requiredAmount = isValidIntegerAmount(request.amount) + ? BigInt(request.amount as string) + : undefined + const methodDetails = request.methodDetails as Record | undefined + const decimals = + (methodDetails?.decimals as number | undefined) ?? + (request.decimals as number | undefined) ?? + 6 + const currency = request.currency as string | undefined + + // Resolve wallet + let walletAddress: string | undefined + try { + walletAddress = await resolveWalletAddress() + } catch {} - const requiredAmount = request.amount ? BigInt(request.amount as string) : undefined - // Display only -- defaults to 6 (standard for USDC/PathUSD tokens) - const decimals = (request.decimals as number | undefined) ?? 6 - const currency = request.currency as string | undefined + if (!walletAddress) { + results.push(skip(tag, 'no wallet configured. Run "mppx account create" to create one.')) + continue + } - // Pre-flight balance check - let walletAddress: string | undefined - if (challenge.method === Constants.Methods.tempo && requiredAmount && currency) { - try { - walletAddress = await resolveWalletAddress() - if (walletAddress) { - const client = createClient({ chain: tempoMainnetChain, transport: http() }) - const tokenInfo = await fetchTokenInfo( - client, - currency as `0x${string}`, - walletAddress as `0x${string}`, - ) - const requiredDisplay = `$${(Number(requiredAmount) / 10 ** decimals).toFixed(2)}` - const balanceDisplay = `$${(Number(tokenInfo.balance) / 10 ** tokenInfo.decimals).toFixed(2)}` - - if (tokenInfo.balance < requiredAmount) { - console.log(pc.dim(` Wallet: ${walletAddress}`)) - console.log(pc.dim(` Balance: ${balanceDisplay} ${tokenInfo.symbol}`)) - const hint = `Wallet ${walletAddress} has ${balanceDisplay} but endpoint requires ${requiredDisplay}. Fund this wallet to run payment tests, or use a testnet server.` - results.push( - skip( - 'Payment: insufficient balance', - `Have ${balanceDisplay}, need ${requiredDisplay}`, - hint, - ), - ) - return results + // Pre-flight balance check + let insufficientBalance = false + let tokenSymbol: string | undefined + if (requiredAmount && currency) { + try { + const client = createClient({ chain: tempoMainnetChain, transport: http() }) + const info = await fetchTokenInfo(client, currency as Address, walletAddress as Address) + tokenSymbol = info.symbol + if (info.balance < requiredAmount) { + const requiredDisplay = `$${(Number(requiredAmount) / 10 ** decimals).toFixed(2)}` + const balanceDisplay = `$${(Number(info.balance) / 10 ** info.decimals).toFixed(2)}` + results.push( + skip( + tag, + `insufficient balance (have ${balanceDisplay}, need ${requiredDisplay} ${info.symbol})`, + ), + ) + insufficientBalance = true + } + } catch (e) { + if (verbose) console.log(pc.dim(` Balance check skipped: ${(e as Error).message}`)) } } - } catch (e) { - if (verbose) console.log(pc.dim(` Balance check skipped: ${(e as Error).message}`)) - } - } + if (insufficientBalance) continue - // Prompt before paying on mainnet (unless --yes or non-interactive) - const amountDisplay = requiredAmount - ? `$${(Number(requiredAmount) / 10 ** decimals).toFixed(2)}` - : 'unknown amount' - if (walletAddress) console.log(pc.dim(` Using wallet: ${walletAddress}`)) - const isInteractive = process.stdin.isTTY ?? false - if (!options?.yes && !isInteractive) { - results.push( - skip('Payment: skipped', 'Non-interactive mode. Use --yes to approve mainnet payments.'), - ) - return results - } - if (!options?.yes) { - console.log('') - const ok = await confirm( - ` ${pc.yellow('Mainnet payment:')} Will transfer ${amountDisplay} to ${String(request.recipient).slice(0, 10)}... Continue?`, - false, - ) - if (!ok) { - results.push(skip('Payment: skipped by user', 'mainnet payment declined')) - return results - } - } else { - console.log( - pc.dim(` Auto-approved: ${amountDisplay} to ${String(request.recipient).slice(0, 10)}...`), - ) - } + // Prompt + const amountDisplay = requiredAmount + ? `$${(Number(requiredAmount) / 10 ** decimals).toFixed(2)}` + : 'unknown amount' + const tokenDisplay = tokenSymbol ?? (currency?.length === 3 ? currency.toUpperCase() : '') + const chainName = tempoMainnetChain.name - // Setup plugin or use direct method - let methods: import('../../Method.js').AnyClient[] - let createCredentialFn: ((response: Response) => Promise) | undefined + if (walletAddress && verbose) console.log(pc.dim(` Using wallet: ${walletAddress}`)) - if (plugin) { - try { - const pluginResult = await plugin.setup({ - challenge, - options: { network: 'mainnet' }, - methodOpts: {}, - }) - methods = pluginResult.methods - createCredentialFn = pluginResult.createCredential - } catch (error) { - const msg = (error as Error).message - if (msg.includes('No account found') || msg.includes('not found')) { - results.push(skip('Payment: no wallet configured', msg)) + if (!options?.yes && !isInteractive) { + results.push(skip(tag, 'non-interactive mode, use --yes to approve')) + continue + } + if (!options?.yes) { + console.log('') + const ok = await confirm( + ` ${pc.yellow('Pay')} ${amountDisplay}${tokenDisplay ? ` ${tokenDisplay}` : ''} on ${chainName}. Continue?`, + false, + ) + if (!ok) { + results.push(skip(tag, 'declined')) + continue + } } else { - results.push(fail('Payment: wallet setup', msg)) + console.log( + pc.dim( + ` Auto-approved: ${amountDisplay}${tokenDisplay ? ` ${tokenDisplay}` : ''} on ${chainName}`, + ), + ) } - return results - } - } else { - methods = [directMethod!] - } - // Create credential - let credential: string - const fakeResponse = new Response(null, { - status: 402, - headers: { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) }, - }) - try { - if (createCredentialFn) { - credential = await createCredentialFn(fakeResponse) - } else { - const mppx = Mppx.create({ methods, polyfill: false }) - credential = await mppx.createCredential(fakeResponse) - } - } catch (error) { - const msg = (error as Error).message - const isInsufficientBalance = - msg.toLowerCase().includes('insufficientbalance') || - msg.toLowerCase().includes('insufficient') - if (isInsufficientBalance) { - const match = msg.match(/available:\s*(\d+),\s*required:\s*(\d+)/) - const available = match ? BigInt(match[1]!) : undefined - const required = match ? BigInt(match[2]!) : requiredAmount - const fromMatch = msg.match(/from:\s*(0x[0-9a-fA-F]{40})/) - const fromAddr = fromMatch?.[1] - const requiredDisplay = required - ? `$${(Number(required) / 10 ** decimals).toFixed(2)}` - : 'unknown' - const availableDisplay = - available !== undefined ? `$${(Number(available) / 10 ** decimals).toFixed(2)}` : undefined - const detail = availableDisplay - ? `Have ${availableDisplay}, need ${requiredDisplay}` - : `Endpoint requires ${requiredDisplay}` - const hint = fromAddr - ? `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.` - : `Fund your mppx wallet with at least ${requiredDisplay} to run payment tests, or use a testnet server.` - results.push(skip('Payment: insufficient balance', detail, hint)) - return results + // Resolve payment methods + let methods: import('../../Method.js').AnyClient[] + let createCredentialFn: ((response: Response) => Promise) | undefined + let plugin: import('../plugins/plugin.js').Plugin | undefined + + const resolved = resolvePlugin(challenge, loaded?.config) + plugin = resolved.plugin + const directMethod = resolved.method + if (!plugin && !directMethod) { + results.push(skip(tag, methodSetupHint(challenge))) + continue + } + if (plugin) { + try { + const pluginResult = await plugin.setup({ + challenge, + options: { network: 'mainnet' }, + methodOpts: {}, + }) + methods = pluginResult.methods + createCredentialFn = pluginResult.createCredential + } catch (error) { + results.push(skip(tag, (error as Error).message)) + continue + } + } else { + methods = [directMethod!] + } + + // Create credential and send + let credential: string + const fakeResponse = new Response(null, { + status: 402, + headers: { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) }, + }) + try { + if (createCredentialFn) { + credential = await createCredentialFn(fakeResponse) + } else { + const mppx = Mppx.create({ methods, polyfill: false }) + credential = await mppx.createCredential(fakeResponse) + } + } catch (error) { + const msg = (error as Error).message + if (msg.toLowerCase().includes('insufficient')) { + results.push(skip(tag, `insufficient balance: ${msg}`)) + continue + } + results.push(fail(tag, msg)) + continue + } + + results.push(check(`${tag}: submitted`)) + + plugin?.prepareCredentialRequest?.({ challenge, credential, headers: fetchHeaders }) + await sendAndValidateResponse( + results, + url, + endpoint, + credential, + fetchHeaders, + fetchBody, + verbose, + tempoMainnetChain, + ) + } finally { + flush() } - results.push(fail('Payment: create credential', msg)) - return results } - results.push(check('Payment: credential created')) - - // Prepare and send - plugin?.prepareCredentialRequest?.({ challenge, credential, headers: fetchHeaders }) - return await sendAndValidateResponse( - results, - url, - endpoint, - credential, - fetchHeaders, - fetchBody, - verbose, - isTestnet, - ) + return results } async function sendAndValidateResponse( @@ -318,7 +344,7 @@ async function sendAndValidateResponse( baseHeaders: Record, fetchBody: string | undefined, verbose: boolean, - isTestnet?: boolean, + explorerChain?: import('viem').Chain | undefined, ): Promise { let paymentResponse: Response try { @@ -476,10 +502,10 @@ async function sendAndValidateResponse( try { const receipt = Receipt.deserialize(receiptHeader) if (receipt.reference && /^0x[0-9a-fA-F]{64}$/.test(receipt.reference)) { - const chain = isTestnet ? tempoModerato : tempoMainnetChain - const explorerUrl = chain.blockExplorers?.default?.url + const explorerUrl = explorerChain?.blockExplorers?.default?.url if (explorerUrl) { - results.push(check('On-chain transaction', `${explorerUrl}/receipt/${receipt.reference}`)) + const path = explorerUrl.includes('tempo') ? 'receipt' : 'tx' + results.push(check('On-chain transaction', `${explorerUrl}/${path}/${receipt.reference}`)) } } } catch {} diff --git a/src/validation/core.ts b/src/validation/core.ts index e3fc4dc5..f6591f27 100644 --- a/src/validation/core.ts +++ b/src/validation/core.ts @@ -6,7 +6,11 @@ import { fetchDiscoveryDoc, } from '../cli/validate/discovery.js' import type { CheckResult, EndpointSpec, PathParameter } from '../cli/validate/helpers.js' -import { parseEndpointArg, resolveBodyForEndpoint } from '../cli/validate/helpers.js' +import { + isValidIntegerAmount, + parseEndpointArg, + resolveBodyForEndpoint, +} from '../cli/validate/helpers.js' import { validatePaymentFlow } from '../cli/validate/payment.js' import { validate as validateDiscoveryDoc } from '../discovery/Validate.js' @@ -48,6 +52,7 @@ export type ValidateResult = { flags: { sawMppEndpoint: boolean sawNonMppPaymentEndpoint: boolean + sawMalformedChallenge: boolean sawTestnet: boolean sawMainnet: boolean paymentSucceeded: boolean @@ -64,6 +69,7 @@ export type ValidateEvent = isMpp: boolean isTestnet: boolean isNonMppPayment: boolean + isMalformedChallenge: boolean } | { phase: 'errorHandling'; endpoint: EndpointSpec; results: CheckResult[] } | { phase: 'payment'; endpoint: EndpointSpec; results: CheckResult[]; succeeded: boolean } @@ -105,10 +111,19 @@ export async function* validateStream(options: ValidateOptions): AsyncGenerator< ) const isTestnet = challengeResults.some( (r) => - r.severity === 'pass' && r.label === 'Valid currency address' && r.detail === 'testnet', + r.severity === 'pass' && + r.label.endsWith('Valid currency address') && + r.detail === 'testnet', ) const isNonMppPayment = !isMpp && challengeResults.some((r) => r.label === 'Not an MPP endpoint') + const hasPaymentScheme = challengeResults.some( + (r) => r.severity === 'pass' && r.label === 'WWW-Authenticate header present', + ) + const challengeFailed = challengeResults.some( + (r) => r.severity === 'fail' && r.label === 'Challenge parseable', + ) + const isMalformedChallenge = !isMpp && hasPaymentScheme && challengeFailed yield { phase: 'challenge', @@ -117,6 +132,7 @@ export async function* validateStream(options: ValidateOptions): AsyncGenerator< isMpp, isTestnet, isNonMppPayment, + isMalformedChallenge, } if (isMpp) { @@ -153,6 +169,7 @@ export async function validate(options: ValidateOptions): Promise> | undefined const attemptedErrors: string[] = [] - for (const path of candidates) { - const candidateUrl = new URL(path, baseUrl).href.replace(/\/$/, '') - const baseForCandidate = candidateUrl.replace(/\/openapi\.json$/i, '') - discoveryResult = await fetchDiscoveryDoc(baseForCandidate) + for (const candidate of candidates) { + discoveryResult = await fetchDiscoveryDoc(candidate) if ('error' in discoveryResult) { - attemptedErrors.push(`${path}: ${discoveryResult.error}`) + attemptedErrors.push(`${candidate}/openapi.json: ${discoveryResult.error}`) } else { break } @@ -261,6 +292,9 @@ async function runDiscovery(baseUrl: string, options: ValidateOptions): Promise< detail: `${errors.length} error(s)`, severity: 'fail', }) + for (const e of errors) { + checks.push({ label: e.message, detail: e.path, severity: 'fail' }) + } } else { valid = true checks.push({ label: 'Valid OpenAPI structure', severity: 'pass' }) @@ -281,8 +315,8 @@ async function runDiscovery(baseUrl: string, options: ValidateOptions): Promise< const NO_AMOUNT = BigInt('999999999999999999') endpoints.sort((a, b) => { - const aAmt = a.amount ? BigInt(a.amount) : NO_AMOUNT - const bAmt = b.amount ? BigInt(b.amount) : NO_AMOUNT + const aAmt = isValidIntegerAmount(a.amount) ? BigInt(a.amount!) : NO_AMOUNT + const bAmt = isValidIntegerAmount(b.amount) ? BigInt(b.amount!) : NO_AMOUNT return aAmt < bAmt ? -1 : aAmt > bAmt ? 1 : 0 })