mppx 0.5.1 → 0.5.3

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 (89) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/Credential.d.ts +12 -0
  3. package/dist/Credential.d.ts.map +1 -1
  4. package/dist/Credential.js +22 -4
  5. package/dist/Credential.js.map +1 -1
  6. package/dist/Method.d.ts +4 -0
  7. package/dist/Method.d.ts.map +1 -1
  8. package/dist/Method.js +2 -1
  9. package/dist/Method.js.map +1 -1
  10. package/dist/proxy/Proxy.d.ts.map +1 -1
  11. package/dist/proxy/Proxy.js +52 -8
  12. package/dist/proxy/Proxy.js.map +1 -1
  13. package/dist/proxy/internal/Route.d.ts.map +1 -1
  14. package/dist/proxy/internal/Route.js +7 -3
  15. package/dist/proxy/internal/Route.js.map +1 -1
  16. package/dist/server/Mppx.d.ts.map +1 -1
  17. package/dist/server/Mppx.js +90 -71
  18. package/dist/server/Mppx.js.map +1 -1
  19. package/dist/server/Transport.d.ts +5 -1
  20. package/dist/server/Transport.d.ts.map +1 -1
  21. package/dist/server/Transport.js +52 -7
  22. package/dist/server/Transport.js.map +1 -1
  23. package/dist/server/internal/html/config.d.ts +7 -0
  24. package/dist/server/internal/html/config.d.ts.map +1 -0
  25. package/dist/server/internal/html/config.js +3 -0
  26. package/dist/server/internal/html/config.js.map +1 -0
  27. package/dist/server/internal/html/serviceWorker.gen.d.ts +2 -0
  28. package/dist/server/internal/html/serviceWorker.gen.d.ts.map +1 -0
  29. package/dist/server/internal/html/serviceWorker.gen.js +3 -0
  30. package/dist/server/internal/html/serviceWorker.gen.js.map +1 -0
  31. package/dist/stripe/server/Charge.d.ts +5 -0
  32. package/dist/stripe/server/Charge.d.ts.map +1 -1
  33. package/dist/stripe/server/Charge.js +14 -6
  34. package/dist/stripe/server/Charge.js.map +1 -1
  35. package/dist/stripe/server/internal/html.gen.d.ts +2 -0
  36. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -0
  37. package/dist/stripe/server/internal/html.gen.js +3 -0
  38. package/dist/stripe/server/internal/html.gen.js.map +1 -0
  39. package/dist/tempo/server/Charge.d.ts +2 -0
  40. package/dist/tempo/server/Charge.d.ts.map +1 -1
  41. package/dist/tempo/server/Charge.js +15 -9
  42. package/dist/tempo/server/Charge.js.map +1 -1
  43. package/dist/tempo/server/Session.d.ts.map +1 -1
  44. package/dist/tempo/server/Session.js +3 -2
  45. package/dist/tempo/server/Session.js.map +1 -1
  46. package/dist/tempo/server/internal/html.gen.d.ts +2 -0
  47. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -0
  48. package/dist/tempo/server/internal/html.gen.js +3 -0
  49. package/dist/tempo/server/internal/html.gen.js.map +1 -0
  50. package/dist/tempo/server/internal/transport.d.ts +1 -1
  51. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  52. package/dist/tempo/server/internal/transport.js +45 -58
  53. package/dist/tempo/server/internal/transport.js.map +1 -1
  54. package/package.json +2 -2
  55. package/src/Credential.ts +28 -4
  56. package/src/Method.ts +6 -1
  57. package/src/env.d.ts +1 -0
  58. package/src/mcp-sdk/server/Transport.test.ts +6 -0
  59. package/src/proxy/Proxy.test.ts +188 -1
  60. package/src/proxy/Proxy.ts +58 -9
  61. package/src/proxy/internal/Route.test.ts +9 -0
  62. package/src/proxy/internal/Route.ts +5 -2
  63. package/src/server/Mppx.test.ts +171 -18
  64. package/src/server/Mppx.ts +120 -79
  65. package/src/server/Transport.test.ts +16 -2
  66. package/src/server/Transport.ts +61 -7
  67. package/src/server/internal/html/config.ts +8 -0
  68. package/src/server/internal/html/serviceWorker.client.ts +28 -0
  69. package/src/server/internal/html/serviceWorker.gen.ts +2 -0
  70. package/src/server/internal/html/serviceWorker.ts +27 -0
  71. package/src/server/internal/html/tsconfig.worker.client.json +8 -0
  72. package/src/server/internal/html/tsconfig.worker.json +8 -0
  73. package/src/stripe/server/Charge.ts +19 -5
  74. package/src/stripe/server/internal/html/main.ts +106 -0
  75. package/src/stripe/server/internal/html/node_modules/.bin/mppx.src +21 -0
  76. package/src/stripe/server/internal/html/package.json +9 -0
  77. package/src/stripe/server/internal/html/stripe-js-pure.d.ts +7 -0
  78. package/src/stripe/server/internal/html/tsconfig.json +8 -0
  79. package/src/stripe/server/internal/html.gen.ts +2 -0
  80. package/src/tempo/server/Charge.ts +20 -8
  81. package/src/tempo/server/Session.ts +3 -2
  82. package/src/tempo/server/internal/html/main.ts +71 -0
  83. package/src/tempo/server/internal/html/node_modules/.bin/mppx.src +21 -0
  84. package/src/tempo/server/internal/html/package.json +10 -0
  85. package/src/tempo/server/internal/html/tsconfig.json +8 -0
  86. package/src/tempo/server/internal/html.gen.ts +2 -0
  87. package/src/tempo/server/internal/transport.test.ts +37 -31
  88. package/src/tempo/server/internal/transport.ts +44 -58
  89. package/src/tsconfig.json +1 -1
@@ -42,13 +42,16 @@ export function matchPath(
42
42
  path: string,
43
43
  filter?: (value: unknown) => boolean,
44
44
  ): { key: string; value: unknown } | null {
45
+ let match: { key: string; value: unknown } | null = null
45
46
  for (const [key, value] of Object.entries(routes)) {
46
47
  if (filter && !filter(value)) continue
47
48
  const { pattern } = parseRouteKey(key)
48
49
  const urlPattern = new URLPattern({ pathname: pattern })
49
- if (urlPattern.test({ pathname: path })) return { key, value }
50
+ if (!urlPattern.test({ pathname: path })) continue
51
+ if (match) return null
52
+ match = { key, value }
50
53
  }
51
- return null
54
+ return match
52
55
  }
53
56
 
54
57
  function parseRouteKey(key: string): { method: string | undefined; pattern: string } {
@@ -95,6 +95,36 @@ describe('request handler', () => {
95
95
  `)
96
96
  })
97
97
 
98
+ test('returns sanitized malformed credential error for unexpected transport failures', async () => {
99
+ const baseTransport = Transport.http()
100
+ const transport = Transport.from({
101
+ ...baseTransport,
102
+ name: 'leaking-http',
103
+ getCredential() {
104
+ throw new Error('request to https://rpc.example.com/?key=secret-key failed')
105
+ },
106
+ })
107
+
108
+ const result = await Mppx.create({ methods: [method], realm, secretKey, transport }).charge({
109
+ amount: '1000',
110
+ currency: asset,
111
+ expires: new Date(Date.now() + 60_000).toISOString(),
112
+ recipient: accounts[0].address,
113
+ })(
114
+ new Request('https://example.com/resource', {
115
+ headers: { Authorization: 'Payment invalid' },
116
+ }),
117
+ )
118
+
119
+ expect(result.status).toBe(402)
120
+ if (result.status !== 402) throw new Error()
121
+
122
+ const body = (await result.challenge.json()) as { detail: string }
123
+ expect(body.detail).toBe('Credential is malformed.')
124
+ expect(body.detail).not.toContain('secret-key')
125
+ expect(body.detail).not.toContain('rpc.example.com')
126
+ })
127
+
98
128
  test('returns 402 when challenge ID mismatch', async () => {
99
129
  const wrongChallenge = Challenge.from({
100
130
  id: 'wrong-id',
@@ -181,7 +211,7 @@ describe('request handler', () => {
181
211
  expect(body.detail).toContain('does not match')
182
212
  })
183
213
 
184
- test('topUp credential bypasses cross-route amount validation', async () => {
214
+ test('topUp credential is rejected when replayed across routes with different amounts', async () => {
185
215
  // Use a session method whose schema defines action: 'topUp'
186
216
  const sessionMethod = Method.from({
187
217
  name: 'mock',
@@ -231,7 +261,7 @@ describe('request handler', () => {
231
261
  payload: { action: 'topUp', token: 'valid' },
232
262
  })
233
263
 
234
- // Present it at the "expensive" route — topUp should bypass amount check
264
+ // Present it at the "expensive" route — topUp must still match scope.
235
265
  const expensiveHandle = handler['mock/session']({
236
266
  amount: '1000000',
237
267
  currency: asset,
@@ -244,16 +274,13 @@ describe('request handler', () => {
244
274
  }),
245
275
  )
246
276
 
247
- // Should NOT get 402 for amount mismatch — topUp bypasses the check.
248
- // It will fail at a later stage (payload validation), but not with
249
- // "does not match this route's requirements".
250
- if (result.status === 402) {
251
- const body = (await result.challenge.json()) as { detail?: string }
252
- expect(body.detail).not.toContain('does not match')
253
- }
277
+ expect(result.status).toBe(402)
278
+ if (result.status !== 402) throw new Error()
279
+ const body = (await result.challenge.json()) as { detail?: string }
280
+ expect(body.detail).toContain('does not match')
254
281
  })
255
282
 
256
- test('voucher credential bypasses cross-route amount validation', async () => {
283
+ test('voucher credential is rejected when replayed across routes with different amounts', async () => {
257
284
  const sessionMethod = Method.from({
258
285
  name: 'mock',
259
286
  intent: 'session',
@@ -307,8 +334,8 @@ describe('request handler', () => {
307
334
  payload: { action: 'voucher', cumulativeAmount: '500', signature: '0xabc' },
308
335
  })
309
336
 
310
- // Present it at the same route but with a higher price — voucher should
311
- // bypass the cross-route amount check just like topUp does
337
+ // Present it at the same route but with a higher price — voucher must
338
+ // still match the original priced scope.
312
339
  const expensiveHandle = handler['mock/session']({
313
340
  amount: '1000000',
314
341
  currency: asset,
@@ -321,11 +348,10 @@ describe('request handler', () => {
321
348
  }),
322
349
  )
323
350
 
324
- // Should NOT get 402 for amount mismatch — voucher bypasses the check.
325
- if (result.status === 402) {
326
- const body = (await result.challenge.json()) as { detail?: string }
327
- expect(body.detail).not.toContain('does not match')
328
- }
351
+ expect(result.status).toBe(402)
352
+ if (result.status !== 402) throw new Error()
353
+ const body = (await result.challenge.json()) as { detail?: string }
354
+ expect(body.detail).toContain('does not match')
329
355
  })
330
356
 
331
357
  test('rejects charge credential with injected action: topUp (cross-route bypass attempt)', async () => {
@@ -552,7 +578,63 @@ describe('request handler', () => {
552
578
  "type": "https://paymentauth.org/problems/invalid-payload",
553
579
  }
554
580
  `)
555
- expect(body.detail).toContain('Credential payload is invalid')
581
+ expect(body.detail).toBe('Credential payload is invalid.')
582
+ expect(body.detail).not.toContain('invalidField')
583
+ })
584
+
585
+ test('returns sanitized verification error for unexpected verifier failures', async () => {
586
+ const leakingMethod = Method.toServer(
587
+ Method.from({
588
+ name: 'mock',
589
+ intent: 'charge',
590
+ schema: {
591
+ credential: {
592
+ payload: z.object({ token: z.string() }),
593
+ },
594
+ request: z.object({
595
+ amount: z.string(),
596
+ currency: z.string(),
597
+ recipient: z.string(),
598
+ }),
599
+ },
600
+ }),
601
+ {
602
+ async verify() {
603
+ throw new Error('request to https://mainnet.infura.io/v3/secret-key failed')
604
+ },
605
+ },
606
+ )
607
+
608
+ const handle = Mppx.create({ methods: [leakingMethod], realm, secretKey })['mock/charge']({
609
+ amount: '1000',
610
+ currency: asset,
611
+ expires: new Date(Date.now() + 60_000).toISOString(),
612
+ recipient: accounts[0].address,
613
+ })
614
+
615
+ const firstResult = await handle(new Request('https://example.com/resource'))
616
+ expect(firstResult.status).toBe(402)
617
+ if (firstResult.status !== 402) throw new Error()
618
+
619
+ const challenge = Challenge.fromResponse(firstResult.challenge)
620
+ const credential = Credential.from({
621
+ challenge,
622
+ payload: { token: 'valid' },
623
+ })
624
+
625
+ const result = await handle(
626
+ new Request('https://example.com/resource', {
627
+ headers: { Authorization: Credential.serialize(credential) },
628
+ }),
629
+ )
630
+
631
+ expect(result.status).toBe(402)
632
+ if (result.status !== 402) throw new Error()
633
+
634
+ const body = (await result.challenge.json()) as { detail: string }
635
+ expect(body.detail).toBe('Payment verification failed.')
636
+ expect(body.detail).not.toContain('infura')
637
+ expect(body.detail).not.toContain('secret-key')
556
638
  })
557
639
  })
558
640
 
@@ -1724,6 +1806,77 @@ describe('cross-route credential replay via scope binding flaw', () => {
1724
1806
 
1725
1807
  expect(result.status).toBe(402)
1726
1808
  })
1809
+
1810
+ test('compose dispatch includes methodDetails memo/splits binding', async () => {
1811
+ const splitsMethod = Method.from({
1812
+ name: 'mock',
1813
+ intent: 'charge',
1814
+ schema: {
1815
+ credential: { payload: z.object({ token: z.string() }) },
1816
+ request: z.pipe(
1817
+ z.object({
1818
+ amount: z.string(),
1819
+ currency: z.string(),
1820
+ decimals: z.number(),
1821
+ recipient: z.string(),
1822
+ splits: z.optional(z.array(z.object({ amount: z.string(), recipient: z.string() }))),
1823
+ }),
1824
+ z.transform(({ amount, currency, decimals, recipient, splits }) => ({
1825
+ methodDetails: {
1826
+ amount: String(Number(amount) * 10 ** decimals),
1827
+ currency,
1828
+ recipient,
1829
+ ...(splits && { splits }),
1830
+ },
1831
+ })),
1832
+ ),
1833
+ },
1834
+ })
1835
+
1836
+ const splitsServerMethod = Method.toServer(splitsMethod, {
1837
+ async verify() {
1838
+ return mockReceipt()
1839
+ },
1840
+ })
1841
+
1842
+ const handler = Mppx.create({ methods: [splitsServerMethod], realm, secretKey })
1843
+
1844
+ const noSplitsHandle = handler.charge({
1845
+ amount: '1',
1846
+ currency: '0x0000000000000000000000000000000000000001',
1847
+ decimals: 6,
1848
+ expires: new Date(Date.now() + 60_000).toISOString(),
1849
+ recipient: '0x0000000000000000000000000000000000000002',
1850
+ })
1851
+ const splitsHandle = handler.charge({
1852
+ amount: '1',
1853
+ currency: '0x0000000000000000000000000000000000000001',
1854
+ decimals: 6,
1855
+ expires: new Date(Date.now() + 60_000).toISOString(),
1856
+ recipient: '0x0000000000000000000000000000000000000002',
1857
+ splits: [{ amount: '0.2', recipient: '0x0000000000000000000000000000000000000003' }],
1858
+ })
1859
+
1860
+ const composed = Mppx.compose(noSplitsHandle, splitsHandle)
1861
+ const firstResult = await composed(new Request('https://example.com/resource'))
1862
+ expect(firstResult.status).toBe(402)
1863
+ if (firstResult.status !== 402) throw new Error()
1864
+
1865
+ const challenges = Challenge.fromResponseList(firstResult.challenge)
1866
+ const noSplitsChallenge = challenges[0]!
1867
+ const credential = Credential.from({
1868
+ challenge: noSplitsChallenge,
1869
+ payload: { token: 'valid' },
1870
+ })
1871
+
1872
+ const result = await composed(
1873
+ new Request('https://example.com/resource', {
1874
+ headers: { Authorization: Credential.serialize(credential) },
1875
+ }),
1876
+ )
1877
+
1878
+ expect(result.status).toBe(200)
1879
+ })
1727
1880
  })
1728
1881
 
1729
1882
  describe('withReceipt', () => {
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http'
2
+ import { isDeepStrictEqual } from 'node:util'
2
3
 
3
4
  import * as Challenge from '../Challenge.js'
4
5
  import * as Credential from '../Credential.js'
@@ -300,10 +301,12 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
300
301
 
301
302
  // Credential was provided but malformed
302
303
  if (credentialError) {
304
+ const reason = getSafeCredentialReason(credentialError)
303
305
  const response = await transport.respondChallenge({
304
306
  challenge,
305
307
  input,
306
- error: new Errors.MalformedCredentialError({ reason: credentialError.message }),
308
+ error: new Errors.MalformedCredentialError(reason ? { reason } : {}),
309
+ html: method.html,
307
310
  })
308
311
  return { challenge: response, status: 402 }
309
312
  }
@@ -314,6 +317,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
314
317
  challenge,
315
318
  input,
316
319
  error: new Errors.PaymentRequiredError({ description }),
320
+ html: method.html,
317
321
  })
318
322
  return { challenge: response, status: 402 }
319
323
  }
@@ -328,6 +332,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
328
332
  id: credential.challenge.id,
329
333
  reason: 'challenge was not issued by this server',
330
334
  }),
335
+ html: method.html,
331
336
  })
332
337
  return { challenge: response, status: 402 }
333
338
  }
@@ -339,13 +344,6 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
339
344
  // Note: we compare specific payment parameters rather than the full
340
345
  // request because the `request` hook may produce credential-dependent
341
346
  // output (e.g. `feePayer` differs between 402 and credential calls).
342
- //
343
- // Skip this check for topUp and voucher actions: the route's
344
- // `request` hook may produce a different amount because these
345
- // requests carry no application body (e.g. no model field for
346
- // dynamic pricing). The credential echoes a challenge obtained
347
- // from the original request which had the correct amount; the
348
- // on-chain voucher signature is the real validation.
349
347
  {
350
348
  for (const field of ['method', 'intent', 'realm'] as const) {
351
349
  if (credential.challenge[field] !== challenge[field]) {
@@ -356,65 +354,26 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
356
354
  id: credential.challenge.id,
357
355
  reason: `credential ${field} does not match this route's requirements`,
358
356
  }),
357
+ html: method.html,
359
358
  })
360
359
  return { challenge: response, status: 402 }
361
360
  }
362
361
  }
363
362
 
364
- // Use safeParse (not raw payload) so only methods whose schema
365
- // defines `action` can trigger the skip. Without this, a client
366
- // could inject `action: 'topUp'` on a charge credential to bypass
367
- // the amount check. Zod strips unknown keys, so charge payloads
368
- // (which don't define `action`) will have it removed.
369
- const parsed = method.schema.credential.payload.safeParse(credential.payload)
370
- const action = parsed.success
371
- ? (parsed.data as Record<string, unknown>)?.action
372
- : undefined
373
- if (action !== 'topUp' && action !== 'voucher') {
374
- const routeReq = challenge.request as Record<string, unknown>
375
- const echoedReq = credential.challenge.request as Record<string, unknown>
376
- const routeDetails = (routeReq.methodDetails ?? {}) as Record<string, unknown>
377
- const echoedDetails = (echoedReq.methodDetails ?? {}) as Record<string, unknown>
378
- for (const field of ['amount', 'currency', 'recipient'] as const) {
379
- const routeVal = routeReq[field] ?? routeDetails[field]
380
- if (
381
- routeVal !== undefined &&
382
- String(routeVal) !== String(echoedReq[field] ?? echoedDetails[field])
383
- ) {
384
- const response = await transport.respondChallenge({
385
- challenge,
386
- input,
387
- error: new Errors.InvalidChallengeError({
388
- id: credential.challenge.id,
389
- reason: `credential ${field} does not match this route's requirements`,
390
- }),
391
- })
392
- return { challenge: response, status: 402 }
393
- }
394
- }
395
-
396
- // Compare payment-relevant methodDetails fields (memo, splits).
397
- // These are excluded from the top-level field check above but
398
- // affect verification semantics — a credential issued for a
399
- // no-splits route must not be accepted on a splits route.
400
- for (const field of ['memo', 'splits'] as const) {
401
- const routeVal = routeDetails[field]
402
- const echoedVal = echoedDetails[field]
403
- if (
404
- routeVal !== undefined &&
405
- JSON.stringify(routeVal) !== JSON.stringify(echoedVal)
406
- ) {
407
- const response = await transport.respondChallenge({
408
- challenge,
409
- input,
410
- error: new Errors.InvalidChallengeError({
411
- id: credential.challenge.id,
412
- reason: `credential ${field} does not match this route's requirements`,
413
- }),
414
- })
415
- return { challenge: response, status: 402 }
416
- }
417
- }
363
+ const mismatch = getRequestBindingMismatch(
364
+ challenge.request as Record<string, unknown>,
365
+ credential.challenge.request as Record<string, unknown>,
366
+ )
367
+ if (mismatch) {
368
+ const response = await transport.respondChallenge({
369
+ challenge,
370
+ input,
371
+ error: new Errors.InvalidChallengeError({
372
+ id: credential.challenge.id,
373
+ reason: `credential ${mismatch} does not match this route's requirements`,
374
+ }),
375
+ })
376
+ return { challenge: response, status: 402 }
418
377
  }
419
378
  }
420
379
 
@@ -432,11 +391,11 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
432
391
  // Validate payload structure against method schema
433
392
  try {
434
393
  method.schema.credential.payload.parse(credential.payload)
435
- } catch (e) {
394
+ } catch {
436
395
  const response = await transport.respondChallenge({
437
396
  challenge,
438
397
  input,
439
- error: new Errors.InvalidPayloadError({ reason: (e as Error).message }),
398
+ error: new Errors.InvalidPayloadError(),
440
399
  })
441
400
  return { challenge: response, status: 402 }
442
401
  }
@@ -447,10 +406,9 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
447
406
  try {
448
407
  receiptData = await verify({ credential, request } as never)
449
408
  } catch (e) {
450
- const error =
451
- e instanceof Errors.PaymentError
452
- ? e
453
- : new Errors.VerificationFailedError({ reason: (e as Error).message })
409
+ if (!(e instanceof Errors.PaymentError))
410
+ console.error('mppx: internal verification error', e)
411
+ const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError()
454
412
  const response = await transport.respondChallenge({
455
413
  challenge,
456
414
  input,
@@ -473,6 +431,8 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
473
431
  withReceipt<response>(response?: response) {
474
432
  if (managementResponse) {
475
433
  return transport.respondReceipt({
434
+ credential,
435
+ input,
476
436
  receipt: receiptData,
477
437
  response: managementResponse as never,
478
438
  challengeId: credential.challenge.id,
@@ -480,6 +440,8 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
480
440
  }
481
441
  if (!response) throw new Error('withReceipt() requires a response argument')
482
442
  return transport.respondReceipt({
443
+ credential,
444
+ input,
483
445
  receipt: receiptData,
484
446
  response: response as never,
485
447
  challengeId: credential.challenge.id,
@@ -501,6 +463,13 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
501
463
  }
502
464
  }
503
465
 
466
+ function getSafeCredentialReason(error: unknown): string | undefined {
467
+ if (error instanceof Credential.InvalidCredentialEncodingError) return error.message
468
+ if (error instanceof Credential.MissingAuthorizationHeaderError) return error.message
469
+ if (error instanceof Credential.MissingPaymentSchemeError) return error.message
470
+ return undefined
471
+ }
472
+
504
473
  declare namespace createMethodFn {
505
474
  type Parameters<
506
475
  method extends Method.Method = Method.Method,
@@ -552,6 +521,88 @@ function resolveRealmFromRequest(input: unknown): string {
552
521
  return defaultRealm
553
522
  }
554
523
 
524
+ type RequestBindingField = 'amount' | 'currency' | 'recipient' | 'chainId' | 'memo' | 'splits'
525
+
526
+ const requestBindingFields = [
527
+ 'amount',
528
+ 'currency',
529
+ 'recipient',
530
+ 'chainId',
531
+ 'memo',
532
+ 'splits',
533
+ ] as const satisfies readonly RequestBindingField[]
534
+
535
+ type RequestBinding = Partial<Record<RequestBindingField, unknown>>
536
+
537
+ function getRequestBindingMismatch(
538
+ expectedRequest: Record<string, unknown>,
539
+ actualRequest: Record<string, unknown>,
540
+ ): RequestBindingField | undefined {
541
+ const expected = getRequestBinding(expectedRequest)
542
+ const actual = getRequestBinding(actualRequest)
543
+
544
+ return requestBindingFields.find(
545
+ (field) => !requestBindingValuesMatch(field, expected[field], actual[field]),
546
+ )
547
+ }
548
+
549
+ function getRequestBinding(request: Record<string, unknown>): RequestBinding {
550
+ const methodDetails = (request.methodDetails ?? {}) as Record<string, unknown>
551
+
552
+ return {
553
+ amount: request.amount ?? methodDetails.amount,
554
+ currency: request.currency ?? methodDetails.currency,
555
+ recipient: request.recipient ?? methodDetails.recipient,
556
+ chainId: request.chainId ?? methodDetails.chainId,
557
+ memo: methodDetails.memo,
558
+ splits: methodDetails.splits,
559
+ }
560
+ }
561
+
562
+ function requestBindingValuesMatch(
563
+ field: RequestBindingField,
564
+ expected: unknown,
565
+ actual: unknown,
566
+ ): boolean {
567
+ return isDeepStrictEqual(
568
+ normalizeRequestBindingValue(field, expected),
569
+ normalizeRequestBindingValue(field, actual),
570
+ )
571
+ }
572
+
573
+ function normalizeRequestBindingValue(field: RequestBindingField, value: unknown): unknown {
574
+ switch (field) {
575
+ case 'memo':
576
+ return normalizeHex(value)
577
+ case 'splits':
578
+ return normalizeComparable(value)
579
+ default:
580
+ return normalizeScalar(value)
581
+ }
582
+ }
583
+
584
+ function normalizeScalar(value: unknown): string | undefined {
585
+ return value === undefined ? undefined : String(value)
586
+ }
587
+
588
+ function normalizeHex(value: unknown): unknown {
589
+ return typeof value === 'string' && value.startsWith('0x') ? value.toLowerCase() : value
590
+ }
591
+
592
+ function normalizeComparable(value: unknown): unknown {
593
+ if (Array.isArray(value)) return value.map(normalizeComparable)
594
+
595
+ if (value && typeof value === 'object') {
596
+ return Object.fromEntries(
597
+ Object.entries(value as Record<string, unknown>)
598
+ .sort(([left], [right]) => left.localeCompare(right))
599
+ .map(([key, nested]) => [key, normalizeComparable(nested)]),
600
+ )
601
+ }
602
+
603
+ return normalizeHex(value)
604
+ }
605
+
555
606
  export type MethodFn<
556
607
  method extends Method.Method,
557
608
  transport extends Transport.AnyTransport,
@@ -668,7 +719,6 @@ export function compose(
668
719
  if (credential) {
669
720
  const { method: credMethod, intent: credIntent } = credential.challenge
670
721
  const credReq = credential.challenge.request as Record<string, unknown>
671
- const credDetails = (credReq.methodDetails ?? {}) as Record<string, unknown>
672
722
 
673
723
  // Filter by name+intent, then narrow by comparing stable request fields
674
724
  // from the echoed challenge against each handler's canonical request.
@@ -680,16 +730,7 @@ export function compose(
680
730
  if (!meta || meta.name !== credMethod || meta.intent !== credIntent) return false
681
731
  const canonical = meta._canonicalRequest
682
732
  if (!canonical) return true
683
- const canonicalDetails = (canonical.methodDetails ?? {}) as Record<string, unknown>
684
- for (const field of ['amount', 'currency', 'recipient', 'chainId'] as const) {
685
- const canonicalVal = canonical[field] ?? canonicalDetails[field]
686
- if (
687
- canonicalVal !== undefined &&
688
- String(canonicalVal) !== String(credReq[field] ?? credDetails[field])
689
- )
690
- return false
691
- }
692
- return true
733
+ return !getRequestBindingMismatch(canonical, credReq)
693
734
  })
694
735
 
695
736
  const match =
@@ -135,6 +135,8 @@ describe('http', () => {
135
135
  const originalResponse = new Response('OK', { status: 200 })
136
136
 
137
137
  const response = transport.respondReceipt({
138
+ credential,
139
+ input: new Request('https://example.com'),
138
140
  receipt,
139
141
  response: originalResponse,
140
142
  challengeId: challenge.id,
@@ -252,7 +254,13 @@ describe('mcp', () => {
252
254
  }
253
255
 
254
256
  expect(
255
- transport.respondReceipt({ receipt, response: successResponse, challengeId: challenge.id }),
257
+ transport.respondReceipt({
258
+ credential,
259
+ input: mcpRequest,
260
+ receipt,
261
+ response: successResponse,
262
+ challengeId: challenge.id,
263
+ }),
256
264
  ).toMatchInlineSnapshot(`
257
265
  {
258
266
  "id": 1,
@@ -285,7 +293,13 @@ describe('mcp', () => {
285
293
  }
286
294
 
287
295
  expect(
288
- transport.respondReceipt({ receipt, response: errorResponse, challengeId: challenge.id }),
296
+ transport.respondReceipt({
297
+ credential,
298
+ input: mcpRequest,
299
+ receipt,
300
+ response: errorResponse,
301
+ challengeId: challenge.id,
302
+ }),
289
303
  ).toBe(errorResponse)
290
304
  })
291
305
  })