mppx 0.4.6 → 0.4.8

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