mppx 0.4.7 → 0.4.9

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 (209) hide show
  1. package/CHANGELOG.md +15 -3
  2. package/README.md +13 -13
  3. package/dist/BodyDigest.d.ts.map +1 -1
  4. package/dist/BodyDigest.js.map +1 -1
  5. package/dist/Challenge.d.ts.map +1 -1
  6. package/dist/Challenge.js.map +1 -1
  7. package/dist/Credential.d.ts.map +1 -1
  8. package/dist/Credential.js.map +1 -1
  9. package/dist/Errors.js +64 -67
  10. package/dist/Errors.js.map +1 -1
  11. package/dist/PaymentRequest.d.ts.map +1 -1
  12. package/dist/PaymentRequest.js.map +1 -1
  13. package/dist/Receipt.d.ts.map +1 -1
  14. package/dist/Receipt.js.map +1 -1
  15. package/dist/Store.d.ts +14 -4
  16. package/dist/Store.d.ts.map +1 -1
  17. package/dist/Store.js +17 -0
  18. package/dist/Store.js.map +1 -1
  19. package/dist/cli/account.d.ts.map +1 -1
  20. package/dist/cli/account.js +40 -5
  21. package/dist/cli/account.js.map +1 -1
  22. package/dist/cli/cli.d.ts.map +1 -1
  23. package/dist/cli/cli.js +24 -8
  24. package/dist/cli/cli.js.map +1 -1
  25. package/dist/cli/internal.d.ts.map +1 -1
  26. package/dist/cli/internal.js.map +1 -1
  27. package/dist/cli/plugins/stripe.d.ts.map +1 -1
  28. package/dist/cli/plugins/stripe.js.map +1 -1
  29. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  30. package/dist/cli/plugins/tempo.js +11 -23
  31. package/dist/cli/plugins/tempo.js.map +1 -1
  32. package/dist/cli/utils.d.ts.map +1 -1
  33. package/dist/cli/utils.js.map +1 -1
  34. package/dist/client/internal/Fetch.d.ts +2 -0
  35. package/dist/client/internal/Fetch.d.ts.map +1 -1
  36. package/dist/client/internal/Fetch.js +1 -1
  37. package/dist/client/internal/Fetch.js.map +1 -1
  38. package/dist/internal/types.d.ts.map +1 -1
  39. package/dist/mcp-sdk/client/McpClient.d.ts.map +1 -1
  40. package/dist/mcp-sdk/client/McpClient.js +1 -1
  41. package/dist/mcp-sdk/client/McpClient.js.map +1 -1
  42. package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
  43. package/dist/mcp-sdk/server/Transport.js.map +1 -1
  44. package/dist/middlewares/elysia.d.ts.map +1 -1
  45. package/dist/middlewares/elysia.js +5 -1
  46. package/dist/middlewares/elysia.js.map +1 -1
  47. package/dist/middlewares/express.d.ts.map +1 -1
  48. package/dist/middlewares/express.js +5 -2
  49. package/dist/middlewares/express.js.map +1 -1
  50. package/dist/middlewares/hono.d.ts.map +1 -1
  51. package/dist/middlewares/hono.js.map +1 -1
  52. package/dist/proxy/Proxy.d.ts.map +1 -1
  53. package/dist/proxy/Proxy.js +3 -1
  54. package/dist/proxy/Proxy.js.map +1 -1
  55. package/dist/proxy/Service.js +1 -1
  56. package/dist/proxy/Service.js.map +1 -1
  57. package/dist/proxy/internal/Route.d.ts +2 -2
  58. package/dist/proxy/internal/Route.d.ts.map +1 -1
  59. package/dist/proxy/internal/Route.js +4 -2
  60. package/dist/proxy/internal/Route.js.map +1 -1
  61. package/dist/server/Mppx.d.ts.map +1 -1
  62. package/dist/server/Mppx.js +47 -11
  63. package/dist/server/Mppx.js.map +1 -1
  64. package/dist/server/Request.d.ts.map +1 -1
  65. package/dist/server/Request.js.map +1 -1
  66. package/dist/stripe/Methods.d.ts.map +1 -1
  67. package/dist/stripe/Methods.js.map +1 -1
  68. package/dist/tempo/Methods.d.ts.map +1 -1
  69. package/dist/tempo/Methods.js.map +1 -1
  70. package/dist/tempo/client/ChannelOps.d.ts.map +1 -1
  71. package/dist/tempo/client/ChannelOps.js.map +1 -1
  72. package/dist/tempo/client/Charge.d.ts.map +1 -1
  73. package/dist/tempo/client/Charge.js.map +1 -1
  74. package/dist/tempo/client/Session.d.ts.map +1 -1
  75. package/dist/tempo/client/Session.js.map +1 -1
  76. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  77. package/dist/tempo/client/SessionManager.js +1 -1
  78. package/dist/tempo/client/SessionManager.js.map +1 -1
  79. package/dist/tempo/internal/address.d.ts +3 -0
  80. package/dist/tempo/internal/address.d.ts.map +1 -0
  81. package/dist/tempo/internal/address.js +4 -0
  82. package/dist/tempo/internal/address.js.map +1 -0
  83. package/dist/tempo/internal/auto-swap.d.ts.map +1 -1
  84. package/dist/tempo/internal/auto-swap.js +4 -4
  85. package/dist/tempo/internal/auto-swap.js.map +1 -1
  86. package/dist/tempo/internal/fee-payer.d.ts +4 -1
  87. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  88. package/dist/tempo/internal/fee-payer.js +12 -4
  89. package/dist/tempo/internal/fee-payer.js.map +1 -1
  90. package/dist/tempo/server/Charge.d.ts +11 -0
  91. package/dist/tempo/server/Charge.d.ts.map +1 -1
  92. package/dist/tempo/server/Charge.js +110 -51
  93. package/dist/tempo/server/Charge.js.map +1 -1
  94. package/dist/tempo/server/Session.d.ts +1 -1
  95. package/dist/tempo/server/Session.d.ts.map +1 -1
  96. package/dist/tempo/server/Session.js +31 -23
  97. package/dist/tempo/server/Session.js.map +1 -1
  98. package/dist/tempo/server/internal/transport.d.ts +1 -1
  99. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  100. package/dist/tempo/server/internal/transport.js +41 -1
  101. package/dist/tempo/server/internal/transport.js.map +1 -1
  102. package/dist/tempo/session/Chain.d.ts.map +1 -1
  103. package/dist/tempo/session/Chain.js +51 -10
  104. package/dist/tempo/session/Chain.js.map +1 -1
  105. package/dist/tempo/session/ChannelStore.d.ts +2 -0
  106. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  107. package/dist/tempo/session/ChannelStore.js +4 -2
  108. package/dist/tempo/session/ChannelStore.js.map +1 -1
  109. package/dist/tempo/session/Receipt.d.ts.map +1 -1
  110. package/dist/tempo/session/Receipt.js.map +1 -1
  111. package/dist/tempo/session/Sse.d.ts.map +1 -1
  112. package/dist/tempo/session/Sse.js.map +1 -1
  113. package/dist/tempo/session/Voucher.d.ts.map +1 -1
  114. package/dist/tempo/session/Voucher.js +3 -2
  115. package/dist/tempo/session/Voucher.js.map +1 -1
  116. package/dist/viem/Client.d.ts.map +1 -1
  117. package/dist/viem/Client.js.map +1 -1
  118. package/package.json +2 -2
  119. package/src/BodyDigest.ts +1 -0
  120. package/src/Challenge.test-d.ts +1 -0
  121. package/src/Challenge.ts +1 -0
  122. package/src/Credential.ts +1 -0
  123. package/src/Errors.test.ts +27 -39
  124. package/src/Expires.test.ts +1 -0
  125. package/src/PaymentRequest.ts +1 -0
  126. package/src/Receipt.ts +1 -0
  127. package/src/Store.test-d.ts +59 -0
  128. package/src/Store.test.ts +56 -6
  129. package/src/Store.ts +31 -4
  130. package/src/cli/account.ts +65 -30
  131. package/src/cli/cli.test.ts +127 -1
  132. package/src/cli/cli.ts +23 -8
  133. package/src/cli/config.test.ts +1 -0
  134. package/src/cli/internal.ts +1 -0
  135. package/src/cli/plugins/stripe.ts +1 -0
  136. package/src/cli/plugins/tempo.ts +21 -24
  137. package/src/cli/utils.ts +1 -0
  138. package/src/client/Mppx.test-d.ts +1 -0
  139. package/src/client/internal/Fetch.browser.test.ts +1 -0
  140. package/src/client/internal/Fetch.test-d.ts +1 -0
  141. package/src/client/internal/Fetch.test.ts +1 -0
  142. package/src/client/internal/Fetch.ts +1 -1
  143. package/src/internal/constantTimeEqual.test.ts +1 -0
  144. package/src/internal/types.ts +1 -3
  145. package/src/mcp-sdk/client/McpClient.test-d.ts +1 -0
  146. package/src/mcp-sdk/client/McpClient.test.ts +1 -0
  147. package/src/mcp-sdk/client/McpClient.ts +2 -0
  148. package/src/mcp-sdk/server/Transport.test.ts +1 -0
  149. package/src/mcp-sdk/server/Transport.ts +1 -0
  150. package/src/middlewares/elysia.test.ts +90 -0
  151. package/src/middlewares/elysia.ts +5 -1
  152. package/src/middlewares/express.test.ts +62 -2
  153. package/src/middlewares/express.ts +6 -2
  154. package/src/middlewares/hono.ts +1 -0
  155. package/src/middlewares/internal/mppx.test.ts +1 -0
  156. package/src/middlewares/nextjs.test.ts +1 -0
  157. package/src/proxy/Proxy.test.ts +57 -0
  158. package/src/proxy/Proxy.ts +8 -1
  159. package/src/proxy/Service.test.ts +1 -0
  160. package/src/proxy/Service.ts +8 -2
  161. package/src/proxy/internal/Headers.test.ts +1 -0
  162. package/src/proxy/internal/Route.test.ts +57 -0
  163. package/src/proxy/internal/Route.ts +3 -1
  164. package/src/proxy/services/openai.test.ts +1 -0
  165. package/src/server/Mppx.test.ts +438 -0
  166. package/src/server/Mppx.ts +51 -13
  167. package/src/server/Request.test.ts +1 -0
  168. package/src/server/Request.ts +1 -0
  169. package/src/server/Response.test.ts +1 -0
  170. package/src/server/Transport.test.ts +1 -0
  171. package/src/stripe/Methods.ts +1 -0
  172. package/src/stripe/client/Charge.test.ts +1 -0
  173. package/src/stripe/server/Charge.test.ts +1 -0
  174. package/src/tempo/Attribution.test.ts +1 -0
  175. package/src/tempo/Methods.ts +1 -0
  176. package/src/tempo/client/ChannelOps.test.ts +1 -0
  177. package/src/tempo/client/ChannelOps.ts +1 -0
  178. package/src/tempo/client/Charge.ts +1 -0
  179. package/src/tempo/client/Session.test.ts +1 -0
  180. package/src/tempo/client/Session.ts +1 -0
  181. package/src/tempo/client/SessionManager.test.ts +28 -0
  182. package/src/tempo/client/SessionManager.ts +2 -1
  183. package/src/tempo/internal/address.ts +6 -0
  184. package/src/tempo/internal/auto-swap.test.ts +1 -0
  185. package/src/tempo/internal/auto-swap.ts +4 -3
  186. package/src/tempo/internal/defaults.test.ts +1 -0
  187. package/src/tempo/internal/fee-payer.test.ts +1 -0
  188. package/src/tempo/internal/fee-payer.ts +19 -4
  189. package/src/tempo/server/Charge.test.ts +1081 -31
  190. package/src/tempo/server/Charge.ts +159 -63
  191. package/src/tempo/server/Session.test.ts +896 -107
  192. package/src/tempo/server/Session.ts +41 -23
  193. package/src/tempo/server/Sse.test.ts +2 -0
  194. package/src/tempo/server/internal/transport.test.ts +30 -0
  195. package/src/tempo/server/internal/transport.ts +41 -2
  196. package/src/tempo/session/Chain.test.ts +145 -0
  197. package/src/tempo/session/Chain.ts +59 -10
  198. package/src/tempo/session/Channel.test.ts +1 -0
  199. package/src/tempo/session/ChannelStore.test.ts +11 -0
  200. package/src/tempo/session/ChannelStore.ts +7 -3
  201. package/src/tempo/session/Receipt.test.ts +1 -0
  202. package/src/tempo/session/Receipt.ts +1 -0
  203. package/src/tempo/session/Sse.test.ts +2 -0
  204. package/src/tempo/session/Sse.ts +1 -0
  205. package/src/tempo/session/Voucher.test.ts +1 -0
  206. package/src/tempo/session/Voucher.ts +4 -2
  207. package/src/viem/Account.test.ts +1 -0
  208. package/src/viem/Client.test.ts +1 -0
  209. package/src/viem/Client.ts +1 -0
@@ -2,20 +2,24 @@ import type { z } from 'mppx'
2
2
  import { Challenge } from 'mppx'
3
3
  import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
4
4
  import { type Address, createClient, type Hex } from 'viem'
5
+ import { waitForTransactionReceipt } from 'viem/actions'
5
6
  import { Addresses } from 'viem/tempo'
6
7
  import { beforeAll, beforeEach, describe, expect, test } from 'vitest'
7
8
  import {
8
9
  deployEscrow,
10
+ requestCloseChannel,
9
11
  signOpenChannel,
10
12
  signTopUpChannel,
11
13
  topUpChannel,
12
14
  } from '~test/tempo/session.js'
13
15
  import { accounts, asset, chain, client, fundAccount, http } from '~test/tempo/viem.js'
16
+
14
17
  import {
15
18
  ChannelClosedError,
16
19
  ChannelNotFoundError,
17
20
  InsufficientBalanceError,
18
21
  InvalidSignatureError,
22
+ VerificationFailedError,
19
23
  } from '../../Errors.js'
20
24
  import * as Store from '../../Store.js'
21
25
  import {
@@ -305,6 +309,110 @@ describe('session', () => {
305
309
 
306
310
  expect(receipt.status).toBe('success')
307
311
  })
312
+
313
+ test('open after store loss uses on-chain settled for settledOnChain and spent', async () => {
314
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
315
+ const server = createServer()
316
+
317
+ // 1. Open channel and accept a voucher
318
+ await server.verify({
319
+ credential: {
320
+ challenge: makeChallenge({ id: 'open-1', channelId }),
321
+ payload: {
322
+ action: 'open' as const,
323
+ type: 'transaction' as const,
324
+ channelId,
325
+ transaction: serializedTransaction,
326
+ cumulativeAmount: '5000000',
327
+ signature: await signTestVoucher(channelId, 5000000n),
328
+ },
329
+ },
330
+ request: makeRequest(),
331
+ })
332
+
333
+ // 2. Settle on-chain so settled becomes 5000000
334
+ const settleTxHash = await settle(store, client, channelId, { escrowContract })
335
+ await waitForTransactionReceipt(client, { hash: settleTxHash })
336
+ expect((await store.getChannel(channelId))!.settledOnChain).toBe(5000000n)
337
+
338
+ // 3. Simulate store loss (non-persistent storage restart)
339
+ await store.updateChannel(channelId, () => null)
340
+ expect(await store.getChannel(channelId)).toBeNull()
341
+
342
+ // 4. Re-open with a new voucher above the settled amount
343
+ const server2 = createServer()
344
+ const receipt = await server2.verify({
345
+ credential: {
346
+ challenge: makeChallenge({ id: 'reopen', channelId }),
347
+ payload: {
348
+ action: 'open' as const,
349
+ type: 'transaction' as const,
350
+ channelId,
351
+ transaction: serializedTransaction,
352
+ cumulativeAmount: '7000000',
353
+ signature: await signTestVoucher(channelId, 7000000n),
354
+ },
355
+ },
356
+ request: makeRequest(),
357
+ })
358
+
359
+ expect(receipt.status).toBe('success')
360
+
361
+ const ch = await store.getChannel(channelId)
362
+ expect(ch).not.toBeNull()
363
+ // settledOnChain must reflect the on-chain value, not 0
364
+ expect(ch!.settledOnChain).toBe(5000000n)
365
+ // spent must equal settledOnChain so deductFromChannel only allows
366
+ // charging the unsettled portion (highestVoucher - spent = 7M - 5M = 2M)
367
+ expect(ch!.spent).toBe(5000000n)
368
+ expect(ch!.highestVoucherAmount).toBe(7000000n)
369
+ })
370
+
371
+ test('open after store loss reports correct spent in receipt', async () => {
372
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
373
+ const server = createServer()
374
+
375
+ // Open, settle, wipe store
376
+ await server.verify({
377
+ credential: {
378
+ challenge: makeChallenge({ id: 'open-1', channelId }),
379
+ payload: {
380
+ action: 'open' as const,
381
+ type: 'transaction' as const,
382
+ channelId,
383
+ transaction: serializedTransaction,
384
+ cumulativeAmount: '3000000',
385
+ signature: await signTestVoucher(channelId, 3000000n),
386
+ },
387
+ },
388
+ request: makeRequest(),
389
+ })
390
+
391
+ const settleTxHash = await settle(store, client, channelId, { escrowContract })
392
+ await waitForTransactionReceipt(client, { hash: settleTxHash })
393
+ await store.updateChannel(channelId, () => null)
394
+
395
+ // Re-open — receipt.spent must reflect unsettled portion
396
+ const server2 = createServer()
397
+ const receipt = (await server2.verify({
398
+ credential: {
399
+ challenge: makeChallenge({ id: 'reopen', channelId }),
400
+ payload: {
401
+ action: 'open' as const,
402
+ type: 'transaction' as const,
403
+ channelId,
404
+ transaction: serializedTransaction,
405
+ cumulativeAmount: '8000000',
406
+ signature: await signTestVoucher(channelId, 8000000n),
407
+ },
408
+ },
409
+ request: makeRequest(),
410
+ })) as SessionReceipt
411
+
412
+ // spent reflects on-chain settled (3M) so only unsettled portion is available
413
+ expect(receipt.spent).toBe('3000000')
414
+ expect(receipt.acceptedCumulative).toBe('8000000')
415
+ })
308
416
  })
309
417
 
310
418
  describe('voucher', () => {
@@ -353,26 +461,52 @@ describe('session', () => {
353
461
  expect(ch!.highestVoucherAmount).toBe(2000000n)
354
462
  })
355
463
 
356
- test('returns success for non-increasing voucher (idempotency)', async () => {
464
+ test('rejects non-increasing voucher replay', async () => {
357
465
  const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
358
466
  const server = createServer()
359
467
  await openServerChannel(server, channelId, serializedTransaction)
360
468
 
361
- const receipt = await server.verify({
362
- credential: {
363
- challenge: makeChallenge({ id: 'challenge-2', channelId }),
364
- payload: {
365
- action: 'voucher' as const,
366
- channelId,
367
- cumulativeAmount: '500000',
368
- signature: await signTestVoucher(channelId, 500000n),
469
+ await expect(
470
+ server.verify({
471
+ credential: {
472
+ challenge: makeChallenge({ id: 'challenge-2', channelId }),
473
+ payload: {
474
+ action: 'voucher' as const,
475
+ channelId,
476
+ cumulativeAmount: '500000',
477
+ signature: await signTestVoucher(channelId, 500000n),
478
+ },
369
479
  },
370
- },
371
- request: makeRequest(),
372
- })
480
+ request: makeRequest(),
481
+ }),
482
+ ).rejects.toThrow(
483
+ 'voucher cumulativeAmount must be strictly greater than highest accepted voucher',
484
+ )
485
+ })
373
486
 
374
- expect(receipt.status).toBe('success')
375
- expect((receipt as SessionReceipt).acceptedCumulative).toBe('1000000')
487
+ test('rejects replay of settled voucher', async () => {
488
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
489
+ const server = createServer()
490
+ await openServerChannel(server, channelId, serializedTransaction)
491
+
492
+ const leakedAmount = 1000000n
493
+ const leakedSignature = await signTestVoucher(channelId, leakedAmount)
494
+ await settle(store, client, channelId, { escrowContract })
495
+
496
+ await expect(
497
+ server.verify({
498
+ credential: {
499
+ challenge: makeChallenge({ id: 'challenge-replay', channelId }),
500
+ payload: {
501
+ action: 'voucher' as const,
502
+ channelId,
503
+ cumulativeAmount: leakedAmount.toString(),
504
+ signature: leakedSignature,
505
+ },
506
+ },
507
+ request: makeRequest(),
508
+ }),
509
+ ).rejects.toThrow('voucher cumulativeAmount is below on-chain settled amount')
376
510
  })
377
511
 
378
512
  test('rejects voucher exceeding deposit', async () => {
@@ -436,142 +570,410 @@ describe('session', () => {
436
570
  }),
437
571
  ).rejects.toThrow(ChannelNotFoundError)
438
572
  })
439
- })
440
573
 
441
- describe('topUp', () => {
442
- async function openServerChannel(
443
- server: ReturnType<typeof createServer>,
444
- channelId: Hex,
445
- serializedTransaction: Hex,
446
- ) {
447
- await server.verify({
448
- credential: {
449
- challenge: makeChallenge({ id: 'open-challenge', channelId }),
450
- payload: {
451
- action: 'open' as const,
452
- type: 'transaction' as const,
453
- channelId,
454
- transaction: serializedTransaction,
455
- cumulativeAmount: '1000000',
456
- signature: await signTestVoucher(channelId, 1000000n),
574
+ test('rejects stale voucher with invalid signature (hijack prevention)', async () => {
575
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
576
+ const server = createServer()
577
+ await openServerChannel(server, channelId, serializedTransaction)
578
+
579
+ await expect(
580
+ server.verify({
581
+ credential: {
582
+ challenge: makeChallenge({ id: 'hijack-attempt', channelId }),
583
+ payload: {
584
+ action: 'voucher' as const,
585
+ channelId,
586
+ // Attacker submits cumulativeAmount=500000, which is <= highestVoucherAmount (1000000)
587
+ // but > settled (0). Rejected by non-increasing cumulative amount check before signature validation.
588
+ cumulativeAmount: '500000',
589
+ signature: `0x${'ab'.repeat(65)}` as Hex,
590
+ },
457
591
  },
458
- },
459
- request: makeRequest(),
460
- })
461
- }
592
+ request: makeRequest(),
593
+ }),
594
+ ).rejects.toThrow(
595
+ 'voucher cumulativeAmount must be strictly greater than highest accepted voucher',
596
+ )
597
+ })
462
598
 
463
- test('accepts topUp with increased deposit', async () => {
599
+ test('rejects forged voucher with valid amount but invalid signature', async () => {
464
600
  const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
465
601
  const server = createServer()
466
602
  await openServerChannel(server, channelId, serializedTransaction)
467
603
 
468
- const { serializedTransaction: topUpTx } = await signTopUpChannel({
469
- escrow: escrowContract,
470
- payer,
604
+ await expect(
605
+ server.verify({
606
+ credential: {
607
+ challenge: makeChallenge({ id: 'forge-attempt', channelId }),
608
+ payload: {
609
+ action: 'voucher' as const,
610
+ channelId,
611
+ // Higher cumulativeAmount than the open voucher, but forged signature.
612
+ cumulativeAmount: '2000000',
613
+ signature: `0x${'cd'.repeat(65)}` as Hex,
614
+ },
615
+ },
616
+ request: makeRequest(),
617
+ }),
618
+ ).rejects.toThrow(InvalidSignatureError)
619
+ })
620
+
621
+ test('rejects exact replay of already-verified voucher (non-increasing)', async () => {
622
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
623
+ const server = createServer()
624
+ await openServerChannel(server, channelId, serializedTransaction)
625
+
626
+ const payload = {
627
+ action: 'voucher' as const,
471
628
  channelId,
472
- token: currency,
473
- amount: 10000000n,
474
- })
629
+ cumulativeAmount: '2000000',
630
+ signature: await signTestVoucher(channelId, 2000000n),
631
+ }
475
632
 
476
- const receipt = await server.verify({
633
+ await server.verify({
477
634
  credential: {
478
635
  challenge: makeChallenge({ id: 'challenge-2', channelId }),
479
- payload: {
480
- action: 'topUp' as const,
481
- type: 'transaction' as const,
482
- channelId,
483
- transaction: topUpTx,
484
- additionalDeposit: '10000000',
485
- },
636
+ payload,
486
637
  },
487
638
  request: makeRequest(),
488
639
  })
489
640
 
490
- expect(receipt.status).toBe('success')
491
-
492
- const ch = await store.getChannel(channelId)
493
- expect(ch!.deposit).toBe(20000000n)
641
+ await expect(
642
+ server.verify({
643
+ credential: {
644
+ challenge: makeChallenge({ id: 'challenge-3', channelId }),
645
+ payload,
646
+ },
647
+ request: makeRequest(),
648
+ }),
649
+ ).rejects.toThrow(VerificationFailedError)
494
650
  })
495
651
 
496
- test('topUp receipt preserves spent and units from prior charges', async () => {
652
+ test('rejects replayed voucher at settled amount after on-chain settlement', async () => {
497
653
  const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
498
654
  const server = createServer()
499
655
  await openServerChannel(server, channelId, serializedTransaction)
500
656
 
501
- await charge(store, channelId, 500000n)
502
- await charge(store, channelId, 300000n)
503
-
504
- const chBefore = await store.getChannel(channelId)
505
- expect(chBefore!.spent).toBe(800000n)
506
- expect(chBefore!.units).toBe(2)
507
-
508
- const { serializedTransaction: topUpTx } = await signTopUpChannel({
509
- escrow: escrowContract,
510
- payer,
511
- channelId,
512
- token: currency,
513
- amount: 5000000n,
514
- })
515
-
516
- const receipt = (await server.verify({
657
+ // Server accepts a higher voucher and then settles on-chain.
658
+ // settle() broadcasts the highestVoucher, leaking it on-chain.
659
+ const voucherSig = await signTestVoucher(channelId, 5000000n)
660
+ await server.verify({
517
661
  credential: {
518
- challenge: makeChallenge({ id: 'challenge-topup', channelId }),
662
+ challenge: makeChallenge({ id: 'challenge-2', channelId }),
519
663
  payload: {
520
- action: 'topUp' as const,
521
- type: 'transaction' as const,
664
+ action: 'voucher' as const,
522
665
  channelId,
523
- transaction: topUpTx,
524
- additionalDeposit: '5000000',
666
+ cumulativeAmount: '5000000',
667
+ signature: voucherSig,
525
668
  },
526
669
  },
527
670
  request: makeRequest(),
528
- })) as SessionReceipt
529
-
530
- expect(receipt.status).toBe('success')
531
- expect(receipt.spent).toBe('800000')
532
- expect(receipt.units).toBe(2)
533
-
534
- const chAfter = await store.getChannel(channelId)
535
- expect(chAfter!.spent).toBe(800000n)
536
- expect(chAfter!.units).toBe(2)
537
- expect(chAfter!.deposit).toBe(15000000n)
538
- })
671
+ })
539
672
 
540
- test('rejects topUp on unknown channel', async () => {
541
- const { channelId } = await createSignedOpenTransaction(10000000n)
542
- const server = createServer()
673
+ await settle(store, client, channelId, { escrowContract })
674
+ expect((await store.getChannel(channelId))!.settledOnChain).toBe(5000000n)
543
675
 
676
+ // Attacker replays the leaked voucher via the voucher action.
544
677
  await expect(
545
678
  server.verify({
546
679
  credential: {
547
- challenge: makeChallenge({ channelId }),
680
+ challenge: makeChallenge({ id: 'replay-attempt', channelId }),
548
681
  payload: {
549
- action: 'topUp' as const,
550
- type: 'transaction' as const,
682
+ action: 'voucher' as const,
551
683
  channelId,
552
- transaction: '0xabcdef' as Hex,
553
- additionalDeposit: '5000000',
684
+ cumulativeAmount: '5000000',
685
+ signature: voucherSig,
554
686
  },
555
687
  },
556
688
  request: makeRequest(),
557
689
  }),
558
- ).rejects.toThrow(ChannelNotFoundError)
690
+ ).rejects.toThrow('voucher cumulativeAmount is below on-chain settled amount')
559
691
  })
560
- })
561
692
 
562
- describe('close', () => {
563
- async function openServerChannel(
564
- server: ReturnType<typeof createServer>,
565
- channelId: Hex,
566
- serializedTransaction: Hex,
567
- ) {
693
+ test('rejects leaked voucher used in open action with mismatched channel', async () => {
694
+ // 1. Legitimate channel: open, send voucher, settle on-chain.
695
+ const { channelId: victimChannelId, serializedTransaction: victimTx } =
696
+ await createSignedOpenTransaction(10000000n)
697
+ const server = createServer()
698
+ await openServerChannel(server, victimChannelId, victimTx)
699
+
700
+ const leakedSig = await signTestVoucher(victimChannelId, 5000000n)
568
701
  await server.verify({
569
702
  credential: {
570
- challenge: makeChallenge({ id: 'open-challenge', channelId }),
703
+ challenge: makeChallenge({ id: 'c-victim', channelId: victimChannelId }),
571
704
  payload: {
572
- action: 'open' as const,
573
- type: 'transaction' as const,
574
- channelId,
705
+ action: 'voucher' as const,
706
+ channelId: victimChannelId,
707
+ cumulativeAmount: '5000000',
708
+ signature: leakedSig,
709
+ },
710
+ },
711
+ request: makeRequest(),
712
+ })
713
+
714
+ await settle(store, client, victimChannelId, { escrowContract })
715
+
716
+ // 2. Attacker creates a different open transaction (nominal channel
717
+ // from their own account) but claims channelId = victimChannelId.
718
+ // The tx broadcasts fine (opens attacker's channel), then the
719
+ // server fetches on-chain state for victimChannelId (the settled
720
+ // channel) and accepts the leaked voucher.
721
+ const attacker = accounts[3]
722
+ await fundAccount({ address: attacker.address, token: currency })
723
+ const { serializedTransaction: attackerTx } = await signOpenChannel({
724
+ escrow: escrowContract,
725
+ payer: attacker,
726
+ payee: recipient,
727
+ token: currency,
728
+ deposit: 1n, // nominal deposit
729
+ salt: nextSalt(),
730
+ })
731
+
732
+ await expect(
733
+ server.verify({
734
+ credential: {
735
+ challenge: makeChallenge({ id: 'c-attack', channelId: victimChannelId }),
736
+ payload: {
737
+ action: 'open' as const,
738
+ type: 'transaction' as const,
739
+ channelId: victimChannelId,
740
+ transaction: attackerTx,
741
+ cumulativeAmount: '5000000',
742
+ signature: leakedSig,
743
+ },
744
+ },
745
+ request: makeRequest(),
746
+ }),
747
+ ).rejects.toThrow('open transaction does not match claimed channelId')
748
+ })
749
+
750
+ test('rejects voucher when payer initiated force-close during cache TTL window', async () => {
751
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
752
+ // Use channelStateTtl: 0 to force on-chain reads on every voucher,
753
+ // ensuring the force-close is detected immediately.
754
+ const server = createServer({ channelStateTtl: 0 })
755
+ await openServerChannel(server, channelId, serializedTransaction)
756
+
757
+ // Accept a voucher to prime the cache (open already verified on-chain)
758
+ await server.verify({
759
+ credential: {
760
+ challenge: makeChallenge({ id: 'challenge-2', channelId }),
761
+ payload: {
762
+ action: 'voucher' as const,
763
+ channelId,
764
+ cumulativeAmount: '2000000',
765
+ signature: await signTestVoucher(channelId, 2000000n),
766
+ },
767
+ },
768
+ request: makeRequest(),
769
+ })
770
+
771
+ // Payer initiates a force-close on-chain (sets closeRequestedAt != 0)
772
+ await requestCloseChannel({ escrow: escrowContract, payer, channelId })
773
+
774
+ // Server submits another voucher within the cache TTL window.
775
+ // The cached state hardcodes closeRequestedAt: 0n, so the check
776
+ // in verifyAndAcceptVoucher never fires. This should throw
777
+ // ChannelClosedError but currently doesn't due to the stale cache.
778
+ await expect(
779
+ server.verify({
780
+ credential: {
781
+ challenge: makeChallenge({ id: 'challenge-3', channelId }),
782
+ payload: {
783
+ action: 'voucher' as const,
784
+ channelId,
785
+ cumulativeAmount: '3000000',
786
+ signature: await signTestVoucher(channelId, 3000000n),
787
+ },
788
+ },
789
+ request: makeRequest(),
790
+ }),
791
+ ).rejects.toThrow(ChannelClosedError)
792
+ })
793
+
794
+ test('rejects voucher when payer initiated force-close with cached state', async () => {
795
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
796
+ // Use channelStateTtl: 0 so every voucher triggers a stale re-query.
797
+ // This lets the first post-close voucher detect the force-close and
798
+ // persist closeRequestedAt to the store.
799
+ const server = createServer({ channelStateTtl: 0 })
800
+ await openServerChannel(server, channelId, serializedTransaction)
801
+
802
+ // Payer initiates a force-close on-chain
803
+ await requestCloseChannel({ escrow: escrowContract, payer, channelId })
804
+
805
+ // First voucher after close: stale re-query detects closeRequestedAt,
806
+ // persists it to the store, then throws ChannelClosedError.
807
+ await expect(
808
+ server.verify({
809
+ credential: {
810
+ challenge: makeChallenge({ id: 'challenge-2', channelId }),
811
+ payload: {
812
+ action: 'voucher' as const,
813
+ channelId,
814
+ cumulativeAmount: '2000000',
815
+ signature: await signTestVoucher(channelId, 2000000n),
816
+ },
817
+ },
818
+ request: makeRequest(),
819
+ }),
820
+ ).rejects.toThrow(ChannelClosedError)
821
+
822
+ // Now switch to a large TTL so subsequent vouchers use the cached path.
823
+ // The persisted closeRequestedAt should cause rejection without an
824
+ // on-chain re-query.
825
+ const server2 = createServer({ channelStateTtl: 60_000, store: rawStore })
826
+ await expect(
827
+ server2.verify({
828
+ credential: {
829
+ challenge: makeChallenge({ id: 'challenge-3', channelId }),
830
+ payload: {
831
+ action: 'voucher' as const,
832
+ channelId,
833
+ cumulativeAmount: '3000000',
834
+ signature: await signTestVoucher(channelId, 3000000n),
835
+ },
836
+ },
837
+ request: makeRequest(),
838
+ }),
839
+ ).rejects.toThrow(ChannelClosedError)
840
+ })
841
+ })
842
+
843
+ describe('topUp', () => {
844
+ async function openServerChannel(
845
+ server: ReturnType<typeof createServer>,
846
+ channelId: Hex,
847
+ serializedTransaction: Hex,
848
+ ) {
849
+ await server.verify({
850
+ credential: {
851
+ challenge: makeChallenge({ id: 'open-challenge', channelId }),
852
+ payload: {
853
+ action: 'open' as const,
854
+ type: 'transaction' as const,
855
+ channelId,
856
+ transaction: serializedTransaction,
857
+ cumulativeAmount: '1000000',
858
+ signature: await signTestVoucher(channelId, 1000000n),
859
+ },
860
+ },
861
+ request: makeRequest(),
862
+ })
863
+ }
864
+
865
+ test('accepts topUp with increased deposit', async () => {
866
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
867
+ const server = createServer()
868
+ await openServerChannel(server, channelId, serializedTransaction)
869
+
870
+ const { serializedTransaction: topUpTx } = await signTopUpChannel({
871
+ escrow: escrowContract,
872
+ payer,
873
+ channelId,
874
+ token: currency,
875
+ amount: 10000000n,
876
+ })
877
+
878
+ const receipt = await server.verify({
879
+ credential: {
880
+ challenge: makeChallenge({ id: 'challenge-2', channelId }),
881
+ payload: {
882
+ action: 'topUp' as const,
883
+ type: 'transaction' as const,
884
+ channelId,
885
+ transaction: topUpTx,
886
+ additionalDeposit: '10000000',
887
+ },
888
+ },
889
+ request: makeRequest(),
890
+ })
891
+
892
+ expect(receipt.status).toBe('success')
893
+
894
+ const ch = await store.getChannel(channelId)
895
+ expect(ch!.deposit).toBe(20000000n)
896
+ })
897
+
898
+ test('topUp receipt preserves spent and units from prior charges', async () => {
899
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
900
+ const server = createServer()
901
+ await openServerChannel(server, channelId, serializedTransaction)
902
+
903
+ await charge(store, channelId, 500000n)
904
+ await charge(store, channelId, 300000n)
905
+
906
+ const chBefore = await store.getChannel(channelId)
907
+ expect(chBefore!.spent).toBe(800000n)
908
+ expect(chBefore!.units).toBe(2)
909
+
910
+ const { serializedTransaction: topUpTx } = await signTopUpChannel({
911
+ escrow: escrowContract,
912
+ payer,
913
+ channelId,
914
+ token: currency,
915
+ amount: 5000000n,
916
+ })
917
+
918
+ const receipt = (await server.verify({
919
+ credential: {
920
+ challenge: makeChallenge({ id: 'challenge-topup', channelId }),
921
+ payload: {
922
+ action: 'topUp' as const,
923
+ type: 'transaction' as const,
924
+ channelId,
925
+ transaction: topUpTx,
926
+ additionalDeposit: '5000000',
927
+ },
928
+ },
929
+ request: makeRequest(),
930
+ })) as SessionReceipt
931
+
932
+ expect(receipt.status).toBe('success')
933
+ expect(receipt.spent).toBe('800000')
934
+ expect(receipt.units).toBe(2)
935
+
936
+ const chAfter = await store.getChannel(channelId)
937
+ expect(chAfter!.spent).toBe(800000n)
938
+ expect(chAfter!.units).toBe(2)
939
+ expect(chAfter!.deposit).toBe(15000000n)
940
+ })
941
+
942
+ test('rejects topUp on unknown channel', async () => {
943
+ const { channelId } = await createSignedOpenTransaction(10000000n)
944
+ const server = createServer()
945
+
946
+ await expect(
947
+ server.verify({
948
+ credential: {
949
+ challenge: makeChallenge({ channelId }),
950
+ payload: {
951
+ action: 'topUp' as const,
952
+ type: 'transaction' as const,
953
+ channelId,
954
+ transaction: '0xabcdef' as Hex,
955
+ additionalDeposit: '5000000',
956
+ },
957
+ },
958
+ request: makeRequest(),
959
+ }),
960
+ ).rejects.toThrow(ChannelNotFoundError)
961
+ })
962
+ })
963
+
964
+ describe('close', () => {
965
+ async function openServerChannel(
966
+ server: ReturnType<typeof createServer>,
967
+ channelId: Hex,
968
+ serializedTransaction: Hex,
969
+ ) {
970
+ await server.verify({
971
+ credential: {
972
+ challenge: makeChallenge({ id: 'open-challenge', channelId }),
973
+ payload: {
974
+ action: 'open' as const,
975
+ type: 'transaction' as const,
976
+ channelId,
575
977
  transaction: serializedTransaction,
576
978
  cumulativeAmount: '1000000',
577
979
  signature: await signTestVoucher(channelId, 1000000n),
@@ -979,6 +1381,392 @@ describe('session', () => {
979
1381
  })
980
1382
  })
981
1383
 
1384
+ describe('non-persistent storage recovery', () => {
1385
+ test('open on existing on-chain channel initializes settledOnChain from chain', async () => {
1386
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
1387
+ const server = createServer()
1388
+
1389
+ // Open channel and accept a voucher.
1390
+ await server.verify({
1391
+ credential: {
1392
+ challenge: makeChallenge({ id: 'c1', channelId }),
1393
+ payload: {
1394
+ action: 'open' as const,
1395
+ type: 'transaction' as const,
1396
+ channelId,
1397
+ transaction: serializedTransaction,
1398
+ cumulativeAmount: '5000000',
1399
+ signature: await signTestVoucher(channelId, 5000000n),
1400
+ },
1401
+ },
1402
+ request: makeRequest(),
1403
+ })
1404
+
1405
+ // Settle on-chain so onChain.settled = 5000000.
1406
+ const settleTxHash = await settle(store, client, channelId, { escrowContract })
1407
+ await waitForTransactionReceipt(client, { hash: settleTxHash })
1408
+ expect((await store.getChannel(channelId))!.settledOnChain).toBe(5000000n)
1409
+
1410
+ // Simulate server restart with non-persistent storage: wipe the store.
1411
+ await store.updateChannel(channelId, () => null)
1412
+ expect(await store.getChannel(channelId)).toBeNull()
1413
+
1414
+ // Re-open with a new (fresh) server instance using the same store.
1415
+ const server2 = createServer()
1416
+ const receipt = (await server2.verify({
1417
+ credential: {
1418
+ challenge: makeChallenge({ id: 'c2', channelId }),
1419
+ payload: {
1420
+ action: 'open' as const,
1421
+ type: 'transaction' as const,
1422
+ channelId,
1423
+ transaction: serializedTransaction,
1424
+ cumulativeAmount: '7000000',
1425
+ signature: await signTestVoucher(channelId, 7000000n),
1426
+ },
1427
+ },
1428
+ request: makeRequest(),
1429
+ })) as SessionReceipt
1430
+
1431
+ expect(receipt.status).toBe('success')
1432
+
1433
+ const ch = await store.getChannel(channelId)
1434
+ expect(ch).not.toBeNull()
1435
+ // settledOnChain should reflect the on-chain settled amount, not 0.
1436
+ expect(ch!.settledOnChain).toBe(5000000n)
1437
+ // spent must equal settledOnChain so only unsettled portion is chargeable.
1438
+ expect(ch!.spent).toBe(5000000n)
1439
+ })
1440
+
1441
+ test('recovery correctly limits available balance to unsettled portion', async () => {
1442
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
1443
+ const server = createServer()
1444
+
1445
+ await server.verify({
1446
+ credential: {
1447
+ challenge: makeChallenge({ id: 'c1', channelId }),
1448
+ payload: {
1449
+ action: 'open' as const,
1450
+ type: 'transaction' as const,
1451
+ channelId,
1452
+ transaction: serializedTransaction,
1453
+ cumulativeAmount: '5000000',
1454
+ signature: await signTestVoucher(channelId, 5000000n),
1455
+ },
1456
+ },
1457
+ request: makeRequest(),
1458
+ })
1459
+
1460
+ const settleTxHash2 = await settle(store, client, channelId, { escrowContract })
1461
+ await waitForTransactionReceipt(client, { hash: settleTxHash2 })
1462
+
1463
+ // Wipe store.
1464
+ await store.updateChannel(channelId, () => null)
1465
+
1466
+ // Re-open with voucher = 6000000 on a channel with settled = 5000000.
1467
+ const server2 = createServer()
1468
+ await server2.verify({
1469
+ credential: {
1470
+ challenge: makeChallenge({ id: 'c2', channelId }),
1471
+ payload: {
1472
+ action: 'open' as const,
1473
+ type: 'transaction' as const,
1474
+ channelId,
1475
+ transaction: serializedTransaction,
1476
+ cumulativeAmount: '6000000',
1477
+ signature: await signTestVoucher(channelId, 6000000n),
1478
+ },
1479
+ },
1480
+ request: makeRequest(),
1481
+ })
1482
+
1483
+ // spent = settledOnChain = 5M, highestVoucherAmount = 6M.
1484
+ // Available = 6M - 5M = 1M (only the unsettled portion).
1485
+ const ch = await store.getChannel(channelId)
1486
+ expect(ch!.highestVoucherAmount).toBe(6000000n)
1487
+ expect(ch!.spent).toBe(5000000n)
1488
+ expect(ch!.settledOnChain).toBe(5000000n)
1489
+ await charge(store, channelId, 1000000n)
1490
+ await expect(charge(store, channelId, 1n)).rejects.toThrow(InsufficientBalanceError)
1491
+ })
1492
+
1493
+ test('reopen existing channel bumps stale settledOnChain from chain', async () => {
1494
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
1495
+ const server = createServer()
1496
+
1497
+ await server.verify({
1498
+ credential: {
1499
+ challenge: makeChallenge({ id: 'c1', channelId }),
1500
+ payload: {
1501
+ action: 'open' as const,
1502
+ type: 'transaction' as const,
1503
+ channelId,
1504
+ transaction: serializedTransaction,
1505
+ cumulativeAmount: '3000000',
1506
+ signature: await signTestVoucher(channelId, 3000000n),
1507
+ },
1508
+ },
1509
+ request: makeRequest(),
1510
+ })
1511
+
1512
+ // Settle on-chain so onChain.settled = 3000000.
1513
+ const settleTxHash = await settle(store, client, channelId, { escrowContract })
1514
+ await waitForTransactionReceipt(client, { hash: settleTxHash })
1515
+
1516
+ // Store still has the old record — settledOnChain is correct after settle.
1517
+ expect((await store.getChannel(channelId))!.settledOnChain).toBe(3000000n)
1518
+
1519
+ // Manually regress settledOnChain to simulate a stale stored value.
1520
+ await store.updateChannel(channelId, (ch) => (ch ? { ...ch, settledOnChain: 0n } : null))
1521
+ expect((await store.getChannel(channelId))!.settledOnChain).toBe(0n)
1522
+
1523
+ // Re-open with a higher voucher — should bump settledOnChain from chain.
1524
+ const server2 = createServer()
1525
+ await server2.verify({
1526
+ credential: {
1527
+ challenge: makeChallenge({ id: 'c2', channelId }),
1528
+ payload: {
1529
+ action: 'open' as const,
1530
+ type: 'transaction' as const,
1531
+ channelId,
1532
+ transaction: serializedTransaction,
1533
+ cumulativeAmount: '5000000',
1534
+ signature: await signTestVoucher(channelId, 5000000n),
1535
+ },
1536
+ },
1537
+ request: makeRequest(),
1538
+ })
1539
+
1540
+ const ch = await store.getChannel(channelId)
1541
+ expect(ch!.settledOnChain).toBe(3000000n)
1542
+ expect(ch!.highestVoucherAmount).toBe(5000000n)
1543
+ })
1544
+
1545
+ test('reopen existing record bumps spent to settledOnChain to prevent over-service', async () => {
1546
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
1547
+ const server = createServer()
1548
+
1549
+ // Open channel with voucher = 5M (spent stays 0).
1550
+ await server.verify({
1551
+ credential: {
1552
+ challenge: makeChallenge({ id: 'c1', channelId }),
1553
+ payload: {
1554
+ action: 'open' as const,
1555
+ type: 'transaction' as const,
1556
+ channelId,
1557
+ transaction: serializedTransaction,
1558
+ cumulativeAmount: '5000000',
1559
+ signature: await signTestVoucher(channelId, 5000000n),
1560
+ },
1561
+ },
1562
+ request: makeRequest(),
1563
+ })
1564
+
1565
+ // Settle on-chain so onChain.settled = 5M.
1566
+ const settleTxHash = await settle(store, client, channelId, { escrowContract })
1567
+ await waitForTransactionReceipt(client, { hash: settleTxHash })
1568
+
1569
+ // Store record still exists (no store loss), but spent is 0.
1570
+ const before = await store.getChannel(channelId)
1571
+ expect(before!.spent).toBe(0n)
1572
+ expect(before!.settledOnChain).toBe(5000000n)
1573
+
1574
+ // Re-open with higher voucher = 7M on existing record.
1575
+ const server2 = createServer()
1576
+ await server2.verify({
1577
+ credential: {
1578
+ challenge: makeChallenge({ id: 'c2', channelId }),
1579
+ payload: {
1580
+ action: 'open' as const,
1581
+ type: 'transaction' as const,
1582
+ channelId,
1583
+ transaction: serializedTransaction,
1584
+ cumulativeAmount: '7000000',
1585
+ signature: await signTestVoucher(channelId, 7000000n),
1586
+ },
1587
+ },
1588
+ request: makeRequest(),
1589
+ })
1590
+
1591
+ // spent must be bumped to at least settledOnChain (5M) so available
1592
+ // is only the unsettled portion (7M - 5M = 2M), not the full 7M.
1593
+ const ch = await store.getChannel(channelId)
1594
+ expect(ch!.settledOnChain).toBe(5000000n)
1595
+ expect(ch!.spent).toBe(5000000n)
1596
+ expect(ch!.highestVoucherAmount).toBe(7000000n)
1597
+
1598
+ // Only 2M should be chargeable.
1599
+ await charge(store, channelId, 2000000n)
1600
+ await expect(charge(store, channelId, 1n)).rejects.toThrow(InsufficientBalanceError)
1601
+ })
1602
+
1603
+ test('rejects voucher at settled amount after store loss', async () => {
1604
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
1605
+ const server = createServer()
1606
+
1607
+ await server.verify({
1608
+ credential: {
1609
+ challenge: makeChallenge({ id: 'c1', channelId }),
1610
+ payload: {
1611
+ action: 'open' as const,
1612
+ type: 'transaction' as const,
1613
+ channelId,
1614
+ transaction: serializedTransaction,
1615
+ cumulativeAmount: '5000000',
1616
+ signature: await signTestVoucher(channelId, 5000000n),
1617
+ },
1618
+ },
1619
+ request: makeRequest(),
1620
+ })
1621
+
1622
+ const settleTxHash3 = await settle(store, client, channelId, { escrowContract })
1623
+ await waitForTransactionReceipt(client, { hash: settleTxHash3 })
1624
+ await store.updateChannel(channelId, () => null)
1625
+
1626
+ // Attempt to re-open with a voucher equal to the settled amount.
1627
+ // This should be rejected because cumulativeAmount <= onChain.settled.
1628
+ const server2 = createServer()
1629
+ await expect(
1630
+ server2.verify({
1631
+ credential: {
1632
+ challenge: makeChallenge({ id: 'c2', channelId }),
1633
+ payload: {
1634
+ action: 'open' as const,
1635
+ type: 'transaction' as const,
1636
+ channelId,
1637
+ transaction: serializedTransaction,
1638
+ cumulativeAmount: '5000000',
1639
+ signature: await signTestVoucher(channelId, 5000000n),
1640
+ },
1641
+ },
1642
+ request: makeRequest(),
1643
+ }),
1644
+ ).rejects.toThrow('voucher cumulativeAmount is below on-chain settled amount')
1645
+ })
1646
+
1647
+ test('close after recovery respects on-chain settled as minimum', async () => {
1648
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
1649
+ const server = createServer()
1650
+
1651
+ // Open, settle 4M, wipe store.
1652
+ await server.verify({
1653
+ credential: {
1654
+ challenge: makeChallenge({ id: 'c1', channelId }),
1655
+ payload: {
1656
+ action: 'open' as const,
1657
+ type: 'transaction' as const,
1658
+ channelId,
1659
+ transaction: serializedTransaction,
1660
+ cumulativeAmount: '4000000',
1661
+ signature: await signTestVoucher(channelId, 4000000n),
1662
+ },
1663
+ },
1664
+ request: makeRequest(),
1665
+ })
1666
+
1667
+ const settleTxHash = await settle(store, client, channelId, { escrowContract })
1668
+ await waitForTransactionReceipt(client, { hash: settleTxHash })
1669
+ await store.updateChannel(channelId, () => null)
1670
+
1671
+ // Re-open with voucher = 8M.
1672
+ const server2 = createServer()
1673
+ await server2.verify({
1674
+ credential: {
1675
+ challenge: makeChallenge({ id: 'c2', channelId }),
1676
+ payload: {
1677
+ action: 'open' as const,
1678
+ type: 'transaction' as const,
1679
+ channelId,
1680
+ transaction: serializedTransaction,
1681
+ cumulativeAmount: '8000000',
1682
+ signature: await signTestVoucher(channelId, 8000000n),
1683
+ },
1684
+ },
1685
+ request: makeRequest(),
1686
+ })
1687
+
1688
+ // Charge 1M — spent goes from 4M (settled baseline) to 5M.
1689
+ await charge(store, channelId, 1000000n)
1690
+
1691
+ // Close must succeed with voucher >= max(spent=5M, settled=4M) = 5M.
1692
+ // Use 8M (the full authorization).
1693
+ const receipt = await server2.verify({
1694
+ credential: {
1695
+ challenge: makeChallenge({ id: 'close', channelId }),
1696
+ payload: {
1697
+ action: 'close' as const,
1698
+ channelId,
1699
+ cumulativeAmount: '8000000',
1700
+ signature: await signTestVoucher(channelId, 8000000n),
1701
+ },
1702
+ },
1703
+ request: makeRequest(),
1704
+ })
1705
+
1706
+ expect(receipt.status).toBe('success')
1707
+ const ch = await store.getChannel(channelId)
1708
+ expect(ch!.finalized).toBe(true)
1709
+ })
1710
+
1711
+ test('close after recovery rejects voucher below on-chain settled', async () => {
1712
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
1713
+ const server = createServer()
1714
+
1715
+ // Open, settle 5M, wipe store.
1716
+ await server.verify({
1717
+ credential: {
1718
+ challenge: makeChallenge({ id: 'c1', channelId }),
1719
+ payload: {
1720
+ action: 'open' as const,
1721
+ type: 'transaction' as const,
1722
+ channelId,
1723
+ transaction: serializedTransaction,
1724
+ cumulativeAmount: '5000000',
1725
+ signature: await signTestVoucher(channelId, 5000000n),
1726
+ },
1727
+ },
1728
+ request: makeRequest(),
1729
+ })
1730
+
1731
+ const settleTxHash = await settle(store, client, channelId, { escrowContract })
1732
+ await waitForTransactionReceipt(client, { hash: settleTxHash })
1733
+ await store.updateChannel(channelId, () => null)
1734
+
1735
+ // Re-open with voucher = 7M.
1736
+ const server2 = createServer()
1737
+ await server2.verify({
1738
+ credential: {
1739
+ challenge: makeChallenge({ id: 'c2', channelId }),
1740
+ payload: {
1741
+ action: 'open' as const,
1742
+ type: 'transaction' as const,
1743
+ channelId,
1744
+ transaction: serializedTransaction,
1745
+ cumulativeAmount: '7000000',
1746
+ signature: await signTestVoucher(channelId, 7000000n),
1747
+ },
1748
+ },
1749
+ request: makeRequest(),
1750
+ })
1751
+
1752
+ // Try to close with 3M — below settled (5M). Must be rejected.
1753
+ await expect(
1754
+ server2.verify({
1755
+ credential: {
1756
+ challenge: makeChallenge({ id: 'close', channelId }),
1757
+ payload: {
1758
+ action: 'close' as const,
1759
+ channelId,
1760
+ cumulativeAmount: '3000000',
1761
+ signature: await signTestVoucher(channelId, 3000000n),
1762
+ },
1763
+ },
1764
+ request: makeRequest(),
1765
+ }),
1766
+ ).rejects.toThrow('close voucher amount must be >=')
1767
+ })
1768
+ })
1769
+
982
1770
  describe('structured errors', () => {
983
1771
  test('ChannelNotFoundError on unknown channel', async () => {
984
1772
  const { channelId } = await createSignedOpenTransaction(10000000n)
@@ -1354,6 +2142,7 @@ describe('monotonicity and TOCTOU (unit tests)', () => {
1354
2142
  },
1355
2143
  spent: 0n,
1356
2144
  units: 0,
2145
+ closeRequestedAt: 0n,
1357
2146
  finalized: false,
1358
2147
  createdAt: new Date().toISOString(),
1359
2148
  ...overrides,