mppx 0.3.4 → 0.3.6

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 (64) hide show
  1. package/README.md +0 -52
  2. package/dist/Challenge.d.ts +8 -0
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +20 -4
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/cli.js +193 -66
  7. package/dist/cli.js.map +1 -1
  8. package/dist/internal/types.d.ts +10 -0
  9. package/dist/internal/types.d.ts.map +1 -1
  10. package/dist/proxy/internal/Headers.d.ts +2 -0
  11. package/dist/proxy/internal/Headers.d.ts.map +1 -1
  12. package/dist/proxy/internal/Headers.js +2 -0
  13. package/dist/proxy/internal/Headers.js.map +1 -1
  14. package/dist/proxy/internal/Route.d.ts +4 -0
  15. package/dist/proxy/internal/Route.d.ts.map +1 -1
  16. package/dist/proxy/internal/Route.js +4 -0
  17. package/dist/proxy/internal/Route.js.map +1 -1
  18. package/dist/server/Mppx.d.ts +2 -0
  19. package/dist/server/Mppx.d.ts.map +1 -1
  20. package/dist/server/Mppx.js +4 -3
  21. package/dist/server/Mppx.js.map +1 -1
  22. package/dist/server/NodeListener.d.ts +6 -0
  23. package/dist/server/NodeListener.d.ts.map +1 -1
  24. package/dist/server/NodeListener.js +6 -0
  25. package/dist/server/NodeListener.js.map +1 -1
  26. package/dist/server/Response.d.ts +17 -0
  27. package/dist/server/Response.d.ts.map +1 -1
  28. package/dist/server/Response.js +17 -0
  29. package/dist/server/Response.js.map +1 -1
  30. package/dist/tempo/client/ChannelOps.js.map +1 -1
  31. package/dist/tempo/internal/defaults.d.ts +34 -8
  32. package/dist/tempo/internal/defaults.d.ts.map +1 -1
  33. package/dist/tempo/internal/defaults.js +30 -8
  34. package/dist/tempo/internal/defaults.js.map +1 -1
  35. package/dist/tempo/server/Charge.js +2 -2
  36. package/dist/tempo/server/Charge.js.map +1 -1
  37. package/dist/tempo/server/Session.d.ts.map +1 -1
  38. package/dist/tempo/server/Session.js +8 -3
  39. package/dist/tempo/server/Session.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/Challenge.test.ts +201 -11
  42. package/src/Challenge.ts +34 -4
  43. package/src/Store.test.ts +93 -0
  44. package/src/cli.test.ts +233 -37
  45. package/src/cli.ts +229 -79
  46. package/src/client/Transport.test.ts +4 -4
  47. package/src/internal/env.test.ts +42 -0
  48. package/src/internal/types.ts +11 -0
  49. package/src/proxy/internal/Headers.ts +2 -0
  50. package/src/proxy/internal/Route.ts +4 -0
  51. package/src/server/Mppx.test.ts +173 -0
  52. package/src/server/Mppx.ts +6 -3
  53. package/src/server/NodeListener.ts +6 -0
  54. package/src/server/Response.ts +17 -0
  55. package/src/server/Transport.test.ts +5 -5
  56. package/src/tempo/client/ChannelOps.ts +1 -1
  57. package/src/tempo/internal/defaults.test.ts +94 -0
  58. package/src/tempo/internal/defaults.ts +41 -8
  59. package/src/tempo/server/Charge.test.ts +150 -0
  60. package/src/tempo/server/Charge.ts +2 -2
  61. package/src/tempo/server/Session.test.ts +241 -1
  62. package/src/tempo/server/Session.ts +8 -3
  63. package/src/tempo/server/internal/transport.test.ts +285 -0
  64. package/src/tempo/session/Voucher.test.ts +46 -0
@@ -1,4 +1,5 @@
1
- import type { Challenge, z } from 'mppx'
1
+ import type { z } from 'mppx'
2
+ import { Challenge } from 'mppx'
2
3
  import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
3
4
  import { type Address, createClient, type Hex } from 'viem'
4
5
  import { Addresses } from 'viem/tempo'
@@ -1099,6 +1100,58 @@ describe('session', () => {
1099
1100
  } as any)
1100
1101
  expect(result).toBeUndefined()
1101
1102
  })
1103
+
1104
+ test('returns undefined for voucher POST with content-length > 0 (content request)', () => {
1105
+ const server = createServer()
1106
+ const result = server.respond!({
1107
+ credential: {
1108
+ challenge: makeChallenge({
1109
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
1110
+ }),
1111
+ payload: { action: 'voucher' },
1112
+ },
1113
+ input: new Request('http://localhost', {
1114
+ method: 'POST',
1115
+ headers: { 'content-length': '42' },
1116
+ }),
1117
+ } as any)
1118
+ expect(result).toBeUndefined()
1119
+ })
1120
+
1121
+ test('returns undefined for voucher POST with transfer-encoding header (content request)', () => {
1122
+ const server = createServer()
1123
+ const result = server.respond!({
1124
+ credential: {
1125
+ challenge: makeChallenge({
1126
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
1127
+ }),
1128
+ payload: { action: 'voucher' },
1129
+ },
1130
+ input: new Request('http://localhost', {
1131
+ method: 'POST',
1132
+ headers: { 'transfer-encoding': 'chunked' },
1133
+ }),
1134
+ } as any)
1135
+ expect(result).toBeUndefined()
1136
+ })
1137
+
1138
+ test('returns 204 for voucher POST with content-length: 0', () => {
1139
+ const server = createServer()
1140
+ const result = server.respond!({
1141
+ credential: {
1142
+ challenge: makeChallenge({
1143
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
1144
+ }),
1145
+ payload: { action: 'voucher' },
1146
+ },
1147
+ input: new Request('http://localhost', {
1148
+ method: 'POST',
1149
+ headers: { 'content-length': '0' },
1150
+ }),
1151
+ } as any)
1152
+ expect(result).toBeInstanceOf(Response)
1153
+ expect((result as Response).status).toBe(204)
1154
+ })
1102
1155
  })
1103
1156
 
1104
1157
  describe('SSE', () => {
@@ -1282,6 +1335,193 @@ describe('monotonicity and TOCTOU (unit tests)', () => {
1282
1335
  })
1283
1336
  })
1284
1337
 
1338
+ describe('session default currency resolution', () => {
1339
+ const mockClient = createClient({ transport: http('http://localhost:1') })
1340
+ const mockMainnetClient = createClient({
1341
+ chain: {
1342
+ id: 4217,
1343
+ name: 'Tempo',
1344
+ nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 },
1345
+ rpcUrls: { default: { http: ['http://localhost:1'] } },
1346
+ },
1347
+ transport: http('http://localhost:1'),
1348
+ })
1349
+ const mockTestnetClient = createClient({
1350
+ chain: {
1351
+ id: 42431,
1352
+ name: 'Tempo Testnet',
1353
+ nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 },
1354
+ rpcUrls: { default: { http: ['http://localhost:1'] } },
1355
+ },
1356
+ transport: http('http://localhost:1'),
1357
+ })
1358
+
1359
+ test('mainnet (default) resolves to USDC', () => {
1360
+ const server = session({
1361
+ store: Store.memory(),
1362
+ getClient: () => mockClient,
1363
+ account: '0x0000000000000000000000000000000000000001',
1364
+ escrowContract: '0x0000000000000000000000000000000000000002',
1365
+ } as session.Parameters)
1366
+ expect(server.defaults?.currency).toBe('0x20C000000000000000000000b9537d11c60E8b50')
1367
+ })
1368
+
1369
+ test('testnet: true defaults to pathUSD', () => {
1370
+ const server = session({
1371
+ store: Store.memory(),
1372
+ getClient: () => mockClient,
1373
+ account: '0x0000000000000000000000000000000000000001',
1374
+ escrowContract: '0x0000000000000000000000000000000000000002',
1375
+ testnet: true,
1376
+ } as session.Parameters)
1377
+ expect(server.defaults?.currency).toBe('0x20c0000000000000000000000000000000000000')
1378
+ })
1379
+
1380
+ test('unknown chain defaults to pathUSD', () => {
1381
+ const server = session({
1382
+ store: Store.memory(),
1383
+ getClient: () => mockClient,
1384
+ account: '0x0000000000000000000000000000000000000001',
1385
+ escrowContract: '0x0000000000000000000000000000000000000002',
1386
+ chainId: 69420,
1387
+ } as session.Parameters)
1388
+ expect(server.defaults?.currency).toBe('0x20c0000000000000000000000000000000000000')
1389
+ })
1390
+
1391
+ test('explicit currency overrides default', () => {
1392
+ const server = session({
1393
+ store: Store.memory(),
1394
+ getClient: () => mockClient,
1395
+ account: '0x0000000000000000000000000000000000000001',
1396
+ currency: '0xcustom',
1397
+ escrowContract: '0x0000000000000000000000000000000000000002',
1398
+ chainId: 4217,
1399
+ testnet: false,
1400
+ } as session.Parameters)
1401
+ expect(server.defaults?.currency).toBe('0xcustom')
1402
+ })
1403
+
1404
+ test('decimals defaults to 6', () => {
1405
+ const server = session({
1406
+ store: Store.memory(),
1407
+ getClient: () => mockClient,
1408
+ account: '0x0000000000000000000000000000000000000001',
1409
+ escrowContract: '0x0000000000000000000000000000000000000002',
1410
+ chainId: 42431,
1411
+ } as session.Parameters)
1412
+ expect(server.defaults?.decimals).toBe(6)
1413
+ })
1414
+
1415
+ test('challenge contains USDC currency (mainnet default)', async () => {
1416
+ const handler = Mppx_server.create({
1417
+ methods: [
1418
+ tempo_server.session({
1419
+ store: Store.memory(),
1420
+ getClient: () => mockMainnetClient,
1421
+ account: '0x0000000000000000000000000000000000000001',
1422
+ escrowContract: '0x0000000000000000000000000000000000000002',
1423
+ chainId: 4217,
1424
+ testnet: false,
1425
+ }),
1426
+ ],
1427
+ realm: 'api.example.com',
1428
+ secretKey: 'secret',
1429
+ })
1430
+
1431
+ const result = await (handler.session as Function)({
1432
+ amount: '1',
1433
+ decimals: 6,
1434
+ unitType: 'token',
1435
+ })(new Request('https://example.com'))
1436
+ expect(result.status).toBe(402)
1437
+
1438
+ const challenge = Challenge.fromResponse(result.challenge)
1439
+ expect(challenge.request.currency).toBe('0x20C000000000000000000000b9537d11c60E8b50')
1440
+ })
1441
+
1442
+ test('challenge contains pathUSD currency when testnet: true', async () => {
1443
+ const handler = Mppx_server.create({
1444
+ methods: [
1445
+ tempo_server.session({
1446
+ store: Store.memory(),
1447
+ getClient: () => mockTestnetClient,
1448
+ account: '0x0000000000000000000000000000000000000001',
1449
+ escrowContract: '0x0000000000000000000000000000000000000002',
1450
+ testnet: true,
1451
+ }),
1452
+ ],
1453
+ realm: 'api.example.com',
1454
+ secretKey: 'secret',
1455
+ })
1456
+
1457
+ const result = await (handler.session as Function)({
1458
+ amount: '1',
1459
+ decimals: 6,
1460
+ unitType: 'token',
1461
+ chainId: 42431,
1462
+ })(new Request('https://example.com'))
1463
+ expect(result.status).toBe(402)
1464
+
1465
+ const challenge = Challenge.fromResponse(result.challenge)
1466
+ expect(challenge.request.currency).toBe('0x20c0000000000000000000000000000000000000')
1467
+ })
1468
+
1469
+ test('challenge contains pathUSD currency (unknown chain)', async () => {
1470
+ const handler = Mppx_server.create({
1471
+ methods: [
1472
+ tempo_server.session({
1473
+ store: Store.memory(),
1474
+ getClient: () => mockTestnetClient,
1475
+ account: '0x0000000000000000000000000000000000000001',
1476
+ escrowContract: '0x0000000000000000000000000000000000000002',
1477
+ chainId: 69420,
1478
+ }),
1479
+ ],
1480
+ realm: 'api.example.com',
1481
+ secretKey: 'secret',
1482
+ })
1483
+
1484
+ const result = await (handler.session as Function)({
1485
+ amount: '1',
1486
+ decimals: 6,
1487
+ unitType: 'token',
1488
+ })(new Request('https://example.com'))
1489
+ expect(result.status).toBe(402)
1490
+
1491
+ const challenge = Challenge.fromResponse(result.challenge)
1492
+ expect(challenge.request.currency).toBe('0x20c0000000000000000000000000000000000000')
1493
+ })
1494
+
1495
+ test('explicit currency in challenge overrides testnet default', async () => {
1496
+ const handler = Mppx_server.create({
1497
+ methods: [
1498
+ tempo_server.session({
1499
+ store: Store.memory(),
1500
+ getClient: () => mockClient,
1501
+ account: '0x0000000000000000000000000000000000000001',
1502
+ currency: '0xcustom',
1503
+ escrowContract: '0x0000000000000000000000000000000000000002',
1504
+ chainId: 4217,
1505
+ testnet: false,
1506
+ }),
1507
+ ],
1508
+ realm: 'api.example.com',
1509
+ secretKey: 'secret',
1510
+ })
1511
+
1512
+ const result = await handler.session({
1513
+ amount: '1',
1514
+ decimals: 6,
1515
+ unitType: 'token',
1516
+ })(new Request('https://example.com'))
1517
+ expect(result.status).toBe(402)
1518
+ if (result.status !== 402) throw new Error()
1519
+
1520
+ const challenge = Challenge.fromResponse(result.challenge)
1521
+ expect(challenge.request.currency).toBe('0xcustom')
1522
+ })
1523
+ })
1524
+
1285
1525
  function nextSalt(): Hex {
1286
1526
  saltCounter++
1287
1527
  return `0x${saltCounter.toString(16).padStart(64, '0')}` as Hex
@@ -85,7 +85,7 @@ export function session<const parameters extends session.Parameters>(p?: paramet
85
85
  const parameters = p as parameters
86
86
  const {
87
87
  amount,
88
- currency,
88
+ currency = defaults.resolveCurrency(parameters),
89
89
  decimals = defaults.decimals,
90
90
  store: rawStore = Store.memory(),
91
91
  suggestedDeposit,
@@ -127,7 +127,7 @@ export function session<const parameters extends session.Parameters>(p?: paramet
127
127
  // Extract chainId from request or default.
128
128
  const chainId = await (async () => {
129
129
  if (request.chainId) return request.chainId
130
- if (parameters.testnet) return defaults.testnetChainId
130
+ if (parameters.testnet) return defaults.chainId.testnet
131
131
  return (await getClient({})).chain?.id
132
132
  })()
133
133
 
@@ -156,7 +156,12 @@ export function session<const parameters extends session.Parameters>(p?: paramet
156
156
  return undefined
157
157
  })()
158
158
 
159
- return { ...request, chainId, escrowContract: resolvedEscrow, feePayer: resolvedFeePayer }
159
+ return {
160
+ ...request,
161
+ chainId,
162
+ escrowContract: resolvedEscrow,
163
+ feePayer: resolvedFeePayer,
164
+ }
160
165
  },
161
166
 
162
167
  async verify({ credential }) {
@@ -0,0 +1,285 @@
1
+ import { Challenge, Credential } from 'mppx'
2
+ import type { Address, Hex } from 'viem'
3
+ import { describe, expect, test } from 'vitest'
4
+ import * as Store from '../../../Store.js'
5
+ import * as ChannelStore from '../../session/ChannelStore.js'
6
+ import { sse } from './transport.js'
7
+
8
+ const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex
9
+ const challengeId = 'challenge-1'
10
+
11
+ function memoryStore() {
12
+ return ChannelStore.fromStore(Store.memory())
13
+ }
14
+
15
+ function seedChannel(
16
+ storage: ChannelStore.ChannelStore,
17
+ balance: bigint,
18
+ ): Promise<ChannelStore.State | null> {
19
+ return storage.updateChannel(channelId, () => ({
20
+ channelId,
21
+ payer: '0x0000000000000000000000000000000000000001' as Address,
22
+ payee: '0x0000000000000000000000000000000000000002' as Address,
23
+ token: '0x0000000000000000000000000000000000000003' as Address,
24
+ authorizedSigner: '0x0000000000000000000000000000000000000004' as Address,
25
+ chainId: 42431,
26
+ escrowContract: '0x542831e3E4Ace07559b7C8787395f4Fb99F70787' as Address,
27
+ deposit: balance,
28
+ settledOnChain: 0n,
29
+ highestVoucherAmount: balance,
30
+ highestVoucher: null,
31
+ spent: 0n,
32
+ units: 0,
33
+ finalized: false,
34
+ createdAt: new Date().toISOString(),
35
+ }))
36
+ }
37
+
38
+ function makeChallenge() {
39
+ return Challenge.from({
40
+ id: challengeId,
41
+ realm: 'test.example.com',
42
+ method: 'tempo',
43
+ intent: 'session',
44
+ request: {
45
+ amount: '1000000',
46
+ currency: '0x20c0000000000000000000000000000000000001',
47
+ recipient: '0x0000000000000000000000000000000000000002',
48
+ },
49
+ })
50
+ }
51
+
52
+ function makeCredential() {
53
+ const challenge = makeChallenge()
54
+ return Credential.from({
55
+ challenge,
56
+ payload: {
57
+ action: 'voucher',
58
+ channelId,
59
+ cumulativeAmount: '1000000',
60
+ signature: '0xdeadbeef',
61
+ },
62
+ })
63
+ }
64
+
65
+ function makeAuthorizedRequest(): Request {
66
+ const credential = makeCredential()
67
+ const header = Credential.serialize(credential)
68
+ return new Request('https://test.example.com/session', {
69
+ headers: { Authorization: header },
70
+ })
71
+ }
72
+
73
+ function makeReceipt() {
74
+ return {
75
+ method: 'tempo',
76
+ status: 'success' as const,
77
+ timestamp: new Date().toISOString(),
78
+ reference: channelId,
79
+ }
80
+ }
81
+
82
+ describe('sse transport', () => {
83
+ test('getCredential returns null when no Authorization header', () => {
84
+ const store = memoryStore()
85
+ const transport = sse({ store })
86
+ const request = new Request('https://test.example.com/session')
87
+ expect(transport.getCredential(request)).toBeNull()
88
+ })
89
+
90
+ test('getCredential returns credential from Authorization header', () => {
91
+ const store = memoryStore()
92
+ const transport = sse({ store })
93
+ const request = makeAuthorizedRequest()
94
+ const credential = transport.getCredential(request)
95
+ expect(credential).not.toBeNull()
96
+ expect(credential!.challenge.id).toBe(challengeId)
97
+ expect((credential!.payload as any).channelId).toBe(channelId)
98
+ })
99
+
100
+ test('getCredential captures SSE context in contextMap', async () => {
101
+ const store = memoryStore()
102
+ await seedChannel(store, 10000000n)
103
+ const transport = sse({ store })
104
+
105
+ const request = makeAuthorizedRequest()
106
+ transport.getCredential(request)
107
+
108
+ async function* gen() {
109
+ yield 'test'
110
+ }
111
+
112
+ const response = transport.respondReceipt({
113
+ receipt: makeReceipt(),
114
+ response: gen(),
115
+ challengeId,
116
+ })
117
+ expect(response.headers.get('Content-Type')).toContain('text/event-stream')
118
+ })
119
+
120
+ test('respondChallenge delegates to base http transport', async () => {
121
+ const store = memoryStore()
122
+ const transport = sse({ store })
123
+ const challenge = makeChallenge()
124
+
125
+ const response = await transport.respondChallenge({
126
+ challenge,
127
+ input: new Request('https://test.example.com/session'),
128
+ })
129
+ expect(response).toBeInstanceOf(Response)
130
+ expect(response.status).toBe(402)
131
+ expect(response.headers.get('WWW-Authenticate')).toContain('Payment')
132
+ })
133
+
134
+ test('respondReceipt with AsyncIterable produces SSE response', async () => {
135
+ const store = memoryStore()
136
+ await seedChannel(store, 10000000n)
137
+ const transport = sse({ store })
138
+
139
+ transport.getCredential(makeAuthorizedRequest())
140
+
141
+ async function* gen() {
142
+ yield 'hello'
143
+ yield 'world'
144
+ }
145
+
146
+ const response = transport.respondReceipt({
147
+ receipt: makeReceipt(),
148
+ response: gen(),
149
+ challengeId,
150
+ })
151
+ expect(response.headers.get('Content-Type')).toContain('text/event-stream')
152
+ })
153
+
154
+ test('respondReceipt with AsyncGeneratorFunction passes stream controller', async () => {
155
+ const store = memoryStore()
156
+ await seedChannel(store, 10000000n)
157
+ const transport = sse({ store })
158
+
159
+ transport.getCredential(makeAuthorizedRequest())
160
+
161
+ const response = transport.respondReceipt({
162
+ receipt: makeReceipt(),
163
+ response: async function* (stream) {
164
+ await stream.charge()
165
+ yield 'hello'
166
+ },
167
+ challengeId,
168
+ })
169
+ expect(response.headers.get('Content-Type')).toContain('text/event-stream')
170
+ })
171
+
172
+ test('respondReceipt with upstream SSE Response auto-detects and iterates', async () => {
173
+ const store = memoryStore()
174
+ await seedChannel(store, 10000000n)
175
+ const transport = sse({ store })
176
+
177
+ transport.getCredential(makeAuthorizedRequest())
178
+
179
+ const encoder = new TextEncoder()
180
+ const upstream = new Response(
181
+ new ReadableStream({
182
+ start(controller) {
183
+ controller.enqueue(encoder.encode('event: message\ndata: chunk1\n\n'))
184
+ controller.enqueue(encoder.encode('event: message\ndata: chunk2\n\n'))
185
+ controller.close()
186
+ },
187
+ }),
188
+ { headers: { 'Content-Type': 'text/event-stream; charset=utf-8' } },
189
+ )
190
+
191
+ const response = transport.respondReceipt({
192
+ receipt: makeReceipt(),
193
+ response: upstream,
194
+ challengeId,
195
+ })
196
+ expect(response.headers.get('Content-Type')).toContain('text/event-stream')
197
+ })
198
+
199
+ test('respondReceipt with plain Response delegates to base http transport', () => {
200
+ const store = memoryStore()
201
+ const transport = sse({ store })
202
+ const receipt = makeReceipt()
203
+
204
+ const plainResponse = new Response('ok', {
205
+ headers: { 'Content-Type': 'application/json' },
206
+ })
207
+
208
+ const response = transport.respondReceipt({
209
+ receipt,
210
+ response: plainResponse,
211
+ challengeId,
212
+ })
213
+ expect(response).toBeInstanceOf(Response)
214
+ expect(response.headers.get('Payment-Receipt')).toBeTruthy()
215
+ })
216
+
217
+ test('respondReceipt cleans up contextMap after use', async () => {
218
+ const store = memoryStore()
219
+ await seedChannel(store, 10000000n)
220
+ const transport = sse({ store })
221
+
222
+ transport.getCredential(makeAuthorizedRequest())
223
+
224
+ async function* gen() {
225
+ yield 'first'
226
+ }
227
+
228
+ transport.respondReceipt({
229
+ receipt: makeReceipt(),
230
+ response: gen(),
231
+ challengeId,
232
+ })
233
+
234
+ async function* gen2() {
235
+ yield 'second'
236
+ }
237
+
238
+ expect(() =>
239
+ transport.respondReceipt({
240
+ receipt: makeReceipt(),
241
+ response: gen2(),
242
+ challengeId,
243
+ }),
244
+ ).toThrow('No SSE context available')
245
+ })
246
+
247
+ test('respondReceipt throws when no SSE context available', () => {
248
+ const store = memoryStore()
249
+ const transport = sse({ store })
250
+
251
+ async function* gen() {
252
+ yield 'hello'
253
+ }
254
+
255
+ expect(() =>
256
+ transport.respondReceipt({
257
+ receipt: makeReceipt(),
258
+ response: gen(),
259
+ challengeId,
260
+ }),
261
+ ).toThrow('No SSE context available')
262
+ })
263
+
264
+ test('poll: true strips waitForUpdate from store', async () => {
265
+ const store = memoryStore()
266
+ ;(store as any).waitForUpdate = async () => {}
267
+ await seedChannel(store, 10000000n)
268
+
269
+ const transport = sse({ store, poll: true })
270
+
271
+ transport.getCredential(makeAuthorizedRequest())
272
+
273
+ async function* gen() {
274
+ yield 'test'
275
+ }
276
+
277
+ const response = transport.respondReceipt({
278
+ receipt: makeReceipt(),
279
+ response: gen(),
280
+ challengeId,
281
+ })
282
+ expect(response.headers.get('Content-Type')).toContain('text/event-stream')
283
+ expect(transport.name).toBe('sse')
284
+ })
285
+ })
@@ -131,4 +131,50 @@ describe('Voucher', () => {
131
131
  expect(voucher.cumulativeAmount).toBe(5000000n)
132
132
  expect(voucher.signature).toBe(sig)
133
133
  })
134
+
135
+ test('parseVoucherFromPayload with zero amount', () => {
136
+ const sig =
137
+ '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab' as const
138
+ const voucher = parseVoucherFromPayload(channelId, '0', sig)
139
+ expect(voucher.cumulativeAmount).toBe(0n)
140
+ })
141
+
142
+ test('verifyVoucher rejects wrong escrow contract', async () => {
143
+ const signature = await signVoucher(
144
+ client,
145
+ account,
146
+ { channelId, cumulativeAmount },
147
+ escrowContract,
148
+ chainId,
149
+ )
150
+
151
+ const wrongEscrow = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as const
152
+ const isValid = await verifyVoucher(
153
+ wrongEscrow,
154
+ chainId,
155
+ { channelId, cumulativeAmount, signature },
156
+ account.address,
157
+ )
158
+ expect(isValid).toBe(false)
159
+ })
160
+
161
+ test('signVoucher and verifyVoucher round-trip with zero amount', async () => {
162
+ const zeroAmount = 0n
163
+ const signature = await signVoucher(
164
+ client,
165
+ account,
166
+ { channelId, cumulativeAmount: zeroAmount },
167
+ escrowContract,
168
+ chainId,
169
+ )
170
+ expect(signature).toMatch(/^0x/)
171
+
172
+ const isValid = await verifyVoucher(
173
+ escrowContract,
174
+ chainId,
175
+ { channelId, cumulativeAmount: zeroAmount, signature },
176
+ account.address,
177
+ )
178
+ expect(isValid).toBe(true)
179
+ })
134
180
  })