mppx 0.6.20 → 0.6.21

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 (34) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/client/Mppx.d.ts +12 -1
  3. package/dist/client/Mppx.d.ts.map +1 -1
  4. package/dist/client/Mppx.js +127 -10
  5. package/dist/client/Mppx.js.map +1 -1
  6. package/dist/client/internal/Fetch.d.ts +69 -1
  7. package/dist/client/internal/Fetch.d.ts.map +1 -1
  8. package/dist/client/internal/Fetch.js +250 -20
  9. package/dist/client/internal/Fetch.js.map +1 -1
  10. package/dist/middlewares/internal/mppx.d.ts +1 -1
  11. package/dist/middlewares/internal/mppx.d.ts.map +1 -1
  12. package/dist/middlewares/internal/mppx.js +2 -1
  13. package/dist/middlewares/internal/mppx.js.map +1 -1
  14. package/dist/server/Mppx.d.ts +82 -3
  15. package/dist/server/Mppx.d.ts.map +1 -1
  16. package/dist/server/Mppx.js +557 -83
  17. package/dist/server/Mppx.js.map +1 -1
  18. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  19. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  20. package/dist/tempo/server/internal/html.gen.js +1 -1
  21. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/client/Mppx.test-d.ts +55 -0
  24. package/src/client/Mppx.test.ts +181 -0
  25. package/src/client/Mppx.ts +248 -16
  26. package/src/client/internal/Fetch.test-d.ts +31 -0
  27. package/src/client/internal/Fetch.test.ts +261 -0
  28. package/src/client/internal/Fetch.ts +467 -24
  29. package/src/middlewares/internal/mppx.ts +5 -6
  30. package/src/proxy/Proxy.test.ts +69 -0
  31. package/src/server/Mppx.test-d.ts +50 -0
  32. package/src/server/Mppx.test.ts +893 -1
  33. package/src/server/Mppx.ts +862 -97
  34. package/src/tempo/server/internal/html.gen.ts +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,w4keAAw4ke,CAAA"}
1
+ {"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,s+teAAs+te,CAAA"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mppx",
3
3
  "type": "module",
4
- "version": "0.6.20",
4
+ "version": "0.6.21",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "files": [
@@ -1,13 +1,17 @@
1
1
  import type { Account } from 'viem'
2
2
  import { describe, expectTypeOf, test } from 'vp/test'
3
3
 
4
+ import * as Challenge from '../Challenge.js'
5
+ import type * as Mcp from '../Mcp.js'
4
6
  import * as Method from '../Method.js'
5
7
  import { charge } from '../tempo/client/Charge.js'
6
8
  import { tempo } from '../tempo/client/Methods.js'
7
9
  import type * as AutoSwap from '../tempo/internal/auto-swap.js'
8
10
  import * as Methods from '../tempo/Methods.js'
9
11
  import * as z from '../zod.js'
12
+ import * as Fetch from './internal/Fetch.js'
10
13
  import * as Mppx from './Mppx.js'
14
+ import * as Transport from './Transport.js'
11
15
 
12
16
  describe('Mppx', () => {
13
17
  test('has methods array', () => {
@@ -63,6 +67,57 @@ describe('create.Config', () => {
63
67
 
64
68
  expectTypeOf(mppx.fetch).toBeFunction()
65
69
  })
70
+
71
+ test('client events expose typed payloads', () => {
72
+ const method = charge()
73
+ const mppx = Mppx.create({
74
+ methods: [method],
75
+ })
76
+
77
+ const unsubscribe = mppx.on('payment.response', (payload) => {
78
+ expectTypeOf(payload.response).toEqualTypeOf<Response>()
79
+ })
80
+ expectTypeOf(unsubscribe).toEqualTypeOf<Fetch.Unsubscribe>()
81
+
82
+ mppx.on('*', (event) => {
83
+ if (event.name === 'credential.created')
84
+ expectTypeOf(event.payload.credential).toEqualTypeOf<string>()
85
+ if (event.name === 'payment.response')
86
+ expectTypeOf(event.payload.response).toEqualTypeOf<Response>()
87
+ })
88
+ mppx.onChallengeReceived((payload) => {
89
+ expectTypeOf(payload.challenge.id).toEqualTypeOf<string>()
90
+ expectTypeOf(payload.challenges).toEqualTypeOf<readonly Challenge.Challenge[]>()
91
+ expectTypeOf(payload.method.intent).toEqualTypeOf<'charge'>()
92
+ expectTypeOf(payload.createCredential({ account: {} as Account })).toEqualTypeOf<
93
+ Promise<string>
94
+ >()
95
+ return payload.createCredential({ account: {} as Account })
96
+ })
97
+ mppx.onCredentialCreated((payload) => {
98
+ expectTypeOf(payload.credential).toEqualTypeOf<string>()
99
+ expectTypeOf(payload.method.intent).toEqualTypeOf<'charge'>()
100
+ })
101
+ mppx.onPaymentFailed((payload) => {
102
+ expectTypeOf(payload.error).toEqualTypeOf<unknown>()
103
+ expectTypeOf(payload.challenge).toEqualTypeOf<Challenge.Challenge | undefined>()
104
+ })
105
+ mppx.onPaymentResponse((payload) => {
106
+ expectTypeOf(payload.response).toEqualTypeOf<Response>()
107
+ expectTypeOf(payload.credential).toEqualTypeOf<string>()
108
+ })
109
+ })
110
+
111
+ test('client events use transport response types', () => {
112
+ const mppx = Mppx.create({
113
+ methods: [tempo({ account: {} as Account })],
114
+ transport: Transport.mcp(),
115
+ })
116
+
117
+ mppx.onChallengeReceived((payload) => {
118
+ expectTypeOf(payload.response).toMatchTypeOf<Response | Mcp.Response>()
119
+ })
120
+ })
66
121
  })
67
122
 
68
123
  describe('Method.toClient', () => {
@@ -108,11 +108,143 @@ describe('createCredential', () => {
108
108
  expect(parsed.challenge.method).toBe('tempo')
109
109
  })
110
110
 
111
+ test('behavior: createCredential emits client events and supports runtime handlers', async () => {
112
+ const events: string[] = []
113
+ const createCredential = vi.fn(async ({ challenge }) =>
114
+ Credential.serialize({
115
+ challenge,
116
+ payload: { signature: '0xsignature', type: 'transaction' },
117
+ }),
118
+ )
119
+ const method = Method.toClient(Methods.charge, { createCredential })
120
+ const mppx = Mppx.create({
121
+ polyfill: false,
122
+ methods: [method],
123
+ })
124
+ mppx.onCredentialCreated((payload) => {
125
+ events.push(`credential:${payload.credential.startsWith('Payment ')}`)
126
+ })
127
+ const offCredential = mppx.onCredentialCreated(() => {
128
+ events.push('removed')
129
+ })
130
+ const offChallenge = mppx.onChallengeReceived((payload) => {
131
+ events.push(`runtime:${payload.method.intent}`)
132
+ return payload.createCredential()
133
+ })
134
+ mppx.on('*', (event) => {
135
+ events.push(`*:${event.name}`)
136
+ })
137
+ offCredential()
138
+
139
+ const challenge = Challenge.fromMethod(Methods.charge, {
140
+ realm,
141
+ secretKey,
142
+ expires: new Date(Date.now() + 60_000).toISOString(),
143
+ request: {
144
+ amount: '1000',
145
+ currency: '0x1234567890123456789012345678901234567890',
146
+ decimals: 6,
147
+ recipient: '0x1234567890123456789012345678901234567890',
148
+ },
149
+ })
150
+ const response = new Response(null, {
151
+ status: 402,
152
+ headers: {
153
+ 'WWW-Authenticate': Challenge.serialize(challenge),
154
+ },
155
+ })
156
+
157
+ const credential = await mppx.createCredential(response)
158
+ offChallenge()
159
+
160
+ expect(credential).toMatch(/^Payment /)
161
+ expect(createCredential).toHaveBeenCalledTimes(1)
162
+ expect(events).toEqual([
163
+ 'runtime:charge',
164
+ '*:challenge.received',
165
+ 'credential:true',
166
+ '*:credential.created',
167
+ ])
168
+ })
169
+
170
+ test('behavior: createCredential memoizes event helper calls', async () => {
171
+ const createCredential = vi.fn(async ({ challenge }) =>
172
+ Credential.serialize({
173
+ challenge,
174
+ payload: { signature: '0xsignature', type: 'transaction' },
175
+ }),
176
+ )
177
+ const method = Method.toClient(Methods.charge, { createCredential })
178
+ const mppx = Mppx.create({
179
+ polyfill: false,
180
+ methods: [method],
181
+ })
182
+ mppx.on('*', async (event) => {
183
+ if (event.name === 'challenge.received') await event.payload.createCredential()
184
+ })
185
+
186
+ const challenge = Challenge.fromMethod(Methods.charge, {
187
+ realm,
188
+ secretKey,
189
+ expires: new Date(Date.now() + 60_000).toISOString(),
190
+ request: {
191
+ amount: '1000',
192
+ currency: '0x1234567890123456789012345678901234567890',
193
+ decimals: 6,
194
+ recipient: '0x1234567890123456789012345678901234567890',
195
+ },
196
+ })
197
+ const response = new Response(null, {
198
+ status: 402,
199
+ headers: {
200
+ 'WWW-Authenticate': Challenge.serialize(challenge),
201
+ },
202
+ })
203
+
204
+ await mppx.createCredential(response)
205
+
206
+ expect(createCredential).toHaveBeenCalledTimes(1)
207
+ })
208
+
209
+ test('behavior: createCredential validates event credentials', async () => {
210
+ const mppx = Mppx.create({
211
+ polyfill: false,
212
+ methods: [tempo({ account: accounts[1], getClient: () => client })],
213
+ })
214
+ mppx.onChallengeReceived(() => 'Payment invalid\r\nX-Injected: true')
215
+
216
+ const challenge = Challenge.fromMethod(Methods.charge, {
217
+ realm,
218
+ secretKey,
219
+ expires: new Date(Date.now() + 60_000).toISOString(),
220
+ request: {
221
+ amount: '1000',
222
+ currency: '0x1234567890123456789012345678901234567890',
223
+ decimals: 6,
224
+ recipient: '0x1234567890123456789012345678901234567890',
225
+ },
226
+ })
227
+ const response = new Response(null, {
228
+ status: 402,
229
+ headers: {
230
+ 'WWW-Authenticate': Challenge.serialize(challenge),
231
+ },
232
+ })
233
+
234
+ await expect(mppx.createCredential(response)).rejects.toThrow('illegal newline')
235
+ })
236
+
111
237
  test('behavior: throws when method not found', async () => {
238
+ const events: string[] = []
112
239
  const mppx = Mppx.create({
113
240
  polyfill: false,
114
241
  methods: [tempo({ account: accounts[1], getClient: () => client })],
115
242
  })
243
+ mppx.onPaymentFailed((payload) => {
244
+ events.push(
245
+ `failed:${payload.challenge === undefined}:${payload.challenges?.length}:${payload.error instanceof Error}`,
246
+ )
247
+ })
116
248
 
117
249
  const challenge = Challenge.from({
118
250
  id: 'test-id',
@@ -132,6 +264,7 @@ describe('createCredential', () => {
132
264
  await expect(mppx.createCredential(response)).rejects.toThrow(
133
265
  'No method found for challenges: unknown.charge. Available: tempo.charge, tempo.session',
134
266
  )
267
+ expect(events).toEqual(['failed:true:1:true'])
135
268
  })
136
269
 
137
270
  test('behavior: rejects expired challenges before creating credential', async () => {
@@ -451,6 +584,54 @@ describe('createCredential', () => {
451
584
  expect((parsed.payload as { type: string }).type).toBe('transaction')
452
585
  expect(parsed.challenge.method).toBe('tempo')
453
586
  })
587
+
588
+ test('behavior: mcp transport event responses are not cast to DOM Response', async () => {
589
+ const method = Method.toClient(Methods.charge, {
590
+ async createCredential({ challenge }) {
591
+ return Credential.serialize({
592
+ challenge,
593
+ payload: { signature: '0xsignature', type: 'transaction' },
594
+ })
595
+ },
596
+ })
597
+ const mppx = Mppx.create({
598
+ polyfill: false,
599
+ methods: [method],
600
+ transport: Transport.mcp(),
601
+ })
602
+
603
+ const challenge = Challenge.fromMethod(Methods.charge, {
604
+ realm,
605
+ secretKey,
606
+ expires: new Date(Date.now() + 60_000).toISOString(),
607
+ request: {
608
+ amount: '1000',
609
+ currency: '0x1234567890123456789012345678901234567890',
610
+ decimals: 6,
611
+ recipient: '0x1234567890123456789012345678901234567890',
612
+ },
613
+ })
614
+ const mcpResponse: Mcp.Response = {
615
+ jsonrpc: '2.0',
616
+ id: 1,
617
+ error: {
618
+ code: Mcp.paymentRequiredCode,
619
+ message: 'Payment Required',
620
+ data: {
621
+ httpStatus: 402,
622
+ challenges: [challenge],
623
+ },
624
+ },
625
+ }
626
+ const seen: unknown[] = []
627
+ mppx.onChallengeReceived((event) => {
628
+ seen.push(event.response)
629
+ })
630
+
631
+ await mppx.createCredential(mcpResponse)
632
+
633
+ expect(seen).toEqual([mcpResponse])
634
+ })
454
635
  })
455
636
 
456
637
  const server = Mppx_server.create({
@@ -7,6 +7,9 @@ import * as Fetch from './internal/Fetch.js'
7
7
  import * as Transport from './Transport.js'
8
8
 
9
9
  export type Methods = readonly (Method.AnyClient | readonly Method.AnyClient[])[]
10
+ type EventResponseOf<transport extends Transport.Transport> =
11
+ | Response
12
+ | Transport.ResponseOf<transport>
10
13
 
11
14
  /**
12
15
  * Client-side payment handler.
@@ -29,6 +32,43 @@ export type Mppx<
29
32
  context?: AnyContextFor<FlattenMethods<methods>> | undefined,
30
33
  options?: createCredential.Options | undefined,
31
34
  ) => Promise<string>
35
+ /** Register a client event handler by canonical event name. */
36
+ on<name extends Fetch.ClientEventName<FlattenMethods<methods>, EventResponseOf<transport>>>(
37
+ name: name,
38
+ handler: Fetch.ClientEventHandler<FlattenMethods<methods>, name, EventResponseOf<transport>>,
39
+ ): Fetch.Unsubscribe
40
+ /** Register a handler for received payment challenges. */
41
+ onChallengeReceived(
42
+ handler: Fetch.ClientEventHandler<
43
+ FlattenMethods<methods>,
44
+ 'challenge.received',
45
+ EventResponseOf<transport>
46
+ >,
47
+ ): Fetch.Unsubscribe
48
+ /** Register a handler for created credentials. */
49
+ onCredentialCreated(
50
+ handler: Fetch.ClientEventHandler<
51
+ FlattenMethods<methods>,
52
+ 'credential.created',
53
+ EventResponseOf<transport>
54
+ >,
55
+ ): Fetch.Unsubscribe
56
+ /** Register a handler for failed automatic payment handling. */
57
+ onPaymentFailed(
58
+ handler: Fetch.ClientEventHandler<
59
+ FlattenMethods<methods>,
60
+ 'payment.failed',
61
+ EventResponseOf<transport>
62
+ >,
63
+ ): Fetch.Unsubscribe
64
+ /** Register a handler for payment retry responses. */
65
+ onPaymentResponse(
66
+ handler: Fetch.ClientEventHandler<
67
+ FlattenMethods<methods>,
68
+ 'payment.response',
69
+ EventResponseOf<transport>
70
+ >,
71
+ ): Fetch.Unsubscribe
32
72
  }
33
73
 
34
74
  /**
@@ -71,6 +111,7 @@ export function create<
71
111
  const rawFetch = config.fetch ?? globalThis.fetch
72
112
  const methods = config.methods.flat() as unknown as FlattenMethods<methods>
73
113
  const acceptPayment = AcceptPayment.resolve(methods, config.paymentPreferences)
114
+ const events = Fetch.createEventDispatcher<FlattenMethods<methods>, EventResponseOf<transport>>()
74
115
 
75
116
  const resolvedOnChallenge = onChallenge as Fetch.from.Config<
76
117
  FlattenMethods<methods>
@@ -79,17 +120,64 @@ export function create<
79
120
  acceptPayment,
80
121
  acceptPaymentPolicy,
81
122
  ...(config.fetch && { fetch: config.fetch }),
123
+ eventDispatcher: events,
82
124
  ...(resolvedOnChallenge && { onChallenge: resolvedOnChallenge }),
83
125
  methods,
84
126
  } satisfies Fetch.from.Config<FlattenMethods<methods>>
85
127
  const fetch = Fetch.from<FlattenMethods<methods>>(config_fetch)
86
128
 
87
129
  if (polyfill) Fetch.polyfill(config_fetch)
130
+
131
+ function onChallengeReceived(
132
+ handler: Fetch.ClientEventHandler<
133
+ FlattenMethods<methods>,
134
+ 'challenge.received',
135
+ EventResponseOf<transport>
136
+ >,
137
+ ) {
138
+ return events.on('challenge.received', handler)
139
+ }
140
+
141
+ function onCredentialCreated(
142
+ handler: Fetch.ClientEventHandler<
143
+ FlattenMethods<methods>,
144
+ 'credential.created',
145
+ EventResponseOf<transport>
146
+ >,
147
+ ) {
148
+ return events.on('credential.created', handler)
149
+ }
150
+
151
+ function onPaymentFailed(
152
+ handler: Fetch.ClientEventHandler<
153
+ FlattenMethods<methods>,
154
+ 'payment.failed',
155
+ EventResponseOf<transport>
156
+ >,
157
+ ) {
158
+ return events.on('payment.failed', handler)
159
+ }
160
+
161
+ function onPaymentResponse(
162
+ handler: Fetch.ClientEventHandler<
163
+ FlattenMethods<methods>,
164
+ 'payment.response',
165
+ EventResponseOf<transport>
166
+ >,
167
+ ) {
168
+ return events.on('payment.response', handler)
169
+ }
170
+
88
171
  return {
89
172
  fetch,
90
173
  rawFetch,
91
174
  methods,
92
175
  transport,
176
+ on: events.on,
177
+ onChallengeReceived,
178
+ onCredentialCreated,
179
+ onPaymentFailed,
180
+ onPaymentResponse,
93
181
  async createCredential(
94
182
  response: Transport.ResponseOf<transport>,
95
183
  context?: unknown,
@@ -100,23 +188,59 @@ export function create<
100
188
  : [transport.getChallenge(response as never)]
101
189
  const preferences = resolveChallengePreferences(acceptPayment.entries, options?.acceptPayment)
102
190
 
103
- const selected = AcceptPayment.selectChallenge(challenges, methods, preferences)
104
- if (!selected)
105
- throw new Error(
106
- `No method found for challenges: ${challenges.map((challenge) => `${challenge.method}.${challenge.intent}`).join(', ')}. Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`,
107
- )
191
+ let challenge: Challenge.Challenge | undefined
192
+ let mi: FlattenMethods<methods>[number] | undefined
193
+ try {
194
+ const selected = AcceptPayment.selectChallenge(challenges, methods, preferences)
195
+ if (!selected)
196
+ throw new Error(
197
+ `No method found for challenges: ${challenges.map((challenge) => `${challenge.method}.${challenge.intent}`).join(', ')}. Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`,
198
+ )
108
199
 
109
- const { challenge, method: mi } = selected
110
- if (challenge.expires) Expires.assert(challenge.expires, challenge.id)
200
+ const selectedChallenge = selected.challenge
201
+ challenge = selectedChallenge
202
+ mi = selected.method as FlattenMethods<methods>[number]
203
+ if (challenge.expires) Expires.assert(challenge.expires, challenge.id)
111
204
 
112
- const parsedContext =
113
- mi.context && context !== undefined ? mi.context.parse(context) : undefined
114
-
115
- return mi.createCredential(
116
- parsedContext !== undefined
117
- ? { challenge, context: parsedContext }
118
- : ({ challenge } as never),
119
- )
205
+ const createCredential = memoizeCreateCredential(
206
+ (overrideContext?: AnyContextFor<FlattenMethods<methods>>) =>
207
+ createCredentialForMethod(selectedChallenge, mi!, overrideContext ?? context),
208
+ )
209
+ const eventCredential = await events.emit(
210
+ 'challenge.received',
211
+ createChallengeReceivedPayload({
212
+ challenge: selectedChallenge,
213
+ challenges,
214
+ createCredential,
215
+ method: mi,
216
+ response,
217
+ }),
218
+ )
219
+ const credential = eventCredential ?? (await createCredential())
220
+ Fetch.validateCredentialHeaderValue(credential)
221
+ await events.emit(
222
+ 'credential.created',
223
+ createCredentialCreatedPayload({
224
+ challenge: selectedChallenge,
225
+ credential,
226
+ method: mi,
227
+ response,
228
+ }),
229
+ )
230
+ return credential
231
+ } catch (error) {
232
+ await events.emit(
233
+ 'payment.failed',
234
+ createPaymentFailedPayload({
235
+ challenge,
236
+ challenges,
237
+ error,
238
+ method: mi,
239
+ response,
240
+ }),
241
+ )
242
+ throw error
243
+ }
120
244
  },
121
245
  }
122
246
  }
@@ -155,7 +279,7 @@ export declare namespace create {
155
279
  acceptPaymentPolicy?: Fetch.from.Config['acceptPaymentPolicy'] | undefined
156
280
  /** Custom fetch function to wrap. Defaults to `globalThis.fetch`. */
157
281
  fetch?: typeof globalThis.fetch
158
- /** Called when a 402 challenge is received, before credential creation. */
282
+ /** Called when a 402 challenge is received and no event handler supplies a credential. */
159
283
  onChallenge?:
160
284
  | ((
161
285
  challenge: Challenge.Challenge,
@@ -187,6 +311,103 @@ type AnyContextFor<methods extends readonly Method.AnyClient[]> = {
187
311
  : undefined
188
312
  }[number]
189
313
 
314
+ function memoizeCreateCredential<methods extends readonly Method.AnyClient[]>(
315
+ createCredential: (context?: AnyContextFor<methods>) => Promise<string>,
316
+ ) {
317
+ let promise: Promise<string> | undefined
318
+ return (context?: AnyContextFor<methods>) => {
319
+ promise ??= createCredential(context)
320
+ return promise
321
+ }
322
+ }
323
+
324
+ function createChallengeReceivedPayload<
325
+ methods extends readonly Method.AnyClient[],
326
+ response,
327
+ >(parameters: {
328
+ challenge: Challenge.Challenge
329
+ challenges: readonly Challenge.Challenge[]
330
+ createCredential: (context?: AnyContextFor<methods>) => Promise<string>
331
+ method: methods[number]
332
+ response: response
333
+ }): Fetch.ChallengeReceivedPayload<methods, response> {
334
+ return Object.freeze({
335
+ challenge: snapshotValue(parameters.challenge),
336
+ challenges: parameters.challenges.map((challenge) => snapshotValue(challenge)),
337
+ createCredential: parameters.createCredential,
338
+ method: snapshotMethod(parameters.method),
339
+ response: snapshotResponse(parameters.response),
340
+ }) as never
341
+ }
342
+
343
+ function createCredentialCreatedPayload<
344
+ methods extends readonly Method.AnyClient[],
345
+ response,
346
+ >(parameters: {
347
+ challenge: Challenge.Challenge
348
+ credential: string
349
+ method: methods[number]
350
+ response: response
351
+ }): Fetch.CredentialCreatedPayload<methods, response> {
352
+ return Object.freeze({
353
+ challenge: snapshotValue(parameters.challenge),
354
+ credential: parameters.credential,
355
+ method: snapshotMethod(parameters.method),
356
+ response: snapshotResponse(parameters.response),
357
+ }) as never
358
+ }
359
+
360
+ function createPaymentFailedPayload<
361
+ methods extends readonly Method.AnyClient[],
362
+ response,
363
+ >(parameters: {
364
+ challenge?: Challenge.Challenge | undefined
365
+ challenges?: readonly Challenge.Challenge[] | undefined
366
+ error: unknown
367
+ method?: methods[number] | undefined
368
+ response: response
369
+ }): Fetch.PaymentFailedPayload<methods, response> {
370
+ return Object.freeze({
371
+ ...(parameters.challenge ? { challenge: snapshotValue(parameters.challenge) } : {}),
372
+ ...(parameters.challenges
373
+ ? { challenges: parameters.challenges.map((challenge) => snapshotValue(challenge)) }
374
+ : {}),
375
+ error: parameters.error,
376
+ ...(parameters.method ? { method: snapshotMethod(parameters.method) } : {}),
377
+ response: snapshotResponse(parameters.response),
378
+ }) as never
379
+ }
380
+
381
+ function snapshotMethod<method extends Method.AnyClient>(method: method): method {
382
+ return freezeSnapshot(Object.assign({}, method)) as method
383
+ }
384
+
385
+ function snapshotResponse<response>(response: response): response {
386
+ if (response instanceof Response) return response.clone() as response
387
+ return snapshotValue(response)
388
+ }
389
+
390
+ function snapshotValue<value>(value: value): value {
391
+ try {
392
+ return deepFreeze(structuredClone(value))
393
+ } catch {
394
+ return freezeSnapshot(value)
395
+ }
396
+ }
397
+
398
+ function deepFreeze<value>(value: value): value {
399
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value
400
+ Object.freeze(value)
401
+ for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child)
402
+ return value
403
+ }
404
+
405
+ function freezeSnapshot<value>(value: value): value {
406
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value
407
+ Object.freeze(value)
408
+ return value
409
+ }
410
+
190
411
  /**
191
412
  * Flattens a methods config tuple, preserving positional types.
192
413
  * @internal
@@ -209,3 +430,14 @@ function resolveChallengePreferences(
209
430
  if (!override) return fallback
210
431
  return typeof override === 'string' ? AcceptPayment.parse(override) : override
211
432
  }
433
+
434
+ async function createCredentialForMethod(
435
+ challenge: Challenge.Challenge,
436
+ mi: Method.AnyClient,
437
+ context: unknown,
438
+ ): Promise<string> {
439
+ const parsedContext = mi.context && context !== undefined ? mi.context.parse(context) : undefined
440
+ return mi.createCredential(
441
+ parsedContext !== undefined ? { challenge, context: parsedContext } : ({ challenge } as never),
442
+ )
443
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Account } from 'viem'
2
2
  import { describe, expectTypeOf, test } from 'vp/test'
3
3
 
4
+ import * as Challenge from '../../Challenge.js'
4
5
  import { charge } from '../../tempo/client/Charge.js'
5
6
  import * as Fetch from './Fetch.js'
6
7
 
@@ -44,6 +45,36 @@ describe('Fetch.from', () => {
44
45
  body: JSON.stringify({ foo: 'bar' }),
45
46
  })
46
47
  })
48
+
49
+ test('behavior: events infer payload types from methods', () => {
50
+ const method = charge()
51
+ const dispatcher = Fetch.createEventDispatcher<[typeof method]>()
52
+ dispatcher.on('*', (event) => {
53
+ if (event.name === 'challenge.received')
54
+ expectTypeOf(event.payload.challenge).toEqualTypeOf<Challenge.Challenge>()
55
+ })
56
+ dispatcher.on('challenge.received', (payload) => {
57
+ expectTypeOf(payload.method.intent).toEqualTypeOf<'charge'>()
58
+ return payload.createCredential({ account: {} as Account })
59
+ })
60
+ dispatcher.on('credential.created', (payload) => {
61
+ expectTypeOf(payload.method.intent).toEqualTypeOf<'charge'>()
62
+ expectTypeOf(payload.credential).toEqualTypeOf<string>()
63
+ })
64
+ dispatcher.on('payment.failed', (payload) => {
65
+ expectTypeOf(payload.error).toEqualTypeOf<unknown>()
66
+ })
67
+ dispatcher.on('payment.response', (payload) => {
68
+ expectTypeOf(payload.response).toEqualTypeOf<Response>()
69
+ })
70
+
71
+ const fetch = Fetch.from({
72
+ eventDispatcher: dispatcher,
73
+ methods: [method],
74
+ })
75
+
76
+ expectTypeOf(fetch).toBeFunction()
77
+ })
47
78
  })
48
79
 
49
80
  describe('Fetch.from.RequestInit', () => {