mppx 0.3.1 → 0.3.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.
@@ -0,0 +1,467 @@
1
+ import { type Address, createClient, type Hex, http } from 'viem'
2
+ import { privateKeyToAccount } from 'viem/accounts'
3
+ import { Addresses } from 'viem/tempo'
4
+ import { beforeAll, describe, expect, test } from 'vitest'
5
+ import { deployEscrow, openChannel } from '~test/tempo/stream.js'
6
+ import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js'
7
+ import * as Challenge from '../../Challenge.js'
8
+ import * as Credential from '../../Credential.js'
9
+ import type { StreamCredentialPayload } from '../stream/Types.js'
10
+ import { session } from './Session.js'
11
+
12
+ function deserializePayload(result: string) {
13
+ const cred = Credential.deserialize<StreamCredentialPayload>(result)
14
+ return cred
15
+ }
16
+
17
+ const pureAccount = privateKeyToAccount(
18
+ '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
19
+ )
20
+ const pureClient = createClient({
21
+ account: pureAccount,
22
+ transport: http('http://127.0.0.1'),
23
+ })
24
+
25
+ const escrowAddress = '0x542831e3E4Ace07559b7C8787395f4Fb99F70787' as Address
26
+ const recipient = '0x2222222222222222222222222222222222222222' as Address
27
+ const currency = '0x3333333333333333333333333333333333333333' as Address
28
+
29
+ function makeChallenge(overrides?: Record<string, unknown>) {
30
+ return Challenge.from({
31
+ id: 'test-challenge-id',
32
+ realm: 'test.com',
33
+ method: 'tempo',
34
+ intent: 'session',
35
+ request: {
36
+ amount: '1000000',
37
+ recipient,
38
+ currency,
39
+ unitType: 'token',
40
+ methodDetails: {
41
+ chainId: 42431,
42
+ escrowContract: escrowAddress,
43
+ },
44
+ ...overrides,
45
+ },
46
+ }) as any
47
+ }
48
+
49
+ describe('session (pure)', () => {
50
+ describe('error: no action and no deposit/maxDeposit', () => {
51
+ test('throws when neither configured', async () => {
52
+ const method = session({
53
+ getClient: () => pureClient,
54
+ account: pureAccount,
55
+ })
56
+
57
+ await expect(
58
+ method.createCredential({ challenge: makeChallenge(), context: {} }),
59
+ ).rejects.toThrow('No `action` in context and no `deposit` or `maxDeposit` configured')
60
+ })
61
+ })
62
+
63
+ describe('manual action validation', () => {
64
+ const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex
65
+
66
+ test('open requires transaction', async () => {
67
+ const method = session({ getClient: () => pureClient, account: pureAccount })
68
+
69
+ await expect(
70
+ method.createCredential({
71
+ challenge: makeChallenge(),
72
+ context: { action: 'open', channelId, cumulativeAmount: '1' },
73
+ }),
74
+ ).rejects.toThrow('transaction required for open action')
75
+ })
76
+
77
+ test('open requires cumulativeAmount', async () => {
78
+ const method = session({ getClient: () => pureClient, account: pureAccount })
79
+
80
+ await expect(
81
+ method.createCredential({
82
+ challenge: makeChallenge(),
83
+ context: { action: 'open', channelId, transaction: '0xabc' },
84
+ }),
85
+ ).rejects.toThrow('cumulativeAmount required for open action')
86
+ })
87
+
88
+ test('topUp requires transaction', async () => {
89
+ const method = session({ getClient: () => pureClient, account: pureAccount })
90
+
91
+ await expect(
92
+ method.createCredential({
93
+ challenge: makeChallenge(),
94
+ context: { action: 'topUp', channelId, additionalDeposit: '5' },
95
+ }),
96
+ ).rejects.toThrow('transaction required for topUp action')
97
+ })
98
+
99
+ test('topUp requires additionalDeposit', async () => {
100
+ const method = session({ getClient: () => pureClient, account: pureAccount })
101
+
102
+ await expect(
103
+ method.createCredential({
104
+ challenge: makeChallenge(),
105
+ context: { action: 'topUp', channelId, transaction: '0xabc' },
106
+ }),
107
+ ).rejects.toThrow('additionalDeposit required for topUp action')
108
+ })
109
+
110
+ test('voucher requires cumulativeAmount', async () => {
111
+ const method = session({ getClient: () => pureClient, account: pureAccount })
112
+
113
+ await expect(
114
+ method.createCredential({
115
+ challenge: makeChallenge(),
116
+ context: { action: 'voucher', channelId },
117
+ }),
118
+ ).rejects.toThrow('cumulativeAmount required for voucher action')
119
+ })
120
+
121
+ test('close requires cumulativeAmount', async () => {
122
+ const method = session({ getClient: () => pureClient, account: pureAccount })
123
+
124
+ await expect(
125
+ method.createCredential({
126
+ challenge: makeChallenge(),
127
+ context: { action: 'close', channelId },
128
+ }),
129
+ ).rejects.toThrow('cumulativeAmount required for close action')
130
+ })
131
+
132
+ test('manual voucher produces valid credential', async () => {
133
+ const method = session({ getClient: () => pureClient, account: pureAccount })
134
+
135
+ const result = await method.createCredential({
136
+ challenge: makeChallenge(),
137
+ context: { action: 'voucher', channelId, cumulativeAmount: '5' },
138
+ })
139
+
140
+ const cred = deserializePayload(result)
141
+ expect(cred.challenge.id).toBe('test-challenge-id')
142
+ expect(cred.challenge.realm).toBe('test.com')
143
+ expect(cred.challenge.method).toBe('tempo')
144
+ expect(cred.challenge.intent).toBe('session')
145
+ expect(cred.payload.action).toBe('voucher')
146
+ expect(cred.payload.channelId).toBe(channelId)
147
+ if (cred.payload.action === 'voucher') {
148
+ expect(cred.payload.cumulativeAmount).toBe('5000000')
149
+ expect(cred.payload.signature).toMatch(/^0x[0-9a-f]+$/)
150
+ }
151
+ expect(cred.source).toBe(`did:pkh:eip155:42431:${pureAccount.address}`)
152
+ })
153
+
154
+ test('manual open produces valid credential', async () => {
155
+ const method = session({ getClient: () => pureClient, account: pureAccount })
156
+
157
+ const result = await method.createCredential({
158
+ challenge: makeChallenge(),
159
+ context: {
160
+ action: 'open',
161
+ channelId,
162
+ cumulativeAmount: '5',
163
+ transaction: '0xdeadbeef',
164
+ },
165
+ })
166
+
167
+ const cred = deserializePayload(result)
168
+ expect(cred.challenge.id).toBe('test-challenge-id')
169
+ expect(cred.payload.action).toBe('open')
170
+ expect(cred.payload.channelId).toBe(channelId)
171
+ if (cred.payload.action === 'open') {
172
+ expect(cred.payload.type).toBe('transaction')
173
+ expect(cred.payload.transaction).toBe('0xdeadbeef')
174
+ expect(cred.payload.cumulativeAmount).toBe('5000000')
175
+ expect(cred.payload.signature).toMatch(/^0x[0-9a-f]+$/)
176
+ expect(cred.payload.authorizedSigner).toBe(pureAccount.address)
177
+ }
178
+ expect(cred.source).toBe(`did:pkh:eip155:42431:${pureAccount.address}`)
179
+ })
180
+
181
+ test('manual close produces valid credential', async () => {
182
+ const method = session({ getClient: () => pureClient, account: pureAccount })
183
+
184
+ const result = await method.createCredential({
185
+ challenge: makeChallenge(),
186
+ context: { action: 'close', channelId, cumulativeAmount: '5' },
187
+ })
188
+
189
+ const cred = deserializePayload(result)
190
+ expect(cred.challenge.id).toBe('test-challenge-id')
191
+ expect(cred.payload.action).toBe('close')
192
+ expect(cred.payload.channelId).toBe(channelId)
193
+ if (cred.payload.action === 'close') {
194
+ expect(cred.payload.cumulativeAmount).toBe('5000000')
195
+ expect(cred.payload.signature).toMatch(/^0x[0-9a-f]+$/)
196
+ }
197
+ expect(cred.source).toBe(`did:pkh:eip155:42431:${pureAccount.address}`)
198
+ })
199
+ })
200
+ })
201
+
202
+ describe('session (on-chain)', () => {
203
+ const payer = accounts[2]
204
+ const payee = accounts[1].address
205
+ let escrowContract: Address
206
+ let saltCounter = 0
207
+
208
+ function nextSalt(): Hex {
209
+ saltCounter++
210
+ return `0x${saltCounter.toString(16).padStart(64, '0')}` as Hex
211
+ }
212
+
213
+ function makeLiveChallenge(overrides?: Record<string, unknown>) {
214
+ return Challenge.from({
215
+ id: 'live-challenge',
216
+ realm: 'test.com',
217
+ method: 'tempo',
218
+ intent: 'session',
219
+ request: {
220
+ amount: '1000000',
221
+ recipient: payee,
222
+ currency: asset,
223
+ unitType: 'token',
224
+ methodDetails: {
225
+ chainId: chain.id,
226
+ escrowContract,
227
+ },
228
+ ...overrides,
229
+ },
230
+ }) as any
231
+ }
232
+
233
+ beforeAll(async () => {
234
+ escrowContract = await deployEscrow()
235
+ await fundAccount({ address: payer.address, token: Addresses.pathUsd })
236
+ await fundAccount({ address: payer.address, token: asset })
237
+ })
238
+
239
+ describe('auto deposit selection', () => {
240
+ test('context.depositRaw wins over everything', async () => {
241
+ const method = session({
242
+ getClient: () => client,
243
+ account: payer,
244
+ deposit: '99',
245
+ escrowContract,
246
+ })
247
+
248
+ const result = await method.createCredential({
249
+ challenge: makeLiveChallenge(),
250
+ context: { depositRaw: '5000000' },
251
+ })
252
+
253
+ const cred = deserializePayload(result)
254
+ expect(cred.payload.action).toBe('open')
255
+ expect(cred.payload.channelId).toMatch(/^0x[0-9a-f]{64}$/)
256
+ if (cred.payload.action === 'open') {
257
+ expect(cred.payload.type).toBe('transaction')
258
+ expect(cred.payload.cumulativeAmount).toBe('1000000')
259
+ expect(cred.payload.signature).toMatch(/^0x[0-9a-f]+$/)
260
+ }
261
+ expect(cred.source).toContain(`did:pkh:eip155:${chain.id}:`)
262
+ })
263
+
264
+ test('suggestedDeposit capped by maxDeposit', async () => {
265
+ const method = session({
266
+ getClient: () => client,
267
+ account: payer,
268
+ maxDeposit: '5',
269
+ escrowContract,
270
+ })
271
+
272
+ const challenge = makeLiveChallenge({ suggestedDeposit: '10000000' })
273
+
274
+ const result = await method.createCredential({ challenge, context: {} })
275
+
276
+ const cred = deserializePayload(result)
277
+ expect(cred.payload.action).toBe('open')
278
+ if (cred.payload.action === 'open') {
279
+ expect(cred.payload.type).toBe('transaction')
280
+ expect(cred.payload.cumulativeAmount).toBe('1000000')
281
+ }
282
+ expect(cred.source).toContain(`did:pkh:eip155:${chain.id}:`)
283
+ })
284
+
285
+ test('suggestedDeposit alone', async () => {
286
+ const method = session({
287
+ getClient: () => client,
288
+ account: payer,
289
+ deposit: '99',
290
+ escrowContract,
291
+ })
292
+
293
+ const challenge = makeLiveChallenge({ suggestedDeposit: '7000000' })
294
+
295
+ const result = await method.createCredential({ challenge, context: {} })
296
+
297
+ const cred = deserializePayload(result)
298
+ expect(cred.payload.action).toBe('open')
299
+ if (cred.payload.action === 'open') {
300
+ expect(cred.payload.cumulativeAmount).toBe('1000000')
301
+ }
302
+ })
303
+
304
+ test('maxDeposit alone', async () => {
305
+ const method = session({
306
+ getClient: () => client,
307
+ account: payer,
308
+ maxDeposit: '10',
309
+ escrowContract,
310
+ })
311
+
312
+ const result = await method.createCredential({
313
+ challenge: makeLiveChallenge(),
314
+ context: {},
315
+ })
316
+
317
+ const cred = deserializePayload(result)
318
+ expect(cred.payload.action).toBe('open')
319
+ if (cred.payload.action === 'open') {
320
+ expect(cred.payload.cumulativeAmount).toBe('1000000')
321
+ }
322
+ })
323
+
324
+ test('parameters.deposit as last resort', async () => {
325
+ const method = session({
326
+ getClient: () => client,
327
+ account: payer,
328
+ deposit: '10',
329
+ escrowContract,
330
+ })
331
+
332
+ const result = await method.createCredential({
333
+ challenge: makeLiveChallenge(),
334
+ context: {},
335
+ })
336
+
337
+ const cred = deserializePayload(result)
338
+ expect(cred.payload.action).toBe('open')
339
+ if (cred.payload.action === 'open') {
340
+ expect(cred.payload.cumulativeAmount).toBe('1000000')
341
+ }
342
+ })
343
+
344
+ test('throws when no deposit source available', async () => {
345
+ const method = session({
346
+ getClient: () => client,
347
+ account: payer,
348
+ escrowContract,
349
+ })
350
+
351
+ await expect(
352
+ method.createCredential({ challenge: makeLiveChallenge(), context: {} }),
353
+ ).rejects.toThrow()
354
+ })
355
+ })
356
+
357
+ describe('channel recovery', () => {
358
+ test('recovers channel via suggestedChannelId', async () => {
359
+ const salt = nextSalt()
360
+ const { channelId } = await openChannel({
361
+ escrow: escrowContract,
362
+ payer,
363
+ payee,
364
+ token: asset,
365
+ deposit: 10_000_000n,
366
+ salt,
367
+ })
368
+
369
+ const method = session({
370
+ getClient: () => client,
371
+ account: payer,
372
+ deposit: '10',
373
+ escrowContract,
374
+ })
375
+
376
+ const challenge = makeLiveChallenge({
377
+ methodDetails: {
378
+ chainId: chain.id,
379
+ escrowContract,
380
+ channelId,
381
+ },
382
+ })
383
+
384
+ const result = await method.createCredential({ challenge, context: {} })
385
+
386
+ const cred = deserializePayload(result)
387
+ expect(cred.payload.action).toBe('voucher')
388
+ if (cred.payload.action === 'voucher') {
389
+ expect(cred.payload.channelId).toBe(channelId)
390
+ expect(cred.payload.cumulativeAmount).toBe('1000000')
391
+ expect(cred.payload.signature).toMatch(/^0x[0-9a-f]+$/)
392
+ }
393
+ expect(cred.source).toContain(`did:pkh:eip155:${chain.id}:`)
394
+ })
395
+
396
+ test('throws when explicit channelId cannot be recovered', async () => {
397
+ const method = session({
398
+ getClient: () => client,
399
+ account: payer,
400
+ deposit: '10',
401
+ escrowContract,
402
+ })
403
+
404
+ await expect(
405
+ method.createCredential({
406
+ challenge: makeLiveChallenge(),
407
+ context: {
408
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000bad',
409
+ },
410
+ }),
411
+ ).rejects.toThrow('cannot be reused')
412
+ })
413
+ })
414
+
415
+ describe('cumulative tracking in auto mode', () => {
416
+ test('first call opens channel, second issues voucher with increased cumulative', async () => {
417
+ const updates: { cumulativeAmount: bigint }[] = []
418
+ const method = session({
419
+ getClient: () => client,
420
+ account: payer,
421
+ deposit: '10',
422
+ escrowContract,
423
+ onChannelUpdate: (entry) => updates.push({ cumulativeAmount: entry.cumulativeAmount }),
424
+ })
425
+
426
+ const challenge = makeLiveChallenge()
427
+
428
+ const first = await method.createCredential({ challenge, context: {} })
429
+ const firstCred = deserializePayload(first)
430
+ expect(firstCred.payload.action).toBe('open')
431
+ if (firstCred.payload.action === 'open') {
432
+ expect(firstCred.payload.type).toBe('transaction')
433
+ expect(firstCred.payload.cumulativeAmount).toBe('1000000')
434
+ }
435
+
436
+ const second = await method.createCredential({ challenge, context: {} })
437
+ const secondCred = deserializePayload(second)
438
+ expect(secondCred.payload.action).toBe('voucher')
439
+ if (secondCred.payload.action === 'voucher') {
440
+ expect(secondCred.payload.channelId).toBe(firstCred.payload.channelId)
441
+ expect(secondCred.payload.cumulativeAmount).toBe('2000000')
442
+ expect(secondCred.payload.signature).toMatch(/^0x[0-9a-f]+$/)
443
+ }
444
+
445
+ expect(updates.length).toBe(2)
446
+ expect(updates[0]!.cumulativeAmount).toBe(1_000_000n)
447
+ expect(updates[1]!.cumulativeAmount).toBe(2_000_000n)
448
+ })
449
+ })
450
+
451
+ describe('onChannelUpdate callback', () => {
452
+ test('fires on auto-manage open', async () => {
453
+ const updates: unknown[] = []
454
+ const method = session({
455
+ getClient: () => client,
456
+ account: payer,
457
+ deposit: '10',
458
+ escrowContract,
459
+ onChannelUpdate: (entry) => updates.push(entry),
460
+ })
461
+
462
+ await method.createCredential({ challenge: makeLiveChallenge(), context: {} })
463
+
464
+ expect(updates.length).toBeGreaterThan(0)
465
+ })
466
+ })
467
+ })