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.
- package/CHANGELOG.md +12 -0
- package/dist/Store.d.ts +5 -4
- package/dist/Store.d.ts.map +1 -1
- package/dist/Store.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +22 -7
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +9 -22
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/middlewares/elysia.d.ts.map +1 -1
- package/dist/middlewares/elysia.js +5 -1
- package/dist/middlewares/elysia.js.map +1 -1
- package/dist/proxy/Proxy.d.ts.map +1 -1
- package/dist/proxy/Proxy.js +3 -1
- package/dist/proxy/Proxy.js.map +1 -1
- package/dist/proxy/internal/Route.d.ts +2 -2
- package/dist/proxy/internal/Route.d.ts.map +1 -1
- package/dist/proxy/internal/Route.js +4 -2
- package/dist/proxy/internal/Route.js.map +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +26 -8
- package/dist/server/Mppx.js.map +1 -1
- package/dist/tempo/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/client/SessionManager.js +12 -1
- package/dist/tempo/client/SessionManager.js.map +1 -1
- package/dist/tempo/internal/address.d.ts +3 -0
- package/dist/tempo/internal/address.d.ts.map +1 -0
- package/dist/tempo/internal/address.js +4 -0
- package/dist/tempo/internal/address.js.map +1 -0
- package/dist/tempo/internal/auto-swap.js +3 -3
- package/dist/tempo/internal/auto-swap.js.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +4 -1
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +11 -3
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +11 -0
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +109 -50
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +1 -1
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +39 -32
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/server/internal/transport.d.ts +1 -1
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +41 -1
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +51 -10
- package/dist/tempo/session/Chain.js.map +1 -1
- package/dist/tempo/session/ChannelStore.d.ts +2 -0
- package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
- package/dist/tempo/session/ChannelStore.js +4 -2
- package/dist/tempo/session/ChannelStore.js.map +1 -1
- package/dist/tempo/session/Voucher.d.ts.map +1 -1
- package/dist/tempo/session/Voucher.js +3 -2
- package/dist/tempo/session/Voucher.js.map +1 -1
- package/package.json +6 -2
- package/src/Store.test-d.ts +58 -0
- package/src/Store.ts +6 -4
- package/src/cli/cli.test.ts +124 -0
- package/src/cli/cli.ts +19 -7
- package/src/cli/plugins/tempo.ts +17 -23
- package/src/middlewares/elysia.test.ts +89 -0
- package/src/middlewares/elysia.ts +4 -1
- package/src/proxy/Proxy.test.ts +56 -0
- package/src/proxy/Proxy.ts +6 -1
- package/src/proxy/internal/Route.test.ts +57 -0
- package/src/proxy/internal/Route.ts +3 -1
- package/src/server/Mppx.test.ts +246 -0
- package/src/server/Mppx.ts +27 -8
- package/src/tempo/client/SessionManager.ts +11 -1
- package/src/tempo/internal/address.ts +6 -0
- package/src/tempo/internal/auto-swap.ts +3 -3
- package/src/tempo/internal/fee-payer.ts +18 -4
- package/src/tempo/server/Charge.test.ts +1080 -31
- package/src/tempo/server/Charge.ts +158 -63
- package/src/tempo/server/Session.test.ts +929 -111
- package/src/tempo/server/Session.ts +48 -33
- package/src/tempo/server/Sse.test.ts +1 -0
- package/src/tempo/server/internal/transport.test.ts +29 -0
- package/src/tempo/server/internal/transport.ts +41 -2
- package/src/tempo/session/Chain.test.ts +144 -0
- package/src/tempo/session/Chain.ts +58 -10
- package/src/tempo/session/ChannelStore.test.ts +10 -0
- package/src/tempo/session/ChannelStore.ts +6 -3
- package/src/tempo/session/Sse.test.ts +1 -0
- 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('
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
372
|
-
|
|
479
|
+
request: makeRequest(),
|
|
480
|
+
}),
|
|
481
|
+
).rejects.toThrow(
|
|
482
|
+
'voucher cumulativeAmount must be strictly greater than highest accepted voucher',
|
|
483
|
+
)
|
|
484
|
+
})
|
|
373
485
|
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
server
|
|
444
|
-
channelId
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
}
|
|
628
|
+
cumulativeAmount: '2000000',
|
|
629
|
+
signature: await signTestVoucher(channelId, 2000000n),
|
|
630
|
+
}
|
|
475
631
|
|
|
476
|
-
|
|
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(
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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('
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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-
|
|
661
|
+
challenge: makeChallenge({ id: 'challenge-2', channelId }),
|
|
519
662
|
payload: {
|
|
520
|
-
action: '
|
|
521
|
-
type: 'transaction' as const,
|
|
663
|
+
action: 'voucher' as const,
|
|
522
664
|
channelId,
|
|
523
|
-
|
|
524
|
-
|
|
665
|
+
cumulativeAmount: '5000000',
|
|
666
|
+
signature: voucherSig,
|
|
525
667
|
},
|
|
526
668
|
},
|
|
527
669
|
request: makeRequest(),
|
|
528
|
-
})
|
|
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
|
-
|
|
541
|
-
|
|
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: '
|
|
550
|
-
type: 'transaction' as const,
|
|
681
|
+
action: 'voucher' as const,
|
|
551
682
|
channelId,
|
|
552
|
-
|
|
553
|
-
|
|
683
|
+
cumulativeAmount: '5000000',
|
|
684
|
+
signature: voucherSig,
|
|
554
685
|
},
|
|
555
686
|
},
|
|
556
687
|
request: makeRequest(),
|
|
557
688
|
}),
|
|
558
|
-
).rejects.toThrow(
|
|
689
|
+
).rejects.toThrow('voucher cumulativeAmount is below on-chain settled amount')
|
|
559
690
|
})
|
|
560
|
-
})
|
|
561
691
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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: '
|
|
702
|
+
challenge: makeChallenge({ id: 'c-victim', channelId: victimChannelId }),
|
|
571
703
|
payload: {
|
|
572
|
-
action: '
|
|
573
|
-
|
|
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('
|
|
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-
|
|
1085
|
+
challenge: makeChallenge({ id: 'challenge-2', channelId }),
|
|
655
1086
|
payload: {
|
|
656
1087
|
action: 'close' as const,
|
|
657
1088
|
channelId,
|
|
658
|
-
cumulativeAmount: '
|
|
659
|
-
signature: await signTestVoucher(channelId,
|
|
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 >=
|
|
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,
|