mppx 0.3.8 → 0.3.11

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 (61) hide show
  1. package/README.md +3 -3
  2. package/dist/Challenge.d.ts.map +1 -1
  3. package/dist/Challenge.js +2 -0
  4. package/dist/Challenge.js.map +1 -1
  5. package/dist/Errors.d.ts +0 -2
  6. package/dist/Errors.d.ts.map +1 -1
  7. package/dist/Errors.js +1 -3
  8. package/dist/Errors.js.map +1 -1
  9. package/dist/internal/constantTimeEqual.d.ts.map +1 -1
  10. package/dist/internal/constantTimeEqual.js +4 -6
  11. package/dist/internal/constantTimeEqual.js.map +1 -1
  12. package/dist/internal/env.d.ts +2 -2
  13. package/dist/internal/env.d.ts.map +1 -1
  14. package/dist/internal/env.js +1 -2
  15. package/dist/internal/env.js.map +1 -1
  16. package/dist/middlewares/internal/mppx.d.ts.map +1 -1
  17. package/dist/middlewares/internal/mppx.js +6 -2
  18. package/dist/middlewares/internal/mppx.js.map +1 -1
  19. package/dist/server/Mppx.d.ts +13 -3
  20. package/dist/server/Mppx.d.ts.map +1 -1
  21. package/dist/server/Mppx.js +46 -3
  22. package/dist/server/Mppx.js.map +1 -1
  23. package/dist/tempo/internal/simulate.d.ts +21 -0
  24. package/dist/tempo/internal/simulate.d.ts.map +1 -0
  25. package/dist/tempo/internal/simulate.js +31 -0
  26. package/dist/tempo/internal/simulate.js.map +1 -0
  27. package/dist/tempo/server/Charge.d.ts +12 -0
  28. package/dist/tempo/server/Charge.d.ts.map +1 -1
  29. package/dist/tempo/server/Charge.js +28 -6
  30. package/dist/tempo/server/Charge.js.map +1 -1
  31. package/dist/tempo/server/Session.d.ts +18 -1
  32. package/dist/tempo/server/Session.d.ts.map +1 -1
  33. package/dist/tempo/server/Session.js +66 -46
  34. package/dist/tempo/server/Session.js.map +1 -1
  35. package/dist/tempo/session/Chain.d.ts +5 -2
  36. package/dist/tempo/session/Chain.d.ts.map +1 -1
  37. package/dist/tempo/session/Chain.js +78 -10
  38. package/dist/tempo/session/Chain.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/Challenge.ts +2 -0
  41. package/src/Errors.test.ts +43 -18
  42. package/src/Errors.ts +1 -4
  43. package/src/client/Mppx.test.ts +1 -0
  44. package/src/internal/constantTimeEqual.ts +5 -4
  45. package/src/internal/env.test.ts +2 -2
  46. package/src/internal/env.ts +4 -5
  47. package/src/middlewares/express.test.ts +5 -0
  48. package/src/middlewares/hono.test.ts +5 -0
  49. package/src/middlewares/internal/mppx.ts +5 -2
  50. package/src/middlewares/nextjs.test.ts +5 -0
  51. package/src/proxy/Proxy.test.ts +3 -0
  52. package/src/proxy/services/openai.test.ts +3 -0
  53. package/src/server/Mppx.test.ts +93 -2
  54. package/src/server/Mppx.ts +81 -6
  55. package/src/tempo/internal/simulate.ts +49 -0
  56. package/src/tempo/server/Charge.test.ts +62 -0
  57. package/src/tempo/server/Charge.ts +44 -6
  58. package/src/tempo/server/Session.test.ts +51 -2
  59. package/src/tempo/server/Session.ts +97 -38
  60. package/src/tempo/session/Chain.test.ts +190 -0
  61. package/src/tempo/session/Chain.ts +109 -5
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mppx",
3
3
  "type": "module",
4
- "version": "0.3.8",
4
+ "version": "0.3.11",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "files": [
package/src/Challenge.ts CHANGED
@@ -336,6 +336,8 @@ export function deserialize<const methods extends readonly Method.Method[] | und
336
336
 
337
337
  const { request, opaque, ...rest } = result
338
338
  if (!request) throw new Error('Missing request parameter.')
339
+ if (rest.method && !/^[a-z][a-z0-9:_-]*$/.test(rest.method))
340
+ throw new Error(`Invalid method: "${rest.method}". Must be lowercase per spec.`)
339
341
 
340
342
  return from(
341
343
  {
@@ -10,6 +10,7 @@ import {
10
10
  InvalidPayloadError,
11
11
  InvalidSignatureError,
12
12
  MalformedCredentialError,
13
+ PaymentActionRequiredError,
13
14
  PaymentExpiredError,
14
15
  PaymentInsufficientError,
15
16
  PaymentMethodUnsupportedError,
@@ -165,27 +166,12 @@ describe('PaymentRequiredError', () => {
165
166
  `)
166
167
  })
167
168
 
168
- test('with realm', () => {
169
+ test('with description', () => {
169
170
  expect(
170
- errorSnapshot(new PaymentRequiredError({ realm: 'api.example.com' })),
171
+ errorSnapshot(new PaymentRequiredError({ description: 'API access fee' })),
171
172
  ).toMatchInlineSnapshot(`
172
173
  {
173
- "message": "Payment is required for "api.example.com".",
174
- "name": "PaymentRequiredError",
175
- "status": 402,
176
- "type": "https://paymentauth.org/problems/payment-required",
177
- }
178
- `)
179
- })
180
-
181
- test('with realm and description', () => {
182
- expect(
183
- errorSnapshot(
184
- new PaymentRequiredError({ realm: 'api.example.com', description: 'API access fee' }),
185
- ),
186
- ).toMatchInlineSnapshot(`
187
- {
188
- "message": "Payment is required for "api.example.com" (API access fee).",
174
+ "message": "Payment is required (API access fee).",
189
175
  "name": "PaymentRequiredError",
190
176
  "status": 402,
191
177
  "type": "https://paymentauth.org/problems/payment-required",
@@ -428,6 +414,45 @@ describe('ChannelClosedError', () => {
428
414
  })
429
415
  })
430
416
 
417
+ describe('PaymentActionRequiredError', () => {
418
+ test('default', () => {
419
+ expect(errorSnapshot(new PaymentActionRequiredError())).toMatchInlineSnapshot(`
420
+ {
421
+ "message": "Payment requires action.",
422
+ "name": "PaymentActionRequiredError",
423
+ "status": 402,
424
+ "type": "https://paymentauth.org/problems/payment-action-required",
425
+ }
426
+ `)
427
+ })
428
+
429
+ test('with reason', () => {
430
+ expect(
431
+ errorSnapshot(new PaymentActionRequiredError({ reason: 'requires_action' })),
432
+ ).toMatchInlineSnapshot(`
433
+ {
434
+ "message": "Payment requires action: requires_action.",
435
+ "name": "PaymentActionRequiredError",
436
+ "status": 402,
437
+ "type": "https://paymentauth.org/problems/payment-action-required",
438
+ }
439
+ `)
440
+ })
441
+
442
+ test('toProblemDetails', () => {
443
+ const error = new PaymentActionRequiredError({ reason: 'Stripe PaymentIntent requires action' })
444
+ expect(error.toProblemDetails('ch_123')).toMatchInlineSnapshot(`
445
+ {
446
+ "challengeId": "ch_123",
447
+ "detail": "Payment requires action: Stripe PaymentIntent requires action.",
448
+ "status": 402,
449
+ "title": "Payment Action Required",
450
+ "type": "https://paymentauth.org/problems/payment-action-required",
451
+ }
452
+ `)
453
+ })
454
+ })
455
+
431
456
  describe('toProblemDetails', () => {
432
457
  test('without challengeId', () => {
433
458
  const error = new MalformedCredentialError({ reason: 'invalid JSON' })
package/src/Errors.ts CHANGED
@@ -158,9 +158,8 @@ export class PaymentRequiredError extends PaymentError {
158
158
  readonly type = 'https://paymentauth.org/problems/payment-required'
159
159
 
160
160
  constructor(options: PaymentRequiredError.Options = {}) {
161
- const { description, realm } = options
161
+ const { description } = options
162
162
  const parts = ['Payment is required']
163
- if (realm) parts.push(`for "${realm}"`)
164
163
  if (description) parts.push(`(${description})`)
165
164
  super(`${parts.join(' ')}.`)
166
165
  }
@@ -170,8 +169,6 @@ export declare namespace PaymentRequiredError {
170
169
  type Options = {
171
170
  /** Human-readable description of the payment. */
172
171
  description?: string | undefined
173
- /** Server realm (e.g., hostname). */
174
- realm?: string | undefined
175
172
  }
176
173
  }
177
174
 
@@ -295,6 +295,7 @@ const server = Mppx_server.create({
295
295
  recipient: accounts[0].address,
296
296
  }),
297
297
  ],
298
+ secretKey,
298
299
  })
299
300
 
300
301
  describe('fetch', () => {
@@ -1,7 +1,8 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto'
2
+
1
3
  /** Constant-time string comparison to prevent timing attacks. */
2
4
  export function constantTimeEqual(a: string, b: string): boolean {
3
- if (a.length !== b.length) return false
4
- let result = 0
5
- for (let i = 0; i < a.length; i++) result |= a.charCodeAt(i) ^ b.charCodeAt(i)
6
- return result === 0
5
+ const hashA = createHash('sha256').update(a).digest()
6
+ const hashB = createHash('sha256').update(b).digest()
7
+ return timingSafeEqual(hashA, hashB)
7
8
  }
@@ -9,8 +9,8 @@ describe('Env.get', () => {
9
9
  expect(Env.get('realm')).toBe('MPP Payment')
10
10
  })
11
11
 
12
- test('returns default secretKey when MPP_SECRET_KEY is not set', () => {
13
- expect(Env.get('secretKey')).toBe('tmp')
12
+ test('returns undefined when MPP_SECRET_KEY is not set', () => {
13
+ expect(Env.get('secretKey')).toBeUndefined()
14
14
  })
15
15
 
16
16
  test('returns MPP_SECRET_KEY when set', () => {
@@ -17,13 +17,12 @@ const variables = {
17
17
  /** Fallback values when no environment variable is set. */
18
18
  const defaults = {
19
19
  realm: 'MPP Payment',
20
- secretKey: 'tmp',
21
- } as const satisfies Record<keyof typeof variables, string>
20
+ } as const satisfies Partial<Record<keyof typeof variables, string>>
22
21
 
23
22
  /**
24
23
  * Resolves a configuration value from environment variables.
25
24
  *
26
- * Checks platform-specific env vars in order, falling back to a default.
25
+ * Checks platform-specific env vars in order, falling back to a default if one exists.
27
26
  *
28
27
  * @example
29
28
  * ```ts
@@ -31,12 +30,12 @@ const defaults = {
31
30
  * Env.get('secretKey') // e.g. value of MPP_SECRET_KEY
32
31
  * ```
33
32
  */
34
- export function get(key: keyof typeof variables): string {
33
+ export function get(key: keyof typeof variables): string | undefined {
35
34
  for (const name of variables[key]) {
36
35
  const value = read(name)
37
36
  if (value) return value
38
37
  }
39
- return defaults[key]
38
+ return (defaults as Record<string, string | undefined>)[key]
40
39
  }
41
40
 
42
41
  /** Reads a single environment variable, probing available runtime APIs. */
@@ -21,6 +21,8 @@ function createServer(app: express.Express) {
21
21
  })
22
22
  }
23
23
 
24
+ const secretKey = 'test-secret-key'
25
+
24
26
  describe('charge', () => {
25
27
  const mppx = Mppx.create({
26
28
  methods: [
@@ -30,6 +32,7 @@ describe('charge', () => {
30
32
  recipient: accounts[0].address,
31
33
  }),
32
34
  ],
35
+ secretKey,
33
36
  })
34
37
 
35
38
  const { fetch } = Mppx_client.create({
@@ -99,6 +102,7 @@ describe('session', () => {
99
102
  escrowContract,
100
103
  }),
101
104
  ],
105
+ secretKey,
102
106
  })
103
107
 
104
108
  const app = express()
@@ -125,6 +129,7 @@ describe('session', () => {
125
129
  feePayer: accounts[0],
126
130
  }),
127
131
  ],
132
+ secretKey,
128
133
  })
129
134
 
130
135
  const { fetch } = Mppx_client.create({
@@ -21,6 +21,8 @@ function createServer(app: Hono) {
21
21
  })
22
22
  }
23
23
 
24
+ const secretKey = 'test-secret-key'
25
+
24
26
  describe('charge', () => {
25
27
  const mppx = Mppx.create({
26
28
  methods: [
@@ -30,6 +32,7 @@ describe('charge', () => {
30
32
  recipient: accounts[0].address,
31
33
  }),
32
34
  ],
35
+ secretKey,
33
36
  })
34
37
 
35
38
  const { fetch } = Mppx_client.create({
@@ -92,6 +95,7 @@ describe('session', () => {
92
95
  escrowContract,
93
96
  }),
94
97
  ],
98
+ secretKey,
95
99
  })
96
100
 
97
101
  const app = new Hono()
@@ -118,6 +122,7 @@ describe('session', () => {
118
122
  feePayer: accounts[0],
119
123
  }),
120
124
  ],
125
+ secretKey,
121
126
  })
122
127
 
123
128
  const { fetch } = Mppx_client.create({
@@ -23,8 +23,11 @@ export function wrap<mppx extends Mppx.Mppx<any, any>, handler>(
23
23
  ): Wrap<mppx, handler> {
24
24
  const result: Record<string, unknown> = { ...mppx }
25
25
  for (const mi of mppx.methods as readonly Method.AnyServer[]) {
26
- const methodFn = (mppx as any)[mi.intent]
27
- result[mi.intent] = (options: any) => wrapper(methodFn, options)
26
+ const key = `${mi.name}/${mi.intent}`
27
+ const methodFn = (mppx as any)[key]
28
+ result[key] = (options: any) => wrapper(methodFn, options)
29
+ // Also set shorthand intent key if Mppx registered it (no collision)
30
+ if ((mppx as any)[mi.intent]) result[mi.intent] = (options: any) => wrapper(methodFn, options)
28
31
  }
29
32
  return result as never
30
33
  }
@@ -33,6 +33,8 @@ function createServer(handler: (request: Request) => Promise<Response> | Respons
33
33
  })
34
34
  }
35
35
 
36
+ const secretKey = 'test-secret-key'
37
+
36
38
  describe('charge', () => {
37
39
  const mppx = Mppx.create({
38
40
  methods: [
@@ -42,6 +44,7 @@ describe('charge', () => {
42
44
  recipient: accounts[0].address,
43
45
  }),
44
46
  ],
47
+ secretKey,
45
48
  })
46
49
 
47
50
  const { fetch } = Mppx_client.create({
@@ -106,6 +109,7 @@ describe('session', () => {
106
109
  escrowContract,
107
110
  }),
108
111
  ],
112
+ secretKey,
109
113
  })
110
114
 
111
115
  const handler = mppx.session({ amount: '1', unitType: 'token' })(() =>
@@ -131,6 +135,7 @@ describe('session', () => {
131
135
  feePayer: accounts[0],
132
136
  }),
133
137
  ],
138
+ secretKey,
134
139
  })
135
140
 
136
141
  const { fetch } = Mppx_client.create({
@@ -9,6 +9,8 @@ import * as Service from './Service.js'
9
9
  import { anthropic } from './services/anthropic.js'
10
10
  import { openai } from './services/openai.js'
11
11
 
12
+ const secretKey = 'test-secret-key'
13
+
12
14
  const mppx_server = Mppx_server.create({
13
15
  methods: [
14
16
  tempo_server({
@@ -18,6 +20,7 @@ const mppx_server = Mppx_server.create({
18
20
  feePayer: true,
19
21
  }),
20
22
  ],
23
+ secretKey,
21
24
  })
22
25
 
23
26
  const mppx_client = Mppx_client.create({
@@ -10,6 +10,8 @@ import { openai } from './openai.js'
10
10
  const apiKey = process.env.VITE_OPENAI_API_KEY
11
11
  if (!apiKey) console.warn('OPENAI_API_KEY not set — openai proxy tests will be skipped')
12
12
 
13
+ const secretKey = 'test-secret-key'
14
+
13
15
  const mppx_server = Mppx_server.create({
14
16
  methods: [
15
17
  tempo_server({
@@ -18,6 +20,7 @@ const mppx_server = Mppx_server.create({
18
20
  getClient: () => client,
19
21
  }),
20
22
  ],
23
+ secretKey,
21
24
  })
22
25
 
23
26
  const mppx_client = Mppx_client.create({
@@ -53,7 +53,7 @@ describe('request handler', () => {
53
53
  }).toMatchInlineSnapshot(`
54
54
  {
55
55
  "challengeId": "[challengeId]",
56
- "detail": "Payment is required for "api.example.com".",
56
+ "detail": "Payment is required.",
57
57
  "instance": "[instance]",
58
58
  "status": 402,
59
59
  "title": "Payment Required",
@@ -138,6 +138,97 @@ describe('request handler', () => {
138
138
  `)
139
139
  })
140
140
 
141
+ test('returns 402 when credential is from a different route (cross-route scope confusion)', async () => {
142
+ const handler = Mppx.create({ methods: [method], realm, secretKey })
143
+
144
+ // Get a challenge from the "cheap" route
145
+ const cheapHandle = handler.charge({
146
+ amount: '1',
147
+ currency: asset,
148
+ expires: new Date(Date.now() + 60_000).toISOString(),
149
+ recipient: accounts[0].address,
150
+ })
151
+ const cheapResult = await cheapHandle(new Request('https://example.com/cheap'))
152
+ expect(cheapResult.status).toBe(402)
153
+ if (cheapResult.status !== 402) throw new Error()
154
+
155
+ const cheapChallenge = Challenge.fromResponse(cheapResult.challenge)
156
+
157
+ // Build a credential from the cheap challenge
158
+ const credential = Credential.from({
159
+ challenge: cheapChallenge,
160
+ payload: { signature: '0x123', type: 'transaction' },
161
+ })
162
+
163
+ // Present it at the "expensive" route
164
+ const expensiveHandle = handler.charge({
165
+ amount: '1000000',
166
+ currency: asset,
167
+ expires: new Date(Date.now() + 60_000).toISOString(),
168
+ recipient: accounts[0].address,
169
+ })
170
+ const result = await expensiveHandle(
171
+ new Request('https://example.com/expensive', {
172
+ headers: { Authorization: Credential.serialize(credential) },
173
+ }),
174
+ )
175
+
176
+ expect(result.status).toBe(402)
177
+ if (result.status !== 402) throw new Error()
178
+
179
+ const body = (await result.challenge.json()) as { detail: string }
180
+ expect(body.detail).toContain('does not match')
181
+ })
182
+
183
+ test('returns 402 when credential challenge is expired', async () => {
184
+ const pastExpires = new Date(Date.now() - 60_000).toISOString()
185
+
186
+ const handle = Mppx.create({ methods: [method], realm, secretKey }).charge({
187
+ amount: '1000',
188
+ currency: asset,
189
+ expires: pastExpires,
190
+ recipient: accounts[0].address,
191
+ })
192
+
193
+ // Get a fresh challenge (which has the expired timestamp baked in)
194
+ const firstResult = await handle(new Request('https://example.com/resource'))
195
+ expect(firstResult.status).toBe(402)
196
+ if (firstResult.status !== 402) throw new Error()
197
+
198
+ const challenge = Challenge.fromResponse(firstResult.challenge)
199
+
200
+ const credential = Credential.from({
201
+ challenge,
202
+ payload: { signature: '0x123', type: 'transaction' },
203
+ })
204
+
205
+ const result = await handle(
206
+ new Request('https://example.com/resource', {
207
+ headers: { Authorization: Credential.serialize(credential) },
208
+ }),
209
+ )
210
+
211
+ expect(result.status).toBe(402)
212
+ if (result.status !== 402) throw new Error()
213
+
214
+ const body = (await result.challenge.json()) as object
215
+ expect({
216
+ ...body,
217
+ challengeId: '[challengeId]',
218
+ detail: '[detail]',
219
+ instance: '[instance]',
220
+ }).toMatchInlineSnapshot(`
221
+ {
222
+ "challengeId": "[challengeId]",
223
+ "detail": "[detail]",
224
+ "instance": "[instance]",
225
+ "status": 402,
226
+ "title": "Payment Expired",
227
+ "type": "https://paymentauth.org/problems/payment-expired",
228
+ }
229
+ `)
230
+ expect((body as { detail: string }).detail).toContain('Payment expired at')
231
+ })
141
232
  test('returns 402 when payload schema validation fails', async () => {
142
233
  const handle = Mppx.create({ methods: [method], realm, secretKey }).charge({
143
234
  amount: '1000',
@@ -215,7 +306,7 @@ describe('request handler (node)', () => {
215
306
  }).toMatchInlineSnapshot(`
216
307
  {
217
308
  "challengeId": "[challengeId]",
218
- "detail": "Payment is required for "api.example.com".",
309
+ "detail": "Payment is required.",
219
310
  "instance": "[instance]",
220
311
  "status": 402,
221
312
  "title": "Payment Required",
@@ -41,17 +41,43 @@ type EffectiveTransportOf<mi, defaultTransport extends Transport.AnyTransport> =
41
41
  ? defaultTransport
42
42
  : TransportOverrideOf<mi>
43
43
 
44
- type Handlers<
44
+ /** True when exactly one method has the given intent (no name collision). */
45
+ type IsUniqueIntent<methods extends readonly Method.AnyServer[], intent extends string> = Extract<
46
+ methods[number],
47
+ { intent: intent }
48
+ > extends infer M
49
+ ? M extends M
50
+ ? [Exclude<Extract<methods[number], { intent: intent }>, M>] extends [never]
51
+ ? true
52
+ : false
53
+ : never
54
+ : never
55
+
56
+ /** Only includes shorthand intent keys when the intent is unique across methods. */
57
+ type UniqueIntentHandlers<
45
58
  methods extends readonly Method.AnyServer[],
46
59
  transport extends Transport.AnyTransport,
47
60
  > = {
48
- [method_name in methods[number]['intent']]: MethodFn<
61
+ [method_name in methods[number]['intent'] as IsUniqueIntent<methods, method_name> extends true
62
+ ? method_name
63
+ : never]: MethodFn<
49
64
  Extract<methods[number], { intent: method_name }>,
50
65
  EffectiveTransportOf<Extract<methods[number], { intent: method_name }>, transport>,
51
66
  NonNullable<Extract<methods[number], { intent: method_name }>['defaults']>
52
67
  >
53
68
  }
54
69
 
70
+ type Handlers<
71
+ methods extends readonly Method.AnyServer[],
72
+ transport extends Transport.AnyTransport,
73
+ > = {
74
+ [mi in methods[number] as `${mi['name']}/${mi['intent']}`]: MethodFn<
75
+ mi,
76
+ EffectiveTransportOf<mi, transport>,
77
+ NonNullable<mi['defaults']>
78
+ >
79
+ } & UniqueIntentHandlers<methods, transport>
80
+
55
81
  /**
56
82
  * Creates a server-side payment handler from methods.
57
83
  *
@@ -73,17 +99,25 @@ export function create<
73
99
  const transport extends Transport.AnyTransport = Transport.Http,
74
100
  >(config: create.Config<methods, transport>): Mppx<methods, transport> {
75
101
  const {
76
- realm = Env.get('realm'),
102
+ realm = Env.get('realm') ?? 'MPP Payment',
77
103
  secretKey = Env.get('secretKey'),
78
104
  transport = Transport.http() as transport,
79
105
  } = config
80
106
 
107
+ if (!secretKey) {
108
+ throw new Error(
109
+ 'Missing secret key. Set the MPP_SECRET_KEY environment variable or pass `secretKey` to Mppx.create().',
110
+ )
111
+ }
112
+
81
113
  const methods = config.methods.flat() as unknown as FlattenMethods<methods>
82
114
 
83
115
  const handlers: Record<string, unknown> = {}
116
+ const intentCount: Record<string, number> = {}
84
117
 
85
118
  for (const mi of methods) {
86
- handlers[mi.intent] = createMethodFn({
119
+ intentCount[mi.intent] = (intentCount[mi.intent] ?? 0) + 1
120
+ handlers[`${mi.name}/${mi.intent}`] = createMethodFn({
87
121
  defaults: mi.defaults,
88
122
  method: mi,
89
123
  realm,
@@ -95,6 +129,11 @@ export function create<
95
129
  })
96
130
  }
97
131
 
132
+ // Also set shorthand intent key when there's no collision
133
+ for (const mi of methods) {
134
+ if (intentCount[mi.intent] === 1) handlers[mi.intent] = handlers[`${mi.name}/${mi.intent}`]
135
+ }
136
+
98
137
  return { methods, realm: realm as string, transport, ...handlers } as never
99
138
  }
100
139
 
@@ -107,7 +146,7 @@ export declare namespace create {
107
146
  methods: methods
108
147
  /** Server realm (e.g., hostname). Auto-detected from environment variables (`MPP_REALM`, `VERCEL_URL`, `RAILWAY_PUBLIC_DOMAIN`, `RENDER_EXTERNAL_HOSTNAME`, `HOST`, `HOSTNAME`), falling back to `"localhost"`. */
109
148
  realm?: string | undefined
110
- /** Secret key for HMAC-bound challenge IDs for stateless verification. Auto-detected from `MPP_SECRET_KEY` environment variable, falling back to a random key. */
149
+ /** Secret key for HMAC-bound challenge IDs for stateless verification. Auto-detected from `MPP_SECRET_KEY` environment variable. Throws if neither provided nor set. */
111
150
  secretKey?: string | undefined
112
151
  /** Transport to use. @default Transport.http() */
113
152
  transport?: transport | undefined
@@ -185,7 +224,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
185
224
  const response = await transport.respondChallenge({
186
225
  challenge,
187
226
  input,
188
- error: new Errors.PaymentRequiredError({ realm, description }),
227
+ error: new Errors.PaymentRequiredError({ description }),
189
228
  })
190
229
  return { challenge: response, status: 402 }
191
230
  }
@@ -204,6 +243,42 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
204
243
  return { challenge: response, status: 402 }
205
244
  }
206
245
 
246
+ // Verify the credential's challenge matches this route's configured
247
+ // request. Prevents cross-route scope confusion where a credential
248
+ // issued for a cheap route is presented at an expensive route.
249
+ {
250
+ const routeReq = challenge.request as Record<string, unknown>
251
+ const echoedReq = credential.challenge.request as Record<string, unknown>
252
+ for (const field of ['amount', 'currency', 'recipient'] as const) {
253
+ if (
254
+ routeReq[field] !== undefined &&
255
+ echoedReq[field] !== undefined &&
256
+ String(routeReq[field]) !== String(echoedReq[field])
257
+ ) {
258
+ const response = await transport.respondChallenge({
259
+ challenge,
260
+ input,
261
+ error: new Errors.InvalidChallengeError({
262
+ id: credential.challenge.id,
263
+ reason: `credential ${field} does not match this route's requirements`,
264
+ }),
265
+ })
266
+ return { challenge: response, status: 402 }
267
+ }
268
+ }
269
+ }
270
+
271
+ // Reject expired credentials
272
+ if (credential.challenge.expires && new Date(credential.challenge.expires) < new Date()) {
273
+ const response = await transport.respondChallenge({
274
+ challenge,
275
+ input,
276
+ error: new Errors.PaymentExpiredError({
277
+ expires: credential.challenge.expires,
278
+ }),
279
+ })
280
+ return { challenge: response, status: 402 }
281
+ }
207
282
  // Validate payload structure against method schema
208
283
  try {
209
284
  method.schema.credential.payload.parse(credential.payload)
@@ -0,0 +1,49 @@
1
+ import type { Address, Client } from 'viem'
2
+
3
+ /**
4
+ * Simulate a Tempo transaction via `eth_estimateGas` to catch reverts
5
+ * (e.g. insufficient balance, invalid calls) before broadcasting.
6
+ */
7
+ export async function simulateTransaction(
8
+ client: Client,
9
+ transaction: {
10
+ from: Address
11
+ chainId: number
12
+ nonce?: number | bigint | undefined
13
+ maxFeePerGas?: bigint | undefined
14
+ maxPriorityFeePerGas?: bigint | undefined
15
+ feeToken?: string | bigint | undefined
16
+ nonceKey?: bigint | undefined
17
+ validBefore?: number | undefined
18
+ calls?: readonly {
19
+ to?: string | undefined
20
+ value?: bigint | undefined
21
+ data?: string | undefined
22
+ }[]
23
+ },
24
+ ): Promise<void> {
25
+ const simCalls = (transaction.calls ?? []).map((c) => ({
26
+ to: c.to,
27
+ value: c.value ? `0x${c.value.toString(16)}` : '0x0',
28
+ input: c.data ?? '0x',
29
+ }))
30
+ await client.request({
31
+ method: 'eth_estimateGas' as never,
32
+ params: [
33
+ {
34
+ from: transaction.from,
35
+ chainId: `0x${transaction.chainId.toString(16)}`,
36
+ nonce: `0x${BigInt(transaction.nonce ?? 0).toString(16)}`,
37
+ gas: '0x2dc6c0', // 3M cap
38
+ maxFeePerGas: `0x${(transaction.maxFeePerGas ?? 0n).toString(16)}`,
39
+ maxPriorityFeePerGas: `0x${(transaction.maxPriorityFeePerGas ?? 0n).toString(16)}`,
40
+ feeToken: transaction.feeToken,
41
+ nonceKey: `0x${(transaction.nonceKey ?? 0n).toString(16)}`,
42
+ calls: simCalls,
43
+ ...(transaction.validBefore
44
+ ? { validBefore: `0x${transaction.validBefore.toString(16)}` }
45
+ : {}),
46
+ },
47
+ ] as never,
48
+ })
49
+ }