mppx 0.8.3 → 0.8.4

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 (53) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +2 -0
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/cli/plugins/stripe.d.ts.map +1 -1
  6. package/dist/cli/plugins/stripe.js +32 -12
  7. package/dist/cli/plugins/stripe.js.map +1 -1
  8. package/dist/cli/utils.d.ts.map +1 -1
  9. package/dist/cli/utils.js +1 -1
  10. package/dist/cli/utils.js.map +1 -1
  11. package/dist/cli/validate/challenge.d.ts +14 -0
  12. package/dist/cli/validate/challenge.d.ts.map +1 -0
  13. package/dist/cli/validate/challenge.js +211 -0
  14. package/dist/cli/validate/challenge.js.map +1 -0
  15. package/dist/cli/validate/discovery.d.ts +10 -0
  16. package/dist/cli/validate/discovery.d.ts.map +1 -0
  17. package/dist/cli/validate/discovery.js +128 -0
  18. package/dist/cli/validate/discovery.js.map +1 -0
  19. package/dist/cli/validate/helpers.d.ts +33 -0
  20. package/dist/cli/validate/helpers.d.ts.map +1 -0
  21. package/dist/cli/validate/helpers.js +136 -0
  22. package/dist/cli/validate/helpers.js.map +1 -0
  23. package/dist/cli/validate/index.d.ts +17 -0
  24. package/dist/cli/validate/index.d.ts.map +1 -0
  25. package/dist/cli/validate/index.js +224 -0
  26. package/dist/cli/validate/index.js.map +1 -0
  27. package/dist/cli/validate/payment.d.ts +7 -0
  28. package/dist/cli/validate/payment.d.ts.map +1 -0
  29. package/dist/cli/validate/payment.js +381 -0
  30. package/dist/cli/validate/payment.js.map +1 -0
  31. package/dist/tempo/internal/auto-swap.d.ts.map +1 -1
  32. package/dist/tempo/internal/auto-swap.js +6 -1
  33. package/dist/tempo/internal/auto-swap.js.map +1 -1
  34. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  35. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  36. package/dist/tempo/server/internal/html.gen.js +1 -1
  37. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/cli/cli.test.ts +134 -29
  40. package/src/cli/cli.ts +2 -0
  41. package/src/cli/mcp.test.ts +1 -0
  42. package/src/cli/plugins/stripe.ts +31 -12
  43. package/src/cli/utils.test.ts +36 -2
  44. package/src/cli/utils.ts +2 -1
  45. package/src/cli/validate/challenge.ts +335 -0
  46. package/src/cli/validate/discovery.ts +120 -0
  47. package/src/cli/validate/helpers.ts +154 -0
  48. package/src/cli/validate/index.ts +348 -0
  49. package/src/cli/validate/payment.ts +488 -0
  50. package/src/cli/validate.test.ts +539 -0
  51. package/src/tempo/internal/auto-swap.test.ts +26 -9
  52. package/src/tempo/internal/auto-swap.ts +15 -2
  53. package/src/tempo/server/internal/html.gen.ts +1 -1
@@ -0,0 +1,539 @@
1
+ import type * as http from 'node:http'
2
+
3
+ import { afterEach, describe, expect, test } from 'vp/test'
4
+ import * as Http from '~test/Http.js'
5
+
6
+ import * as Challenge from '../Challenge.js'
7
+ import * as Constants from '../Constants.js'
8
+ import * as Receipt from '../Receipt.js'
9
+ import validate from './validate/index.js'
10
+
11
+ // Auto-cleanup for test servers
12
+ const servers: Http.TestServer[] = []
13
+ afterEach(() => {
14
+ servers.forEach((s) => s.close())
15
+ servers.length = 0
16
+ })
17
+
18
+ async function testServer(handler: http.RequestListener) {
19
+ const s = await Http.createServer(handler)
20
+ servers.push(s)
21
+ return s
22
+ }
23
+
24
+ async function serve(argv: string[]) {
25
+ let output = ''
26
+ let exitCode: number | undefined
27
+ const origLog = console.log
28
+ const origStdout = process.stdout.write
29
+ const origStderr = process.stderr.write
30
+ console.log = (...args: unknown[]) => {
31
+ output += `${args.map(String).join(' ')}\n`
32
+ }
33
+ process.stdout.write = ((chunk: unknown) => {
34
+ output += typeof chunk === 'string' ? chunk : String(chunk)
35
+ return true
36
+ }) as typeof process.stdout.write
37
+ process.stderr.write = ((chunk: unknown) => {
38
+ output += typeof chunk === 'string' ? chunk : String(chunk)
39
+ return true
40
+ }) as typeof process.stderr.write
41
+ try {
42
+ const { Cli } = await import('incur')
43
+ const cli = Cli.create('mppx', {})
44
+ cli.command(validate)
45
+ await cli.serve(argv, {
46
+ stdout(s: string) {
47
+ output += s
48
+ },
49
+ exit(code: number) {
50
+ exitCode = code
51
+ },
52
+ })
53
+ } finally {
54
+ console.log = origLog
55
+ process.stdout.write = origStdout
56
+ process.stderr.write = origStderr
57
+ }
58
+ return { output, exitCode }
59
+ }
60
+
61
+ function makeChallenge(overrides?: Partial<Challenge.Challenge>) {
62
+ return {
63
+ id: 'test-id-123',
64
+ realm: 'localhost',
65
+ method: 'tempo',
66
+ intent: 'charge',
67
+ request: {
68
+ amount: '10000',
69
+ currency: '0x20c0000000000000000000000000000000000000',
70
+ recipient: '0x1234567890123456789012345678901234567890',
71
+ methodDetails: { chainId: 42431 },
72
+ },
73
+ expires: new Date(Date.now() + 300_000).toISOString(),
74
+ ...overrides,
75
+ } as Challenge.Challenge
76
+ }
77
+
78
+ function makeDiscoveryDoc(
79
+ endpoints: Record<string, { method?: string; amount?: string; requestBody?: unknown }> = {},
80
+ ) {
81
+ const paths: Record<string, unknown> = {}
82
+ for (const [path, opts] of Object.entries(endpoints)) {
83
+ const op: Record<string, unknown> = {
84
+ 'x-payment-info': { method: 'tempo', intent: 'charge', amount: opts.amount ?? '10000' },
85
+ responses: { '402': { description: 'Payment required' } },
86
+ }
87
+ if (opts.requestBody) op.requestBody = opts.requestBody
88
+ paths[path] = { [opts.method?.toLowerCase() ?? 'post']: op }
89
+ }
90
+ return JSON.stringify({ openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths })
91
+ }
92
+
93
+ async function mppServer(
94
+ challenge: Challenge.Challenge,
95
+ opts?: { errorStatus?: number; postPaymentStatus?: number },
96
+ ) {
97
+ return testServer((req, res) => {
98
+ const url = new URL(req.url!, 'http://localhost')
99
+ if (url.pathname === '/openapi.json') {
100
+ res.setHeader('Content-Type', 'application/json')
101
+ res.end(makeDiscoveryDoc({ '/api/test': {} }))
102
+ return
103
+ }
104
+ const hasAuth = req.headers[Constants.Headers.authorization.toLowerCase()]
105
+ if (hasAuth && hasAuth !== `${Constants.Schemes.payment} dGhpcyBpcyBnYXJiYWdl`) {
106
+ const status = opts?.postPaymentStatus ?? 200
107
+ const receipt = Receipt.serialize({
108
+ method: 'tempo',
109
+ status: 'success',
110
+ reference: '0x' + 'ab'.repeat(32),
111
+ timestamp: new Date().toISOString(),
112
+ })
113
+ res.writeHead(status, {
114
+ [Constants.Headers.paymentReceipt]: receipt,
115
+ 'content-type': 'application/json',
116
+ })
117
+ res.end(JSON.stringify({ result: 'ok' }))
118
+ } else if (hasAuth) {
119
+ const errorStatus = opts?.errorStatus ?? 402
120
+ if (errorStatus === 402) {
121
+ res.writeHead(402, { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) })
122
+ } else {
123
+ res.writeHead(errorStatus)
124
+ }
125
+ res.end()
126
+ } else {
127
+ res.writeHead(402, { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) })
128
+ res.end()
129
+ }
130
+ })
131
+ }
132
+
133
+ describe('validate: discovery', () => {
134
+ test('succeeds with valid discovery doc', { timeout: 15_000 }, async () => {
135
+ const server = await mppServer(makeChallenge())
136
+ const { output } = await serve(['validate', server.url])
137
+ expect(output).toContain('Document found and parseable')
138
+ expect(output).toContain('Valid OpenAPI structure')
139
+ expect(output).toContain('Paid endpoints found')
140
+ })
141
+
142
+ test('reports missing discovery doc', { timeout: 15_000 }, async () => {
143
+ const server = await testServer((_req, res) => {
144
+ res.writeHead(404)
145
+ res.end()
146
+ })
147
+ const { output, exitCode } = await serve(['validate', server.url])
148
+ expect(exitCode).toBe(1)
149
+ expect(output).toContain('No discovery document found.')
150
+ expect(output).toContain('MPP servers must serve an OpenAPI document at /openapi.json')
151
+ expect(output).toContain(
152
+ 'To test a specific endpoint: mppx validate <url> --endpoint POST:/your/path',
153
+ )
154
+ })
155
+
156
+ test('strips /openapi.json from input URL', { timeout: 15_000 }, async () => {
157
+ const server = await mppServer(makeChallenge())
158
+ const { output } = await serve(['validate', `${server.url}/openapi.json`])
159
+ expect(output).toContain('Document found and parseable')
160
+ })
161
+
162
+ test('reports invalid JSON', { timeout: 15_000 }, async () => {
163
+ const server = await testServer((req, res) => {
164
+ if (req.url?.includes('openapi.json')) {
165
+ res.setHeader('Content-Type', 'application/json')
166
+ res.end('not json{{{')
167
+ } else {
168
+ res.writeHead(404)
169
+ res.end()
170
+ }
171
+ })
172
+ const { output, exitCode } = await serve(['validate', server.url])
173
+ expect(exitCode).toBe(1)
174
+ expect(output).toContain('Invalid JSON')
175
+ })
176
+
177
+ test('handles no paid endpoints in discovery', { timeout: 15_000 }, async () => {
178
+ const server = await testServer((req, res) => {
179
+ if (req.url?.includes('openapi.json')) {
180
+ res.setHeader('Content-Type', 'application/json')
181
+ res.end(
182
+ JSON.stringify({
183
+ openapi: '3.1.0',
184
+ info: { title: 'T', version: '1' },
185
+ paths: { '/health': { get: { responses: { '200': {} } } } },
186
+ }),
187
+ )
188
+ } else {
189
+ res.writeHead(200)
190
+ res.end()
191
+ }
192
+ })
193
+ const { output, exitCode } = await serve(['validate', server.url])
194
+ expect(exitCode).toBe(1)
195
+ expect(output).toContain('Paid endpoints found (No endpoints with x-payment-info)')
196
+ expect(output).toContain('Use --endpoint to specify endpoints manually.')
197
+ })
198
+
199
+ test('falls back to 402 response heuristic', { timeout: 15_000 }, async () => {
200
+ const challenge = makeChallenge()
201
+ const server = await testServer((req, res) => {
202
+ const url = new URL(req.url!, 'http://localhost')
203
+ if (url.pathname === '/openapi.json') {
204
+ res.setHeader('Content-Type', 'application/json')
205
+ res.end(
206
+ JSON.stringify({
207
+ openapi: '3.1.0',
208
+ info: { title: 'T', version: '1' },
209
+ paths: {
210
+ '/api/data': { get: { responses: { '402': { description: 'Payment required' } } } },
211
+ },
212
+ }),
213
+ )
214
+ } else {
215
+ res.writeHead(402, { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) })
216
+ res.end()
217
+ }
218
+ })
219
+ const { output } = await serve(['validate', server.url])
220
+ expect(output).toContain('Paid endpoints found')
221
+ expect(output).toContain('Challenge parseable')
222
+ })
223
+ })
224
+
225
+ describe('validate: challenge', () => {
226
+ test('validates correct MPP challenge', { timeout: 15_000 }, async () => {
227
+ const server = await mppServer(makeChallenge())
228
+ const { output } = await serve(['validate', server.url])
229
+ expect(output).toContain('Challenge parseable (tempo/charge)')
230
+ expect(output).toContain('Challenge has id')
231
+ expect(output).toContain('Challenge has realm')
232
+ expect(output).toContain('Valid recipient address')
233
+ expect(output).toContain('Valid currency address (testnet)')
234
+ expect(output).toContain('Amount is valid integer string')
235
+ })
236
+
237
+ test('fails on expired challenge', { timeout: 15_000 }, async () => {
238
+ const server = await mppServer(makeChallenge({ expires: '2020-01-01T00:00:00Z' }))
239
+ const { output } = await serve(['validate', server.url])
240
+ expect(output).toContain('Challenge expires in the future (Expired at 2020-01-01T00:00:00Z)')
241
+ expect(output).toContain('The expires timestamp must be in the future')
242
+ })
243
+
244
+ test('warns on realm mismatch', { timeout: 15_000 }, async () => {
245
+ const server = await mppServer(makeChallenge({ realm: 'other.example.com' }))
246
+ const { output } = await serve(['validate', server.url])
247
+ expect(output).toContain(
248
+ 'Realm matches server hostname (realm="other.example.com" vs host="localhost")',
249
+ )
250
+ expect(output).toContain('Set the realm to your production hostname')
251
+ })
252
+
253
+ test('fails on invalid recipient address', { timeout: 15_000 }, async () => {
254
+ const challenge = makeChallenge()
255
+ ;(challenge.request as Record<string, unknown>).recipient = 'not-an-address'
256
+ const server = await mppServer(challenge)
257
+ const { output } = await serve(['validate', server.url])
258
+ expect(output).toContain('Valid recipient address (Got: not-an-address)')
259
+ expect(output).toContain('Set request.recipient to a valid 0x-prefixed 40-hex-char address')
260
+ })
261
+
262
+ test('fails on invalid amount', { timeout: 15_000 }, async () => {
263
+ const challenge = makeChallenge()
264
+ ;(challenge.request as Record<string, unknown>).amount = '12.5'
265
+ const server = await mppServer(challenge)
266
+ const { output } = await serve(['validate', server.url])
267
+ expect(output).toContain('Amount is valid integer string (Got: 12.5)')
268
+ expect(output).toContain('request.amount must be a string of digits')
269
+ })
270
+
271
+ test('detects non-MPP endpoint (x402)', { timeout: 15_000 }, async () => {
272
+ const server = await testServer((req, res) => {
273
+ const url = new URL(req.url!, 'http://localhost')
274
+ if (url.pathname === '/openapi.json') {
275
+ res.setHeader('Content-Type', 'application/json')
276
+ res.end(makeDiscoveryDoc({ '/api/test': {} }))
277
+ } else {
278
+ res.writeHead(402, { 'payment-required': 'eyJ0ZXN0IjoxfQ==' })
279
+ res.end()
280
+ }
281
+ })
282
+ const { output, exitCode } = await serve(['validate', server.url])
283
+ expect(output).toContain(
284
+ 'Not an MPP endpoint (No WWW-Authenticate header (may be x402 or other protocol))',
285
+ )
286
+ expect(exitCode).toBe(1)
287
+ })
288
+
289
+ test('skips 200 response', { timeout: 15_000 }, async () => {
290
+ const server = await testServer((req, res) => {
291
+ const url = new URL(req.url!, 'http://localhost')
292
+ if (url.pathname === '/openapi.json') {
293
+ res.setHeader('Content-Type', 'application/json')
294
+ res.end(makeDiscoveryDoc({ '/api/test': { method: 'GET' } }))
295
+ } else {
296
+ res.writeHead(200)
297
+ res.end('ok')
298
+ }
299
+ })
300
+ const { output } = await serve(['validate', server.url])
301
+ expect(output).toContain(
302
+ 'Returns 402 without credentials (Got 200 (endpoint may not require payment in all cases))',
303
+ )
304
+ })
305
+
306
+ test('skips 401 as auth-gated', { timeout: 15_000 }, async () => {
307
+ const server = await testServer((req, res) => {
308
+ const url = new URL(req.url!, 'http://localhost')
309
+ if (url.pathname === '/openapi.json') {
310
+ res.setHeader('Content-Type', 'application/json')
311
+ res.end(makeDiscoveryDoc({ '/api/test': {} }))
312
+ } else {
313
+ res.writeHead(401)
314
+ res.end()
315
+ }
316
+ })
317
+ const { output } = await serve(['validate', server.url])
318
+ expect(output).toContain(
319
+ 'Returns 402 without credentials (Got 401 (endpoint requires auth before payment gate))',
320
+ )
321
+ })
322
+
323
+ test('retries with body on 400', { timeout: 15_000 }, async () => {
324
+ const challenge = makeChallenge()
325
+ const doc = JSON.stringify({
326
+ openapi: '3.1.0',
327
+ info: { title: 'T', version: '1' },
328
+ paths: {
329
+ '/api/test': {
330
+ post: {
331
+ 'x-payment-info': { method: 'tempo', intent: 'charge', amount: '10000' },
332
+ responses: { '402': {} },
333
+ requestBody: {
334
+ required: true,
335
+ content: {
336
+ 'application/json': {
337
+ schema: {
338
+ type: 'object',
339
+ required: ['q'],
340
+ properties: { q: { type: 'string', example: 'hello' } },
341
+ },
342
+ },
343
+ },
344
+ },
345
+ },
346
+ },
347
+ },
348
+ })
349
+ let requestCount = 0
350
+ const server = await testServer((req, res) => {
351
+ const url = new URL(req.url!, 'http://localhost')
352
+ if (url.pathname === '/openapi.json') {
353
+ res.setHeader('Content-Type', 'application/json')
354
+ res.end(doc)
355
+ } else {
356
+ requestCount++
357
+ const hasAuth = req.headers[Constants.Headers.authorization.toLowerCase()]
358
+ if (hasAuth) {
359
+ res.writeHead(402, {
360
+ [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge),
361
+ })
362
+ res.end()
363
+ } else if (requestCount === 1) {
364
+ res.writeHead(400)
365
+ res.end()
366
+ } else {
367
+ res.writeHead(402, {
368
+ [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge),
369
+ })
370
+ res.end()
371
+ }
372
+ }
373
+ })
374
+ const { output } = await serve(['validate', server.url])
375
+ expect(output).toContain('Challenge parseable')
376
+ expect(requestCount).toBeGreaterThan(1)
377
+ })
378
+ })
379
+
380
+ describe('validate: error handling', () => {
381
+ test('passes when malformed credential returns 402', { timeout: 15_000 }, async () => {
382
+ const server = await mppServer(makeChallenge())
383
+ const { output } = await serve(['validate', server.url])
384
+ expect(output).toContain('Malformed credential returns 402 (not 500)')
385
+ })
386
+
387
+ test('fails when malformed credential returns 500', { timeout: 15_000 }, async () => {
388
+ const server = await mppServer(makeChallenge(), { errorStatus: 500 })
389
+ const { output } = await serve(['validate', server.url])
390
+ expect(output).toContain('Malformed credential returns 402 (Got 500 (server error))')
391
+ expect(output).toContain(
392
+ 'When the Authorization header contains an invalid credential, respond with 402 (not 500)',
393
+ )
394
+ })
395
+
396
+ test('warns when malformed credential returns other status', { timeout: 15_000 }, async () => {
397
+ const server = await mppServer(makeChallenge(), { errorStatus: 422 })
398
+ const { output } = await serve(['validate', server.url])
399
+ expect(output).toContain('Malformed credential returns 402 (Got 422)')
400
+ expect(output).toContain('Returning 422 prevents the client from retrying with a valid payment')
401
+ })
402
+ })
403
+
404
+ describe('validate: --endpoint flag', () => {
405
+ test('checks discovery even with --endpoint', { timeout: 15_000 }, async () => {
406
+ const challenge = makeChallenge()
407
+ const server = await testServer((_req, res) => {
408
+ res.writeHead(402, { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) })
409
+ res.end()
410
+ })
411
+ const { output } = await serve(['validate', server.url, '--endpoint', 'POST:/api/test'])
412
+ expect(output).toContain('Discovery (/openapi.json)')
413
+ // Discovery fails (server only returns 402) but doesn't block --endpoint testing
414
+ expect(output).toContain('Document found')
415
+ expect(output).toContain('Challenge parseable')
416
+ })
417
+
418
+ test('uses --body directly with --endpoint', { timeout: 15_000 }, async () => {
419
+ const challenge = makeChallenge()
420
+ const bodies: string[] = []
421
+ const server = await testServer((req, res) => {
422
+ let body = ''
423
+ req.on('data', (chunk) => {
424
+ body += chunk
425
+ })
426
+ req.on('end', () => {
427
+ bodies.push(body)
428
+ res.writeHead(402, { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) })
429
+ res.end()
430
+ })
431
+ })
432
+ await serve(['validate', server.url, '--endpoint', 'POST:/api/test', '--body', '{"test":true}'])
433
+ // Challenge phase: bare request first (no body)
434
+ expect(bodies[0]).toBe('')
435
+ // Payment phase: sends body with the credential request
436
+ const paymentRequest = bodies.find((b) => b === '{"test":true}')
437
+ expect(paymentRequest).toBe('{"test":true}')
438
+ })
439
+ })
440
+
441
+ describe('validate: no MPP endpoints', () => {
442
+ test('exits non-zero when all endpoints are x402', { timeout: 15_000 }, async () => {
443
+ const server = await testServer((req, res) => {
444
+ const url = new URL(req.url!, 'http://localhost')
445
+ if (url.pathname === '/openapi.json') {
446
+ res.setHeader('Content-Type', 'application/json')
447
+ res.end(makeDiscoveryDoc({ '/api/a': {}, '/api/b': {} }))
448
+ } else {
449
+ res.writeHead(402, { 'payment-required': 'eyJ0ZXN0IjoxfQ==' })
450
+ res.end()
451
+ }
452
+ })
453
+ const { output, exitCode } = await serve(['validate', server.url])
454
+ expect(exitCode).toBe(1)
455
+ expect(output).toContain('No MPP endpoints found.')
456
+ expect(output).toContain('This server may use x402 or another payment protocol.')
457
+ })
458
+
459
+ test('shows auth-gated message when all return 401', { timeout: 15_000 }, async () => {
460
+ const server = await testServer((req, res) => {
461
+ const url = new URL(req.url!, 'http://localhost')
462
+ if (url.pathname === '/openapi.json') {
463
+ res.setHeader('Content-Type', 'application/json')
464
+ res.end(makeDiscoveryDoc({ '/api/a': {}, '/api/b': {} }))
465
+ } else {
466
+ res.writeHead(401)
467
+ res.end()
468
+ }
469
+ })
470
+ const { output, exitCode } = await serve(['validate', server.url])
471
+ expect(exitCode).toBe(1)
472
+ expect(output).toContain(
473
+ 'Could not reach payment gate on any endpoint (all returned 401/403/200).',
474
+ )
475
+ expect(output).toContain('The server may require authentication before payment.')
476
+ })
477
+
478
+ test(
479
+ 'mixed: some MPP, some not -- does not show "no MPP" message',
480
+ { timeout: 15_000 },
481
+ async () => {
482
+ const challenge = makeChallenge()
483
+ const server = await testServer((req, res) => {
484
+ const url = new URL(req.url!, 'http://localhost')
485
+ if (url.pathname === '/openapi.json') {
486
+ res.setHeader('Content-Type', 'application/json')
487
+ res.end(makeDiscoveryDoc({ '/api/paid': {}, '/api/free': {} }))
488
+ } else if (url.pathname === '/api/paid') {
489
+ res.writeHead(402, {
490
+ [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge),
491
+ })
492
+ res.end()
493
+ } else {
494
+ res.writeHead(402, { 'payment-required': 'eyJ0ZXN0IjoxfQ==' })
495
+ res.end()
496
+ }
497
+ })
498
+ const { output } = await serve(['validate', server.url])
499
+ expect(output).toContain('Challenge parseable')
500
+ expect(output).toContain('Not an MPP endpoint')
501
+ expect(output).not.toContain('No MPP endpoints found')
502
+ },
503
+ )
504
+ })
505
+
506
+ describe('validate: summary', () => {
507
+ test('shows pass count in summary', { timeout: 15_000 }, async () => {
508
+ const server = await mppServer(makeChallenge())
509
+ const { output } = await serve(['validate', server.url])
510
+ expect(output).toMatch(/\d+ passed/)
511
+ expect(output).toContain('Summary:')
512
+ })
513
+
514
+ test('shows skipped count when endpoints are skipped', { timeout: 15_000 }, async () => {
515
+ const challenge = makeChallenge()
516
+ const server = await testServer((req, res) => {
517
+ const url = new URL(req.url!, 'http://localhost')
518
+ if (url.pathname === '/openapi.json') {
519
+ res.setHeader('Content-Type', 'application/json')
520
+ res.end(makeDiscoveryDoc({ '/api/mpp': {}, '/api/free': { method: 'GET' } }))
521
+ } else if (url.pathname === '/api/mpp') {
522
+ res.writeHead(402, { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) })
523
+ res.end()
524
+ } else {
525
+ res.writeHead(200)
526
+ res.end('ok')
527
+ }
528
+ })
529
+ const { output } = await serve(['validate', server.url])
530
+ expect(output).toContain('skipped')
531
+ })
532
+
533
+ test('exits non-zero when there are failures', { timeout: 15_000 }, async () => {
534
+ const server = await mppServer(makeChallenge(), { errorStatus: 500 })
535
+ const { output, exitCode } = await serve(['validate', server.url])
536
+ expect(exitCode).toBe(1)
537
+ expect(output).toContain('failed')
538
+ })
539
+ })
@@ -109,18 +109,24 @@ describe('findCalls', () => {
109
109
  const tokenOut = '0x2222222222222222222222222222222222222222' as Address
110
110
  const tokenIn = '0x3333333333333333333333333333333333333333' as Address
111
111
  const client = { chain: { id: 42431 } }
112
- const tokenCalls: { client: unknown; name: string; parameters: Record<string, unknown> }[] = []
112
+ const builderCalls: { client: unknown; name: string; parameters: Record<string, unknown> }[] =
113
+ []
113
114
 
114
115
  function getBalanceCall(client: unknown, parameters: Record<string, unknown>) {
115
- tokenCalls.push({ client, name: 'getBalance', parameters })
116
+ builderCalls.push({ client, name: 'getBalance', parameters })
116
117
  return { kind: parameters.token === tokenOut ? 'targetBalance' : 'candidateBalance' }
117
118
  }
118
119
 
119
120
  function approveCall(client: unknown, parameters: Record<string, unknown>) {
120
- tokenCalls.push({ client, name: 'approve', parameters })
121
+ builderCalls.push({ client, name: 'approve', parameters })
121
122
  return { kind: 'approve' }
122
123
  }
123
124
 
125
+ function buyCall(client: unknown, parameters: Record<string, unknown>) {
126
+ builderCalls.push({ client, name: 'buy', parameters })
127
+ return { kind: 'buy' }
128
+ }
129
+
124
130
  vi.doMock('viem/actions', () => ({
125
131
  readContract: vi.fn(async (_client, call: { kind: string }) =>
126
132
  call.kind === 'targetBalance' ? 0n : 2_000_000n,
@@ -129,7 +135,7 @@ describe('findCalls', () => {
129
135
  vi.doMock('viem/tempo', () => ({
130
136
  Actions: {
131
137
  dex: {
132
- buy: { call: vi.fn((parameters) => ({ kind: 'buy', parameters })) },
138
+ buy: { call: buyCall },
133
139
  getBuyQuote: vi.fn(async () => 1_000_000n),
134
140
  },
135
141
  token: {
@@ -152,15 +158,26 @@ describe('findCalls', () => {
152
158
  })
153
159
 
154
160
  expect(calls).toHaveLength(2)
155
- expect(tokenCalls.map((call) => call.client)).toEqual([client, client, client])
156
- expect(tokenCalls.map((call) => call.name)).toEqual(['getBalance', 'getBalance', 'approve'])
157
- expect(tokenCalls[0]!.parameters).toMatchObject({ account, token: tokenOut })
158
- expect(tokenCalls[1]!.parameters).toMatchObject({ account, token: tokenIn })
159
- expect(tokenCalls[2]!.parameters).toMatchObject({
161
+ expect(builderCalls.map((call) => call.client)).toEqual([client, client, client, client])
162
+ expect(builderCalls.map((call) => call.name)).toEqual([
163
+ 'getBalance',
164
+ 'getBalance',
165
+ 'approve',
166
+ 'buy',
167
+ ])
168
+ expect(builderCalls[0]!.parameters).toMatchObject({ account, token: tokenOut })
169
+ expect(builderCalls[1]!.parameters).toMatchObject({ account, token: tokenIn })
170
+ expect(builderCalls[2]!.parameters).toMatchObject({
160
171
  amount: 1_010_000n,
161
172
  spender: '0x4444444444444444444444444444444444444444',
162
173
  token: tokenIn,
163
174
  })
175
+ expect(builderCalls[3]!.parameters).toMatchObject({
176
+ amountOut: 1_000_000n,
177
+ maxAmountIn: 1_010_000n,
178
+ tokenIn,
179
+ tokenOut,
180
+ })
164
181
  } finally {
165
182
  vi.doUnmock('viem/actions')
166
183
  vi.doUnmock('viem/tempo')
@@ -1,7 +1,7 @@
1
1
  import type { Address, Client } from 'viem'
2
2
  import { readContract } from 'viem/actions'
3
3
  import { Actions, Addresses } from 'viem/tempo'
4
- import type { token as viem_token } from 'viem/tempo/actions'
4
+ import type { dex as viem_dex, token as viem_token } from 'viem/tempo/actions'
5
5
 
6
6
  import * as TempoAddress from './address.js'
7
7
  import * as defaults from './defaults.js'
@@ -47,6 +47,19 @@ function getApproveCall(
47
47
  return call.length >= 2 ? call(client, parameters) : call(parameters)
48
48
  }
49
49
 
50
+ // TODO: Remove once the minimum viem version is >=2.54.0, which uses client-first Tempo call builders.
51
+ function getBuyCall(
52
+ client: Client,
53
+ parameters: viem_dex.buy.Args,
54
+ ): ReturnType<typeof viem_dex.buy.call> {
55
+ const call = Actions.dex.buy.call as unknown as {
56
+ length: number
57
+ (parameters: viem_dex.buy.Args): ReturnType<typeof viem_dex.buy.call>
58
+ (client: Client, parameters: viem_dex.buy.Args): ReturnType<typeof viem_dex.buy.call>
59
+ }
60
+ return call.length >= 2 ? call(client, parameters) : call(parameters)
61
+ }
62
+
50
63
  /**
51
64
  * Finds the optimal swap calls to acquire `amountOut` of `tokenOut`,
52
65
  * returning an approve + buy call sequence if a viable route is found.
@@ -100,7 +113,7 @@ export async function findCalls(
100
113
  spender: Addresses.stablecoinDex,
101
114
  token: tokenIn,
102
115
  }),
103
- Actions.dex.buy.call({
116
+ getBuyCall(client, {
104
117
  tokenIn,
105
118
  tokenOut,
106
119
  amountOut,