Skip to content

Commit d752fb5

Browse files
authored
feat: add client payment events (#441)
* feat: add client payment events * refactor: use rettime for client events * refactor: remove client event config * fix: harden client event dispatch
1 parent 85c7e38 commit d752fb5

7 files changed

Lines changed: 1248 additions & 40 deletions

File tree

.changeset/client-events.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'mppx': patch
3+
---
4+
5+
Added typed client payment events for challenge handling, credential creation, payment responses, and failures.

src/client/Mppx.test-d.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import type { Account } from 'viem'
22
import { describe, expectTypeOf, test } from 'vp/test'
33

4+
import * as Challenge from '../Challenge.js'
5+
import type * as Mcp from '../Mcp.js'
46
import * as Method from '../Method.js'
57
import { charge } from '../tempo/client/Charge.js'
68
import { tempo } from '../tempo/client/Methods.js'
79
import type * as AutoSwap from '../tempo/internal/auto-swap.js'
810
import * as Methods from '../tempo/Methods.js'
911
import * as z from '../zod.js'
12+
import * as Fetch from './internal/Fetch.js'
1013
import * as Mppx from './Mppx.js'
14+
import * as Transport from './Transport.js'
1115

1216
describe('Mppx', () => {
1317
test('has methods array', () => {
@@ -63,6 +67,57 @@ describe('create.Config', () => {
6367

6468
expectTypeOf(mppx.fetch).toBeFunction()
6569
})
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+
})
66121
})
67122

68123
describe('Method.toClient', () => {

src/client/Mppx.test.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,143 @@ describe('createCredential', () => {
108108
expect(parsed.challenge.method).toBe('tempo')
109109
})
110110

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+
111237
test('behavior: throws when method not found', async () => {
238+
const events: string[] = []
112239
const mppx = Mppx.create({
113240
polyfill: false,
114241
methods: [tempo({ account: accounts[1], getClient: () => client })],
115242
})
243+
mppx.onPaymentFailed((payload) => {
244+
events.push(
245+
`failed:${payload.challenge === undefined}:${payload.challenges?.length}:${payload.error instanceof Error}`,
246+
)
247+
})
116248

117249
const challenge = Challenge.from({
118250
id: 'test-id',
@@ -132,6 +264,7 @@ describe('createCredential', () => {
132264
await expect(mppx.createCredential(response)).rejects.toThrow(
133265
'No method found for challenges: unknown.charge. Available: tempo.charge, tempo.session',
134266
)
267+
expect(events).toEqual(['failed:true:1:true'])
135268
})
136269

137270
test('behavior: rejects expired challenges before creating credential', async () => {
@@ -451,6 +584,54 @@ describe('createCredential', () => {
451584
expect((parsed.payload as { type: string }).type).toBe('transaction')
452585
expect(parsed.challenge.method).toBe('tempo')
453586
})
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+
})
454635
})
455636

456637
const server = Mppx_server.create({

0 commit comments

Comments
 (0)