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