mppx 0.4.11 → 0.5.0
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 +21 -0
- package/dist/Expires.d.ts +7 -0
- package/dist/Expires.d.ts.map +1 -1
- package/dist/Expires.js +21 -0
- package/dist/Expires.js.map +1 -1
- package/dist/internal/env.d.ts +1 -1
- package/dist/internal/env.d.ts.map +1 -1
- package/dist/internal/env.js +2 -6
- package/dist/internal/env.js.map +1 -1
- package/dist/internal/types.d.ts +23 -0
- package/dist/internal/types.d.ts.map +1 -1
- package/dist/server/Mppx.d.ts +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +55 -7
- package/dist/server/Mppx.js.map +1 -1
- package/dist/stripe/server/Charge.d.ts.map +1 -1
- package/dist/stripe/server/Charge.js +3 -3
- package/dist/stripe/server/Charge.js.map +1 -1
- package/dist/tempo/Methods.d.ts +18 -0
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +28 -3
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/Charge.d.ts +24 -0
- package/dist/tempo/client/Charge.d.ts.map +1 -1
- package/dist/tempo/client/Charge.js +51 -9
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +18 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/internal/account.d.ts +5 -11
- package/dist/tempo/internal/account.d.ts.map +1 -1
- package/dist/tempo/internal/charge.d.ts +20 -0
- package/dist/tempo/internal/charge.d.ts.map +1 -0
- package/dist/tempo/internal/charge.js +23 -0
- package/dist/tempo/internal/charge.js.map +1 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +15 -3
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/internal/proof.d.ts +23 -0
- package/dist/tempo/internal/proof.d.ts.map +1 -0
- package/dist/tempo/internal/proof.js +17 -0
- package/dist/tempo/internal/proof.js.map +1 -0
- package/dist/tempo/server/Charge.d.ts +20 -2
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +180 -103
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +20 -2
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +4 -1
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +9 -4
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +18 -3
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts +18 -2
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +18 -14
- package/dist/tempo/session/Chain.js.map +1 -1
- package/package.json +1 -1
- package/src/Expires.ts +25 -0
- package/src/cli/cli.test.ts +230 -1
- package/src/internal/env.test.ts +12 -12
- package/src/internal/env.ts +2 -6
- package/src/internal/types.ts +25 -0
- package/src/middlewares/elysia.test.ts +127 -4
- package/src/middlewares/express.test.ts +120 -54
- package/src/middlewares/hono.test.ts +73 -34
- package/src/middlewares/nextjs.test.ts +159 -36
- package/src/server/Mppx.test.ts +373 -0
- package/src/server/Mppx.ts +64 -10
- package/src/stripe/server/Charge.ts +3 -7
- package/src/tempo/Methods.test.ts +105 -0
- package/src/tempo/Methods.ts +54 -17
- package/src/tempo/client/Charge.ts +67 -11
- package/src/tempo/internal/account.ts +7 -14
- package/src/tempo/internal/charge.test.ts +66 -0
- package/src/tempo/internal/charge.ts +43 -0
- package/src/tempo/internal/fee-payer.test.ts +33 -14
- package/src/tempo/internal/fee-payer.ts +21 -6
- package/src/tempo/internal/proof.test.ts +36 -0
- package/src/tempo/internal/proof.ts +19 -0
- package/src/tempo/server/Charge.test.ts +593 -1
- package/src/tempo/server/Charge.ts +233 -126
- package/src/tempo/server/Methods.ts +4 -1
- package/src/tempo/server/Session.test.ts +1152 -54
- package/src/tempo/server/Session.ts +26 -17
- package/src/tempo/server/internal/transport.test.ts +32 -0
- package/src/tempo/session/Chain.test.ts +60 -5
- package/src/tempo/session/Chain.ts +30 -14
- package/src/tempo/session/Sse.test.ts +31 -0
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import type { z } from 'mppx'
|
|
2
|
-
import { Challenge } from 'mppx'
|
|
2
|
+
import { Challenge, Credential } from 'mppx'
|
|
3
3
|
import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
|
|
4
|
-
import {
|
|
4
|
+
import { Base64 } from 'ox'
|
|
5
|
+
import {
|
|
6
|
+
type Address,
|
|
7
|
+
createClient,
|
|
8
|
+
type Hex,
|
|
9
|
+
parseSignature,
|
|
10
|
+
serializeCompactSignature,
|
|
11
|
+
signatureToCompactSignature,
|
|
12
|
+
} from 'viem'
|
|
5
13
|
import { waitForTransactionReceipt } from 'viem/actions'
|
|
6
14
|
import { Addresses } from 'viem/tempo'
|
|
7
15
|
import { beforeAll, beforeEach, describe, expect, expectTypeOf, test } from 'vp/test'
|
|
@@ -9,6 +17,7 @@ import { nodeEnv } from '~test/config.js'
|
|
|
9
17
|
|
|
10
18
|
const isLocalnet = nodeEnv === 'localnet'
|
|
11
19
|
import {
|
|
20
|
+
closeChannelOnChain,
|
|
12
21
|
deployEscrow,
|
|
13
22
|
requestCloseChannel,
|
|
14
23
|
signOpenChannel,
|
|
@@ -24,6 +33,7 @@ import {
|
|
|
24
33
|
InvalidSignatureError,
|
|
25
34
|
} from '../../Errors.js'
|
|
26
35
|
import * as Store from '../../Store.js'
|
|
36
|
+
import { sessionManager } from '../client/SessionManager.js'
|
|
27
37
|
import {
|
|
28
38
|
chainId as chainIdDefaults,
|
|
29
39
|
escrowContract as escrowContractDefaults,
|
|
@@ -35,6 +45,7 @@ import { signVoucher } from '../session/Voucher.js'
|
|
|
35
45
|
import { charge, session, settle } from './Session.js'
|
|
36
46
|
|
|
37
47
|
const payer = accounts[2]
|
|
48
|
+
const delegatedSigner = accounts[4]
|
|
38
49
|
const recipientAccount = accounts[0]
|
|
39
50
|
const recipient = accounts[0].address
|
|
40
51
|
const currency = asset
|
|
@@ -70,6 +81,39 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
70
81
|
} as session.Parameters)
|
|
71
82
|
}
|
|
72
83
|
|
|
84
|
+
function createServerWithStore(
|
|
85
|
+
customStore: Store.Store,
|
|
86
|
+
overrides: Partial<session.Parameters> = {},
|
|
87
|
+
) {
|
|
88
|
+
return session({
|
|
89
|
+
store: customStore,
|
|
90
|
+
getClient: () => client,
|
|
91
|
+
account: recipientAccount,
|
|
92
|
+
currency,
|
|
93
|
+
escrowContract,
|
|
94
|
+
chainId: chain.id,
|
|
95
|
+
...overrides,
|
|
96
|
+
} as session.Parameters)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function createHandler(overrides: Partial<session.Parameters> = {}) {
|
|
100
|
+
return Mppx_server.create({
|
|
101
|
+
methods: [
|
|
102
|
+
tempo_server.session({
|
|
103
|
+
store: rawStore,
|
|
104
|
+
getClient: () => client,
|
|
105
|
+
account: recipientAccount,
|
|
106
|
+
currency,
|
|
107
|
+
escrowContract,
|
|
108
|
+
chainId: chain.id,
|
|
109
|
+
...overrides,
|
|
110
|
+
} as session.Parameters),
|
|
111
|
+
],
|
|
112
|
+
realm: 'api.example.com',
|
|
113
|
+
secretKey: 'secret',
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
73
117
|
describe('open', () => {
|
|
74
118
|
test('accepts a valid open with voucher', async () => {
|
|
75
119
|
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
@@ -885,6 +929,34 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
885
929
|
}),
|
|
886
930
|
).rejects.toThrow(ChannelClosedError)
|
|
887
931
|
})
|
|
932
|
+
|
|
933
|
+
test('rejects voucher when deposit is zero (settled race window)', async () => {
|
|
934
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
935
|
+
// Use a large TTL so the voucher path uses the cached store state
|
|
936
|
+
// instead of reading on-chain. This lets us simulate the settlement
|
|
937
|
+
// race where deposit=0 but finalized=false by manipulating the store.
|
|
938
|
+
const server = createServer({ channelStateTtl: 60_000 })
|
|
939
|
+
await openServerChannel(server, channelId, serializedTransaction)
|
|
940
|
+
|
|
941
|
+
// Simulate the escrow contract zeroing the deposit before setting
|
|
942
|
+
// finalized (the race window this PR guards against).
|
|
943
|
+
await store.updateChannel(channelId, (ch) => (ch ? { ...ch, deposit: 0n } : null))
|
|
944
|
+
|
|
945
|
+
await expect(
|
|
946
|
+
server.verify({
|
|
947
|
+
credential: {
|
|
948
|
+
challenge: makeChallenge({ id: 'challenge-after-settle', channelId }),
|
|
949
|
+
payload: {
|
|
950
|
+
action: 'voucher' as const,
|
|
951
|
+
channelId,
|
|
952
|
+
cumulativeAmount: '2000000',
|
|
953
|
+
signature: await signTestVoucher(channelId, 2000000n),
|
|
954
|
+
},
|
|
955
|
+
},
|
|
956
|
+
request: makeRequest(),
|
|
957
|
+
}),
|
|
958
|
+
).rejects.toThrow(ChannelClosedError)
|
|
959
|
+
})
|
|
888
960
|
})
|
|
889
961
|
|
|
890
962
|
describe('topUp', () => {
|
|
@@ -1363,6 +1435,185 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
1363
1435
|
expect(chAfter).not.toBeNull()
|
|
1364
1436
|
expect(chAfter!.highestVoucherAmount).toBe(7000000n)
|
|
1365
1437
|
})
|
|
1438
|
+
|
|
1439
|
+
test('supports delegated signer end-to-end (open -> voucher -> close)', async () => {
|
|
1440
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n, {
|
|
1441
|
+
authorizedSigner: delegatedSigner.address,
|
|
1442
|
+
})
|
|
1443
|
+
const server = createServer()
|
|
1444
|
+
|
|
1445
|
+
const openReceipt = await server.verify({
|
|
1446
|
+
credential: {
|
|
1447
|
+
challenge: makeChallenge({ id: 'open-delegated', channelId }),
|
|
1448
|
+
payload: {
|
|
1449
|
+
action: 'open' as const,
|
|
1450
|
+
type: 'transaction' as const,
|
|
1451
|
+
channelId,
|
|
1452
|
+
transaction: serializedTransaction,
|
|
1453
|
+
cumulativeAmount: '1000000',
|
|
1454
|
+
signature: await signTestVoucher(channelId, 1000000n, delegatedSigner),
|
|
1455
|
+
},
|
|
1456
|
+
},
|
|
1457
|
+
request: makeRequest(),
|
|
1458
|
+
})
|
|
1459
|
+
expect(openReceipt.status).toBe('success')
|
|
1460
|
+
|
|
1461
|
+
const channel = await store.getChannel(channelId)
|
|
1462
|
+
expect(channel?.authorizedSigner).toBe(delegatedSigner.address)
|
|
1463
|
+
|
|
1464
|
+
const voucherReceipt = (await server.verify({
|
|
1465
|
+
credential: {
|
|
1466
|
+
challenge: makeChallenge({ id: 'voucher-delegated', channelId }),
|
|
1467
|
+
payload: {
|
|
1468
|
+
action: 'voucher' as const,
|
|
1469
|
+
channelId,
|
|
1470
|
+
cumulativeAmount: '2000000',
|
|
1471
|
+
signature: await signTestVoucher(channelId, 2000000n, delegatedSigner),
|
|
1472
|
+
},
|
|
1473
|
+
},
|
|
1474
|
+
request: makeRequest(),
|
|
1475
|
+
})) as SessionReceipt
|
|
1476
|
+
expect(voucherReceipt.acceptedCumulative).toBe('2000000')
|
|
1477
|
+
|
|
1478
|
+
const closeReceipt = await server.verify({
|
|
1479
|
+
credential: {
|
|
1480
|
+
challenge: makeChallenge({ id: 'close-delegated', channelId }),
|
|
1481
|
+
payload: {
|
|
1482
|
+
action: 'close' as const,
|
|
1483
|
+
channelId,
|
|
1484
|
+
cumulativeAmount: '2000000',
|
|
1485
|
+
signature: await signTestVoucher(channelId, 2000000n, delegatedSigner),
|
|
1486
|
+
},
|
|
1487
|
+
},
|
|
1488
|
+
request: makeRequest(),
|
|
1489
|
+
})
|
|
1490
|
+
expect(closeReceipt.status).toBe('success')
|
|
1491
|
+
})
|
|
1492
|
+
|
|
1493
|
+
test('open -> topUp -> topUp -> voucher/charge -> close', async () => {
|
|
1494
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(4000000n)
|
|
1495
|
+
const server = createServer()
|
|
1496
|
+
|
|
1497
|
+
const openReceipt = await server.verify({
|
|
1498
|
+
credential: {
|
|
1499
|
+
challenge: makeChallenge({ id: 'open-multi-topup', channelId }),
|
|
1500
|
+
payload: {
|
|
1501
|
+
action: 'open' as const,
|
|
1502
|
+
type: 'transaction' as const,
|
|
1503
|
+
channelId,
|
|
1504
|
+
transaction: serializedTransaction,
|
|
1505
|
+
cumulativeAmount: '1000000',
|
|
1506
|
+
signature: await signTestVoucher(channelId, 1000000n),
|
|
1507
|
+
},
|
|
1508
|
+
},
|
|
1509
|
+
request: makeRequest(),
|
|
1510
|
+
})
|
|
1511
|
+
expect(openReceipt.status).toBe('success')
|
|
1512
|
+
|
|
1513
|
+
await charge(store, channelId, 1000000n)
|
|
1514
|
+
await expect(charge(store, channelId, 1000000n)).rejects.toThrow('requested')
|
|
1515
|
+
|
|
1516
|
+
const topUp1Amount = 2000000n
|
|
1517
|
+
const { serializedTransaction: topUp1 } = await signTopUpChannel({
|
|
1518
|
+
escrow: escrowContract,
|
|
1519
|
+
payer,
|
|
1520
|
+
channelId,
|
|
1521
|
+
token: currency,
|
|
1522
|
+
amount: topUp1Amount,
|
|
1523
|
+
})
|
|
1524
|
+
|
|
1525
|
+
const topUp1Receipt = await server.verify({
|
|
1526
|
+
credential: {
|
|
1527
|
+
challenge: makeChallenge({ id: 'topup-1', channelId }),
|
|
1528
|
+
payload: {
|
|
1529
|
+
action: 'topUp' as const,
|
|
1530
|
+
type: 'transaction' as const,
|
|
1531
|
+
channelId,
|
|
1532
|
+
transaction: topUp1,
|
|
1533
|
+
additionalDeposit: topUp1Amount.toString(),
|
|
1534
|
+
},
|
|
1535
|
+
},
|
|
1536
|
+
request: makeRequest(),
|
|
1537
|
+
})
|
|
1538
|
+
expect(topUp1Receipt.status).toBe('success')
|
|
1539
|
+
expect((await store.getChannel(channelId))?.deposit).toBe(6000000n)
|
|
1540
|
+
|
|
1541
|
+
const voucher1 = (await server.verify({
|
|
1542
|
+
credential: {
|
|
1543
|
+
challenge: makeChallenge({ id: 'voucher-after-topup-1', channelId }),
|
|
1544
|
+
payload: {
|
|
1545
|
+
action: 'voucher' as const,
|
|
1546
|
+
channelId,
|
|
1547
|
+
cumulativeAmount: '3000000',
|
|
1548
|
+
signature: await signTestVoucher(channelId, 3000000n),
|
|
1549
|
+
},
|
|
1550
|
+
},
|
|
1551
|
+
request: makeRequest(),
|
|
1552
|
+
})) as SessionReceipt
|
|
1553
|
+
expect(voucher1.acceptedCumulative).toBe('3000000')
|
|
1554
|
+
|
|
1555
|
+
await charge(store, channelId, 2000000n)
|
|
1556
|
+
await expect(charge(store, channelId, 1000000n)).rejects.toThrow('requested')
|
|
1557
|
+
|
|
1558
|
+
const topUp2Amount = 2000000n
|
|
1559
|
+
const { serializedTransaction: topUp2 } = await signTopUpChannel({
|
|
1560
|
+
escrow: escrowContract,
|
|
1561
|
+
payer,
|
|
1562
|
+
channelId,
|
|
1563
|
+
token: currency,
|
|
1564
|
+
amount: topUp2Amount,
|
|
1565
|
+
})
|
|
1566
|
+
|
|
1567
|
+
const topUp2Receipt = await server.verify({
|
|
1568
|
+
credential: {
|
|
1569
|
+
challenge: makeChallenge({ id: 'topup-2', channelId }),
|
|
1570
|
+
payload: {
|
|
1571
|
+
action: 'topUp' as const,
|
|
1572
|
+
type: 'transaction' as const,
|
|
1573
|
+
channelId,
|
|
1574
|
+
transaction: topUp2,
|
|
1575
|
+
additionalDeposit: topUp2Amount.toString(),
|
|
1576
|
+
},
|
|
1577
|
+
},
|
|
1578
|
+
request: makeRequest(),
|
|
1579
|
+
})
|
|
1580
|
+
expect(topUp2Receipt.status).toBe('success')
|
|
1581
|
+
expect((await store.getChannel(channelId))?.deposit).toBe(8000000n)
|
|
1582
|
+
|
|
1583
|
+
const voucher2 = (await server.verify({
|
|
1584
|
+
credential: {
|
|
1585
|
+
challenge: makeChallenge({ id: 'voucher-after-topup-2', channelId }),
|
|
1586
|
+
payload: {
|
|
1587
|
+
action: 'voucher' as const,
|
|
1588
|
+
channelId,
|
|
1589
|
+
cumulativeAmount: '5000000',
|
|
1590
|
+
signature: await signTestVoucher(channelId, 5000000n),
|
|
1591
|
+
},
|
|
1592
|
+
},
|
|
1593
|
+
request: makeRequest(),
|
|
1594
|
+
})) as SessionReceipt
|
|
1595
|
+
expect(voucher2.acceptedCumulative).toBe('5000000')
|
|
1596
|
+
|
|
1597
|
+
await charge(store, channelId, 2000000n)
|
|
1598
|
+
|
|
1599
|
+
const closeReceipt = await server.verify({
|
|
1600
|
+
credential: {
|
|
1601
|
+
challenge: makeChallenge({ id: 'close-multi-topup', channelId }),
|
|
1602
|
+
payload: {
|
|
1603
|
+
action: 'close' as const,
|
|
1604
|
+
channelId,
|
|
1605
|
+
cumulativeAmount: '5000000',
|
|
1606
|
+
signature: await signTestVoucher(channelId, 5000000n),
|
|
1607
|
+
},
|
|
1608
|
+
},
|
|
1609
|
+
request: makeRequest(),
|
|
1610
|
+
})
|
|
1611
|
+
expect(closeReceipt.status).toBe('success')
|
|
1612
|
+
|
|
1613
|
+
const finalized = await store.getChannel(channelId)
|
|
1614
|
+
expect(finalized?.spent).toBe(5000000n)
|
|
1615
|
+
expect(finalized?.finalized).toBe(true)
|
|
1616
|
+
})
|
|
1366
1617
|
})
|
|
1367
1618
|
|
|
1368
1619
|
describe('charge', () => {
|
|
@@ -1845,73 +2096,554 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
1845
2096
|
})
|
|
1846
2097
|
})
|
|
1847
2098
|
|
|
1848
|
-
describe('
|
|
1849
|
-
test('
|
|
1850
|
-
const { channelId } = await createSignedOpenTransaction(10000000n)
|
|
2099
|
+
describe('signature compatibility', () => {
|
|
2100
|
+
test('rejects malformed compact signatures', async () => {
|
|
2101
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
1851
2102
|
const server = createServer()
|
|
1852
2103
|
|
|
1853
|
-
|
|
1854
|
-
|
|
2104
|
+
await server.verify({
|
|
2105
|
+
credential: {
|
|
2106
|
+
challenge: makeChallenge({ id: 'open-baseline', channelId }),
|
|
2107
|
+
payload: {
|
|
2108
|
+
action: 'open' as const,
|
|
2109
|
+
type: 'transaction' as const,
|
|
2110
|
+
channelId,
|
|
2111
|
+
transaction: serializedTransaction,
|
|
2112
|
+
cumulativeAmount: '1000000',
|
|
2113
|
+
signature: await signTestVoucher(channelId, 1000000n),
|
|
2114
|
+
},
|
|
2115
|
+
},
|
|
2116
|
+
request: makeRequest(),
|
|
2117
|
+
})
|
|
2118
|
+
|
|
2119
|
+
const compact = toCompactSignature(await signTestVoucher(channelId, 2000000n))
|
|
2120
|
+
await expect(
|
|
2121
|
+
server.verify({
|
|
1855
2122
|
credential: {
|
|
1856
|
-
challenge: makeChallenge({ channelId }),
|
|
2123
|
+
challenge: makeChallenge({ id: 'voucher-invalid-compact', channelId }),
|
|
1857
2124
|
payload: {
|
|
1858
2125
|
action: 'voucher' as const,
|
|
1859
2126
|
channelId,
|
|
1860
|
-
cumulativeAmount: '
|
|
1861
|
-
signature:
|
|
2127
|
+
cumulativeAmount: '2000000',
|
|
2128
|
+
signature: mutateSignature(compact),
|
|
1862
2129
|
},
|
|
1863
2130
|
},
|
|
1864
2131
|
request: makeRequest(),
|
|
1865
|
-
})
|
|
1866
|
-
|
|
1867
|
-
} catch (e) {
|
|
1868
|
-
expect(e).toBeInstanceOf(ChannelNotFoundError)
|
|
1869
|
-
expect((e as ChannelNotFoundError).status).toBe(410)
|
|
1870
|
-
}
|
|
2132
|
+
}),
|
|
2133
|
+
).rejects.toThrow('invalid voucher signature')
|
|
1871
2134
|
})
|
|
2135
|
+
})
|
|
1872
2136
|
|
|
1873
|
-
|
|
2137
|
+
describe('session-level concurrency', () => {
|
|
2138
|
+
test('concurrent voucher submissions linearize to monotonic final state', async () => {
|
|
1874
2139
|
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
1875
2140
|
const server = createServer()
|
|
1876
2141
|
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
signature: `0x${'ab'.repeat(65)}` as Hex,
|
|
1888
|
-
},
|
|
2142
|
+
await server.verify({
|
|
2143
|
+
credential: {
|
|
2144
|
+
challenge: makeChallenge({ id: 'open-concurrency', channelId }),
|
|
2145
|
+
payload: {
|
|
2146
|
+
action: 'open' as const,
|
|
2147
|
+
type: 'transaction' as const,
|
|
2148
|
+
channelId,
|
|
2149
|
+
transaction: serializedTransaction,
|
|
2150
|
+
cumulativeAmount: '1000000',
|
|
2151
|
+
signature: await signTestVoucher(channelId, 1000000n),
|
|
1889
2152
|
},
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
} catch (e) {
|
|
1894
|
-
expect(e).toBeInstanceOf(InvalidSignatureError)
|
|
1895
|
-
expect((e as InvalidSignatureError).status).toBe(402)
|
|
1896
|
-
}
|
|
1897
|
-
})
|
|
1898
|
-
})
|
|
2153
|
+
},
|
|
2154
|
+
request: makeRequest(),
|
|
2155
|
+
})
|
|
1899
2156
|
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
2157
|
+
const amounts = [2000000n, 3000000n, 4000000n, 5000000n]
|
|
2158
|
+
const results = await Promise.allSettled(
|
|
2159
|
+
amounts.map(async (amount, index) =>
|
|
2160
|
+
server.verify({
|
|
2161
|
+
credential: {
|
|
2162
|
+
challenge: makeChallenge({ id: `voucher-concurrency-${index}`, channelId }),
|
|
2163
|
+
payload: {
|
|
2164
|
+
action: 'voucher' as const,
|
|
2165
|
+
channelId,
|
|
2166
|
+
cumulativeAmount: amount.toString(),
|
|
2167
|
+
signature: await signTestVoucher(channelId, amount),
|
|
2168
|
+
},
|
|
2169
|
+
},
|
|
2170
|
+
request: makeRequest(),
|
|
1907
2171
|
}),
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
expect(
|
|
1913
|
-
|
|
1914
|
-
|
|
2172
|
+
),
|
|
2173
|
+
)
|
|
2174
|
+
|
|
2175
|
+
const fulfilled = results.filter((result) => result.status === 'fulfilled')
|
|
2176
|
+
expect(fulfilled.length).toBeGreaterThan(0)
|
|
2177
|
+
|
|
2178
|
+
const channel = await store.getChannel(channelId)
|
|
2179
|
+
expect(channel?.highestVoucherAmount).toBe(5000000n)
|
|
2180
|
+
expect(channel?.spent).toBe(0n)
|
|
2181
|
+
})
|
|
2182
|
+
})
|
|
2183
|
+
|
|
2184
|
+
describe('fault tolerance', () => {
|
|
2185
|
+
test('recovers after open write crash by replaying open against on-chain state', async () => {
|
|
2186
|
+
const baseStore = Store.memory()
|
|
2187
|
+
const faultStore = withFaultHooks(baseStore, { failPutAt: 1 })
|
|
2188
|
+
const faultServer = createServerWithStore(faultStore)
|
|
2189
|
+
|
|
2190
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
2191
|
+
const openPayload = {
|
|
2192
|
+
action: 'open' as const,
|
|
2193
|
+
type: 'transaction' as const,
|
|
2194
|
+
channelId,
|
|
2195
|
+
transaction: serializedTransaction,
|
|
2196
|
+
cumulativeAmount: '1000000',
|
|
2197
|
+
signature: await signTestVoucher(channelId, 1000000n),
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
await expect(
|
|
2201
|
+
faultServer.verify({
|
|
2202
|
+
credential: {
|
|
2203
|
+
challenge: makeChallenge({ id: 'open-crash-1', channelId }),
|
|
2204
|
+
payload: openPayload,
|
|
2205
|
+
},
|
|
2206
|
+
request: makeRequest(),
|
|
2207
|
+
}),
|
|
2208
|
+
).rejects.toThrow('simulated store crash before persisting')
|
|
2209
|
+
|
|
2210
|
+
const afterCrashStore = ChannelStore.fromStore(baseStore)
|
|
2211
|
+
expect(await afterCrashStore.getChannel(channelId)).toBeNull()
|
|
2212
|
+
|
|
2213
|
+
const healthyServer = createServerWithStore(baseStore)
|
|
2214
|
+
const recovered = await healthyServer.verify({
|
|
2215
|
+
credential: {
|
|
2216
|
+
challenge: makeChallenge({ id: 'open-crash-retry', channelId }),
|
|
2217
|
+
payload: openPayload,
|
|
2218
|
+
},
|
|
2219
|
+
request: makeRequest(),
|
|
2220
|
+
})
|
|
2221
|
+
|
|
2222
|
+
expect(recovered.status).toBe('success')
|
|
2223
|
+
const channel = await afterCrashStore.getChannel(channelId)
|
|
2224
|
+
expect(channel?.highestVoucherAmount).toBe(1000000n)
|
|
2225
|
+
expect(channel?.deposit).toBe(10000000n)
|
|
2226
|
+
})
|
|
2227
|
+
|
|
2228
|
+
test('recovers stale deposit after topUp write crash by reopening from on-chain state', async () => {
|
|
2229
|
+
const baseStore = Store.memory()
|
|
2230
|
+
const healthyServer = createServerWithStore(baseStore)
|
|
2231
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(5000000n)
|
|
2232
|
+
|
|
2233
|
+
await healthyServer.verify({
|
|
2234
|
+
credential: {
|
|
2235
|
+
challenge: makeChallenge({ id: 'open-before-topup-crash', channelId }),
|
|
2236
|
+
payload: {
|
|
2237
|
+
action: 'open' as const,
|
|
2238
|
+
type: 'transaction' as const,
|
|
2239
|
+
channelId,
|
|
2240
|
+
transaction: serializedTransaction,
|
|
2241
|
+
cumulativeAmount: '1000000',
|
|
2242
|
+
signature: await signTestVoucher(channelId, 1000000n),
|
|
2243
|
+
},
|
|
2244
|
+
},
|
|
2245
|
+
request: makeRequest(),
|
|
2246
|
+
})
|
|
2247
|
+
|
|
2248
|
+
const additionalDeposit = 2000000n
|
|
2249
|
+
const { serializedTransaction: topUpTransaction } = await signTopUpChannel({
|
|
2250
|
+
escrow: escrowContract,
|
|
2251
|
+
payer,
|
|
2252
|
+
channelId,
|
|
2253
|
+
token: currency,
|
|
2254
|
+
amount: additionalDeposit,
|
|
2255
|
+
})
|
|
2256
|
+
|
|
2257
|
+
const faultStore = withFaultHooks(baseStore, { failPutAt: 1 })
|
|
2258
|
+
const faultServer = createServerWithStore(faultStore)
|
|
2259
|
+
|
|
2260
|
+
await expect(
|
|
2261
|
+
faultServer.verify({
|
|
2262
|
+
credential: {
|
|
2263
|
+
challenge: makeChallenge({ id: 'topup-crash', channelId }),
|
|
2264
|
+
payload: {
|
|
2265
|
+
action: 'topUp' as const,
|
|
2266
|
+
type: 'transaction' as const,
|
|
2267
|
+
channelId,
|
|
2268
|
+
transaction: topUpTransaction,
|
|
2269
|
+
additionalDeposit: additionalDeposit.toString(),
|
|
2270
|
+
},
|
|
2271
|
+
},
|
|
2272
|
+
request: makeRequest(),
|
|
2273
|
+
}),
|
|
2274
|
+
).rejects.toThrow('simulated store crash before persisting')
|
|
2275
|
+
|
|
2276
|
+
const staleStore = ChannelStore.fromStore(baseStore)
|
|
2277
|
+
expect((await staleStore.getChannel(channelId))?.deposit).toBe(5000000n)
|
|
2278
|
+
|
|
2279
|
+
await healthyServer.verify({
|
|
2280
|
+
credential: {
|
|
2281
|
+
challenge: makeChallenge({ id: 'reopen-after-topup-crash', channelId }),
|
|
2282
|
+
payload: {
|
|
2283
|
+
action: 'open' as const,
|
|
2284
|
+
type: 'transaction' as const,
|
|
2285
|
+
channelId,
|
|
2286
|
+
transaction: serializedTransaction,
|
|
2287
|
+
cumulativeAmount: '2000000',
|
|
2288
|
+
signature: await signTestVoucher(channelId, 2000000n),
|
|
2289
|
+
},
|
|
2290
|
+
},
|
|
2291
|
+
request: makeRequest(),
|
|
2292
|
+
})
|
|
2293
|
+
|
|
2294
|
+
const recoveredChannel = await staleStore.getChannel(channelId)
|
|
2295
|
+
expect(recoveredChannel?.deposit).toBe(7000000n)
|
|
2296
|
+
})
|
|
2297
|
+
|
|
2298
|
+
test('voucher rejects when channel disappears between read and update', async () => {
|
|
2299
|
+
const baseStore = Store.memory()
|
|
2300
|
+
const hooks = withReadDropHooks(baseStore)
|
|
2301
|
+
const server = createServerWithStore(hooks.store)
|
|
2302
|
+
|
|
2303
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(5000000n)
|
|
2304
|
+
await server.verify({
|
|
2305
|
+
credential: {
|
|
2306
|
+
challenge: makeChallenge({ id: 'open-racy-voucher', channelId }),
|
|
2307
|
+
payload: {
|
|
2308
|
+
action: 'open' as const,
|
|
2309
|
+
type: 'transaction' as const,
|
|
2310
|
+
channelId,
|
|
2311
|
+
transaction: serializedTransaction,
|
|
2312
|
+
cumulativeAmount: '1000000',
|
|
2313
|
+
signature: await signTestVoucher(channelId, 1000000n),
|
|
2314
|
+
},
|
|
2315
|
+
},
|
|
2316
|
+
request: makeRequest(),
|
|
2317
|
+
})
|
|
2318
|
+
|
|
2319
|
+
hooks.dropOnRead(channelId, 1)
|
|
2320
|
+
await expect(
|
|
2321
|
+
server.verify({
|
|
2322
|
+
credential: {
|
|
2323
|
+
challenge: makeChallenge({ id: 'voucher-racy-missing', channelId }),
|
|
2324
|
+
payload: {
|
|
2325
|
+
action: 'voucher' as const,
|
|
2326
|
+
channelId,
|
|
2327
|
+
cumulativeAmount: '2000000',
|
|
2328
|
+
signature: await signTestVoucher(channelId, 2000000n),
|
|
2329
|
+
},
|
|
2330
|
+
},
|
|
2331
|
+
request: makeRequest(),
|
|
2332
|
+
}),
|
|
2333
|
+
).rejects.toThrow('channel not found')
|
|
2334
|
+
|
|
2335
|
+
const persisted = await ChannelStore.fromStore(baseStore).getChannel(channelId)
|
|
2336
|
+
expect(persisted).not.toBeNull()
|
|
2337
|
+
})
|
|
2338
|
+
|
|
2339
|
+
test('close still returns a receipt when channel disappears before final write', async () => {
|
|
2340
|
+
const baseStore = Store.memory()
|
|
2341
|
+
const hooks = withReadDropHooks(baseStore)
|
|
2342
|
+
const server = createServerWithStore(hooks.store)
|
|
2343
|
+
|
|
2344
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(5000000n)
|
|
2345
|
+
await server.verify({
|
|
2346
|
+
credential: {
|
|
2347
|
+
challenge: makeChallenge({ id: 'open-racy-close', channelId }),
|
|
2348
|
+
payload: {
|
|
2349
|
+
action: 'open' as const,
|
|
2350
|
+
type: 'transaction' as const,
|
|
2351
|
+
channelId,
|
|
2352
|
+
transaction: serializedTransaction,
|
|
2353
|
+
cumulativeAmount: '1000000',
|
|
2354
|
+
signature: await signTestVoucher(channelId, 1000000n),
|
|
2355
|
+
},
|
|
2356
|
+
},
|
|
2357
|
+
request: makeRequest(),
|
|
2358
|
+
})
|
|
2359
|
+
|
|
2360
|
+
hooks.dropOnRead(channelId, 1)
|
|
2361
|
+
const closeReceipt = (await server.verify({
|
|
2362
|
+
credential: {
|
|
2363
|
+
challenge: makeChallenge({ id: 'close-racy-missing', channelId }),
|
|
2364
|
+
payload: {
|
|
2365
|
+
action: 'close' as const,
|
|
2366
|
+
channelId,
|
|
2367
|
+
cumulativeAmount: '1000000',
|
|
2368
|
+
signature: await signTestVoucher(channelId, 1000000n),
|
|
2369
|
+
},
|
|
2370
|
+
},
|
|
2371
|
+
request: makeRequest(),
|
|
2372
|
+
})) as SessionReceipt
|
|
2373
|
+
|
|
2374
|
+
expect(closeReceipt.status).toBe('success')
|
|
2375
|
+
expect(closeReceipt.spent).toBe('0')
|
|
2376
|
+
const persisted = await ChannelStore.fromStore(baseStore).getChannel(channelId)
|
|
2377
|
+
expect(persisted).toBeNull()
|
|
2378
|
+
})
|
|
2379
|
+
|
|
2380
|
+
test('settle returns txHash even when channel disappears before settle write', async () => {
|
|
2381
|
+
const baseStore = Store.memory()
|
|
2382
|
+
const hooks = withReadDropHooks(baseStore)
|
|
2383
|
+
const server = createServerWithStore(hooks.store)
|
|
2384
|
+
|
|
2385
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(5000000n)
|
|
2386
|
+
await server.verify({
|
|
2387
|
+
credential: {
|
|
2388
|
+
challenge: makeChallenge({ id: 'open-racy-settle', channelId }),
|
|
2389
|
+
payload: {
|
|
2390
|
+
action: 'open' as const,
|
|
2391
|
+
type: 'transaction' as const,
|
|
2392
|
+
channelId,
|
|
2393
|
+
transaction: serializedTransaction,
|
|
2394
|
+
cumulativeAmount: '1000000',
|
|
2395
|
+
signature: await signTestVoucher(channelId, 1000000n),
|
|
2396
|
+
},
|
|
2397
|
+
},
|
|
2398
|
+
request: makeRequest(),
|
|
2399
|
+
})
|
|
2400
|
+
|
|
2401
|
+
hooks.dropOnRead(channelId, 1)
|
|
2402
|
+
const txHash = await settle(ChannelStore.fromStore(hooks.store), client, channelId, {
|
|
2403
|
+
escrowContract,
|
|
2404
|
+
})
|
|
2405
|
+
|
|
2406
|
+
expect(txHash).toBeDefined()
|
|
2407
|
+
const persisted = await ChannelStore.fromStore(baseStore).getChannel(channelId)
|
|
2408
|
+
expect(persisted).toBeNull()
|
|
2409
|
+
})
|
|
2410
|
+
|
|
2411
|
+
test('close rejects when channel was already finalized on-chain', async () => {
|
|
2412
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(5000000n)
|
|
2413
|
+
const server = createServer()
|
|
2414
|
+
|
|
2415
|
+
await server.verify({
|
|
2416
|
+
credential: {
|
|
2417
|
+
challenge: makeChallenge({ id: 'open-before-external-close', channelId }),
|
|
2418
|
+
payload: {
|
|
2419
|
+
action: 'open' as const,
|
|
2420
|
+
type: 'transaction' as const,
|
|
2421
|
+
channelId,
|
|
2422
|
+
transaction: serializedTransaction,
|
|
2423
|
+
cumulativeAmount: '1000000',
|
|
2424
|
+
signature: await signTestVoucher(channelId, 1000000n),
|
|
2425
|
+
},
|
|
2426
|
+
},
|
|
2427
|
+
request: makeRequest(),
|
|
2428
|
+
})
|
|
2429
|
+
|
|
2430
|
+
const closeSignature = await signTestVoucher(channelId, 1000000n)
|
|
2431
|
+
await closeChannelOnChain({
|
|
2432
|
+
escrow: escrowContract,
|
|
2433
|
+
payee: accounts[0],
|
|
2434
|
+
channelId,
|
|
2435
|
+
cumulativeAmount: 1000000n,
|
|
2436
|
+
signature: closeSignature,
|
|
2437
|
+
})
|
|
2438
|
+
|
|
2439
|
+
await expect(
|
|
2440
|
+
server.verify({
|
|
2441
|
+
credential: {
|
|
2442
|
+
challenge: makeChallenge({ id: 'close-after-external-finalize', channelId }),
|
|
2443
|
+
payload: {
|
|
2444
|
+
action: 'close' as const,
|
|
2445
|
+
channelId,
|
|
2446
|
+
cumulativeAmount: '1000000',
|
|
2447
|
+
signature: closeSignature,
|
|
2448
|
+
},
|
|
2449
|
+
},
|
|
2450
|
+
request: makeRequest(),
|
|
2451
|
+
}),
|
|
2452
|
+
).rejects.toThrow('channel is finalized on-chain')
|
|
2453
|
+
})
|
|
2454
|
+
})
|
|
2455
|
+
|
|
2456
|
+
describe('protocol compatibility', () => {
|
|
2457
|
+
test('HEAD voucher management request falls through to content handler', () => {
|
|
2458
|
+
const server = createServer()
|
|
2459
|
+
const response = server.respond!({
|
|
2460
|
+
credential: {
|
|
2461
|
+
challenge: makeChallenge({
|
|
2462
|
+
channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
|
|
2463
|
+
}),
|
|
2464
|
+
payload: { action: 'voucher' },
|
|
2465
|
+
},
|
|
2466
|
+
input: new Request('https://api.example.com/resource', { method: 'HEAD' }),
|
|
2467
|
+
} as never)
|
|
2468
|
+
|
|
2469
|
+
expect(response).toBeUndefined()
|
|
2470
|
+
})
|
|
2471
|
+
|
|
2472
|
+
test('ignores unknown challenge and credential fields for forward compatibility', async () => {
|
|
2473
|
+
const challenge = Challenge.from({
|
|
2474
|
+
id: 'forward-compat',
|
|
2475
|
+
realm: 'api.example.com',
|
|
2476
|
+
method: 'tempo',
|
|
2477
|
+
intent: 'session',
|
|
2478
|
+
request: {
|
|
2479
|
+
amount: '1000000',
|
|
2480
|
+
currency: '0x20c0000000000000000000000000000000000001',
|
|
2481
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
2482
|
+
unitType: 'token',
|
|
2483
|
+
},
|
|
2484
|
+
})
|
|
2485
|
+
const parsed = Challenge.deserialize(`${Challenge.serialize(challenge)}, future="v1"`)
|
|
2486
|
+
expect(parsed.id).toBe(challenge.id)
|
|
2487
|
+
|
|
2488
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
2489
|
+
const handler = createHandler()
|
|
2490
|
+
const route = handler.session({ amount: '1', decimals: 6, unitType: 'token' })
|
|
2491
|
+
|
|
2492
|
+
const first = await route(new Request('https://api.example.com/resource'))
|
|
2493
|
+
if (first.status !== 402) throw new Error('expected challenge')
|
|
2494
|
+
const issuedChallenge = Challenge.fromResponse(first.challenge)
|
|
2495
|
+
const signature = await signTestVoucher(channelId, 1000000n)
|
|
2496
|
+
|
|
2497
|
+
const header = Credential.serialize({
|
|
2498
|
+
challenge: issuedChallenge,
|
|
2499
|
+
payload: {
|
|
2500
|
+
action: 'open',
|
|
2501
|
+
type: 'transaction',
|
|
2502
|
+
channelId,
|
|
2503
|
+
transaction: serializedTransaction,
|
|
2504
|
+
cumulativeAmount: '1000000',
|
|
2505
|
+
signature,
|
|
2506
|
+
},
|
|
2507
|
+
})
|
|
2508
|
+
const encoded = header.replace(/^Payment\s+/i, '')
|
|
2509
|
+
const decoded = JSON.parse(Base64.toString(encoded)) as Record<string, any>
|
|
2510
|
+
decoded.payload.futureField = { enabled: true }
|
|
2511
|
+
decoded.unrecognized = 'ignored'
|
|
2512
|
+
const mutatedHeader = `Payment ${Base64.fromString(JSON.stringify(decoded), { url: true, pad: false })}`
|
|
2513
|
+
|
|
2514
|
+
const second = await route(
|
|
2515
|
+
new Request('https://api.example.com/resource', {
|
|
2516
|
+
headers: { Authorization: mutatedHeader },
|
|
2517
|
+
}),
|
|
2518
|
+
)
|
|
2519
|
+
expect(second.status).toBe(200)
|
|
2520
|
+
})
|
|
2521
|
+
|
|
2522
|
+
test('does not return Payment-Receipt on verification errors', async () => {
|
|
2523
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
2524
|
+
const handler = createHandler()
|
|
2525
|
+
const route = handler.session({ amount: '1', decimals: 6, unitType: 'token' })
|
|
2526
|
+
|
|
2527
|
+
const first = await route(new Request('https://api.example.com/resource'))
|
|
2528
|
+
if (first.status !== 402) throw new Error('expected challenge')
|
|
2529
|
+
const issuedChallenge = Challenge.fromResponse(first.challenge)
|
|
2530
|
+
|
|
2531
|
+
const invalidCredential = Credential.serialize({
|
|
2532
|
+
challenge: issuedChallenge,
|
|
2533
|
+
payload: {
|
|
2534
|
+
action: 'open',
|
|
2535
|
+
type: 'transaction',
|
|
2536
|
+
channelId,
|
|
2537
|
+
transaction: serializedTransaction,
|
|
2538
|
+
cumulativeAmount: '1000000',
|
|
2539
|
+
signature: `0x${'ab'.repeat(65)}`,
|
|
2540
|
+
},
|
|
2541
|
+
})
|
|
2542
|
+
|
|
2543
|
+
const second = await route(
|
|
2544
|
+
new Request('https://api.example.com/resource', {
|
|
2545
|
+
headers: { Authorization: invalidCredential },
|
|
2546
|
+
}),
|
|
2547
|
+
)
|
|
2548
|
+
|
|
2549
|
+
expect(second.status).toBe(402)
|
|
2550
|
+
if (second.status !== 402) throw new Error('expected challenge')
|
|
2551
|
+
expect(second.challenge.headers.get('Payment-Receipt')).toBeNull()
|
|
2552
|
+
})
|
|
2553
|
+
|
|
2554
|
+
test('converts amount/suggestedDeposit/minVoucherDelta with decimals=18', async () => {
|
|
2555
|
+
const handler = createHandler()
|
|
2556
|
+
const route = handler.session({
|
|
2557
|
+
amount: '0.000000000000000001',
|
|
2558
|
+
suggestedDeposit: '0.000000000000000002',
|
|
2559
|
+
minVoucherDelta: '0.000000000000000001',
|
|
2560
|
+
decimals: 18,
|
|
2561
|
+
unitType: 'token',
|
|
2562
|
+
})
|
|
2563
|
+
|
|
2564
|
+
const result = await route(new Request('https://api.example.com/resource'))
|
|
2565
|
+
expect(result.status).toBe(402)
|
|
2566
|
+
if (result.status !== 402) throw new Error('expected challenge')
|
|
2567
|
+
|
|
2568
|
+
const challenge = Challenge.fromResponse(result.challenge)
|
|
2569
|
+
const request = challenge.request as {
|
|
2570
|
+
amount: string
|
|
2571
|
+
suggestedDeposit: string
|
|
2572
|
+
methodDetails: { minVoucherDelta: string }
|
|
2573
|
+
}
|
|
2574
|
+
expect(request.amount).toBe('1')
|
|
2575
|
+
expect(request.suggestedDeposit).toBe('2')
|
|
2576
|
+
expect(request.methodDetails.minVoucherDelta).toBe('1')
|
|
2577
|
+
})
|
|
2578
|
+
})
|
|
2579
|
+
|
|
2580
|
+
describe('structured errors', () => {
|
|
2581
|
+
test('ChannelNotFoundError on unknown channel', async () => {
|
|
2582
|
+
const { channelId } = await createSignedOpenTransaction(10000000n)
|
|
2583
|
+
const server = createServer()
|
|
2584
|
+
|
|
2585
|
+
try {
|
|
2586
|
+
await server.verify({
|
|
2587
|
+
credential: {
|
|
2588
|
+
challenge: makeChallenge({ channelId }),
|
|
2589
|
+
payload: {
|
|
2590
|
+
action: 'voucher' as const,
|
|
2591
|
+
channelId,
|
|
2592
|
+
cumulativeAmount: '1000000',
|
|
2593
|
+
signature: await signTestVoucher(channelId, 1000000n),
|
|
2594
|
+
},
|
|
2595
|
+
},
|
|
2596
|
+
request: makeRequest(),
|
|
2597
|
+
})
|
|
2598
|
+
expect.unreachable()
|
|
2599
|
+
} catch (e) {
|
|
2600
|
+
expect(e).toBeInstanceOf(ChannelNotFoundError)
|
|
2601
|
+
expect((e as ChannelNotFoundError).status).toBe(410)
|
|
2602
|
+
}
|
|
2603
|
+
})
|
|
2604
|
+
|
|
2605
|
+
test('InvalidSignatureError has status 402', async () => {
|
|
2606
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
2607
|
+
const server = createServer()
|
|
2608
|
+
|
|
2609
|
+
try {
|
|
2610
|
+
await server.verify({
|
|
2611
|
+
credential: {
|
|
2612
|
+
challenge: makeChallenge({ channelId }),
|
|
2613
|
+
payload: {
|
|
2614
|
+
action: 'open' as const,
|
|
2615
|
+
type: 'transaction' as const,
|
|
2616
|
+
channelId,
|
|
2617
|
+
transaction: serializedTransaction,
|
|
2618
|
+
cumulativeAmount: '1000000',
|
|
2619
|
+
signature: `0x${'ab'.repeat(65)}` as Hex,
|
|
2620
|
+
},
|
|
2621
|
+
},
|
|
2622
|
+
request: makeRequest(),
|
|
2623
|
+
})
|
|
2624
|
+
expect.unreachable()
|
|
2625
|
+
} catch (e) {
|
|
2626
|
+
expect(e).toBeInstanceOf(InvalidSignatureError)
|
|
2627
|
+
expect((e as InvalidSignatureError).status).toBe(402)
|
|
2628
|
+
}
|
|
2629
|
+
})
|
|
2630
|
+
})
|
|
2631
|
+
|
|
2632
|
+
describe('respond', () => {
|
|
2633
|
+
test('returns 204 for POST with open action', () => {
|
|
2634
|
+
const server = createServer()
|
|
2635
|
+
const result = server.respond!({
|
|
2636
|
+
credential: {
|
|
2637
|
+
challenge: makeChallenge({
|
|
2638
|
+
channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
|
|
2639
|
+
}),
|
|
2640
|
+
payload: { action: 'open' },
|
|
2641
|
+
},
|
|
2642
|
+
input: new Request('http://localhost', { method: 'POST' }),
|
|
2643
|
+
} as any)
|
|
2644
|
+
expect(result).toBeInstanceOf(Response)
|
|
2645
|
+
expect((result as Response).status).toBe(204)
|
|
2646
|
+
})
|
|
1915
2647
|
|
|
1916
2648
|
test('returns 204 for POST with topUp action', () => {
|
|
1917
2649
|
const server = createServer()
|
|
@@ -2175,6 +2907,158 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
2175
2907
|
}
|
|
2176
2908
|
})
|
|
2177
2909
|
|
|
2910
|
+
test('open -> stream -> need-voucher -> resume -> close', async () => {
|
|
2911
|
+
const backingStore = Store.memory()
|
|
2912
|
+
const routeHandler = Mppx_server.create({
|
|
2913
|
+
methods: [
|
|
2914
|
+
tempo_server.session({
|
|
2915
|
+
store: backingStore,
|
|
2916
|
+
getClient: () => client,
|
|
2917
|
+
account: recipientAccount,
|
|
2918
|
+
currency,
|
|
2919
|
+
escrowContract,
|
|
2920
|
+
chainId: chain.id,
|
|
2921
|
+
sse: true,
|
|
2922
|
+
}),
|
|
2923
|
+
],
|
|
2924
|
+
realm: 'api.example.com',
|
|
2925
|
+
secretKey: 'secret',
|
|
2926
|
+
}).session({ amount: '1', decimals: 6, unitType: 'token' })
|
|
2927
|
+
|
|
2928
|
+
let voucherPosts = 0
|
|
2929
|
+
const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
2930
|
+
const request = new Request(input, init)
|
|
2931
|
+
let action: 'open' | 'topUp' | 'voucher' | 'close' | undefined
|
|
2932
|
+
|
|
2933
|
+
if (request.method === 'POST' && request.headers.has('Authorization')) {
|
|
2934
|
+
try {
|
|
2935
|
+
const credential = Credential.fromRequest<any>(request)
|
|
2936
|
+
action = credential.payload?.action
|
|
2937
|
+
if (action === 'voucher') voucherPosts++
|
|
2938
|
+
} catch {}
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
const result = await routeHandler(request)
|
|
2942
|
+
if (result.status === 402) return result.challenge
|
|
2943
|
+
|
|
2944
|
+
if (action === 'voucher') {
|
|
2945
|
+
return new Response(null, { status: 200 })
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
if (request.headers.get('Accept')?.includes('text/event-stream')) {
|
|
2949
|
+
return result.withReceipt(async function* (stream) {
|
|
2950
|
+
await stream.charge()
|
|
2951
|
+
yield 'chunk-1'
|
|
2952
|
+
await stream.charge()
|
|
2953
|
+
yield 'chunk-2'
|
|
2954
|
+
await stream.charge()
|
|
2955
|
+
yield 'chunk-3'
|
|
2956
|
+
})
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
return result.withReceipt(new Response('ok'))
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
const manager = sessionManager({
|
|
2963
|
+
account: payer,
|
|
2964
|
+
client,
|
|
2965
|
+
escrowContract,
|
|
2966
|
+
fetch,
|
|
2967
|
+
maxDeposit: '3',
|
|
2968
|
+
})
|
|
2969
|
+
|
|
2970
|
+
const chunks: string[] = []
|
|
2971
|
+
const stream = await manager.sse('https://api.example.com/stream')
|
|
2972
|
+
for await (const chunk of stream) chunks.push(chunk)
|
|
2973
|
+
|
|
2974
|
+
expect(chunks).toEqual(['chunk-1', 'chunk-2', 'chunk-3'])
|
|
2975
|
+
expect(voucherPosts).toBeGreaterThan(0)
|
|
2976
|
+
|
|
2977
|
+
const closeReceipt = await manager.close()
|
|
2978
|
+
expect(closeReceipt?.status).toBe('success')
|
|
2979
|
+
expect(closeReceipt?.spent).toBe('3000000')
|
|
2980
|
+
|
|
2981
|
+
const channelId = manager.channelId
|
|
2982
|
+
expect(channelId).toBeTruthy()
|
|
2983
|
+
|
|
2984
|
+
const persisted = await ChannelStore.fromStore(backingStore).getChannel(channelId!)
|
|
2985
|
+
expect(persisted?.finalized).toBe(true)
|
|
2986
|
+
})
|
|
2987
|
+
|
|
2988
|
+
test('handles repeated exhaustion/resume cycles within one stream', async () => {
|
|
2989
|
+
const backingStore = Store.memory()
|
|
2990
|
+
const routeHandler = Mppx_server.create({
|
|
2991
|
+
methods: [
|
|
2992
|
+
tempo_server.session({
|
|
2993
|
+
store: backingStore,
|
|
2994
|
+
getClient: () => client,
|
|
2995
|
+
account: recipientAccount,
|
|
2996
|
+
currency,
|
|
2997
|
+
escrowContract,
|
|
2998
|
+
chainId: chain.id,
|
|
2999
|
+
sse: true,
|
|
3000
|
+
}),
|
|
3001
|
+
],
|
|
3002
|
+
realm: 'api.example.com',
|
|
3003
|
+
secretKey: 'secret',
|
|
3004
|
+
}).session({ amount: '1', decimals: 6, unitType: 'token' })
|
|
3005
|
+
|
|
3006
|
+
let voucherPosts = 0
|
|
3007
|
+
const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
3008
|
+
const request = new Request(input, init)
|
|
3009
|
+
let action: 'open' | 'topUp' | 'voucher' | 'close' | undefined
|
|
3010
|
+
|
|
3011
|
+
if (request.method === 'POST' && request.headers.has('Authorization')) {
|
|
3012
|
+
try {
|
|
3013
|
+
const credential = Credential.fromRequest<any>(request)
|
|
3014
|
+
action = credential.payload?.action
|
|
3015
|
+
if (action === 'voucher') voucherPosts++
|
|
3016
|
+
} catch {}
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
const result = await routeHandler(request)
|
|
3020
|
+
if (result.status === 402) return result.challenge
|
|
3021
|
+
|
|
3022
|
+
if (action === 'voucher') {
|
|
3023
|
+
return new Response(null, { status: 200 })
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
if (request.headers.get('Accept')?.includes('text/event-stream')) {
|
|
3027
|
+
return result.withReceipt(async function* (stream) {
|
|
3028
|
+
await stream.charge()
|
|
3029
|
+
yield 'chunk-1'
|
|
3030
|
+
await stream.charge()
|
|
3031
|
+
yield 'chunk-2'
|
|
3032
|
+
await stream.charge()
|
|
3033
|
+
yield 'chunk-3'
|
|
3034
|
+
await stream.charge()
|
|
3035
|
+
yield 'chunk-4'
|
|
3036
|
+
})
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
return result.withReceipt(new Response('ok'))
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
const manager = sessionManager({
|
|
3043
|
+
account: payer,
|
|
3044
|
+
client,
|
|
3045
|
+
escrowContract,
|
|
3046
|
+
fetch,
|
|
3047
|
+
maxDeposit: '4',
|
|
3048
|
+
})
|
|
3049
|
+
|
|
3050
|
+
const chunks: string[] = []
|
|
3051
|
+
const stream = await manager.sse('https://api.example.com/stream')
|
|
3052
|
+
for await (const chunk of stream) chunks.push(chunk)
|
|
3053
|
+
|
|
3054
|
+
expect(chunks).toEqual(['chunk-1', 'chunk-2', 'chunk-3', 'chunk-4'])
|
|
3055
|
+
expect(voucherPosts).toBeGreaterThanOrEqual(2)
|
|
3056
|
+
|
|
3057
|
+
const closeReceipt = await manager.close()
|
|
3058
|
+
expect(closeReceipt?.status).toBe('success')
|
|
3059
|
+
expect(closeReceipt?.spent).toBe('4000000')
|
|
3060
|
+
})
|
|
3061
|
+
|
|
2178
3062
|
test('behavior: charge withReceipt returns Response', async () => {
|
|
2179
3063
|
const handler = Mppx_server.create({
|
|
2180
3064
|
methods: [tempo_server.charge({ account: accounts[0], currency: asset })],
|
|
@@ -2285,6 +3169,166 @@ describe('monotonicity and TOCTOU (unit tests)', () => {
|
|
|
2285
3169
|
})
|
|
2286
3170
|
})
|
|
2287
3171
|
|
|
3172
|
+
describe('session request and verify guardrails', () => {
|
|
3173
|
+
const accountOne = accounts[0]
|
|
3174
|
+
const addressTwo = '0x0000000000000000000000000000000000000002' as Address
|
|
3175
|
+
const defaultCurrency = '0x20c0000000000000000000000000000000000000'
|
|
3176
|
+
const defaultEscrow = '0x0000000000000000000000000000000000000003'
|
|
3177
|
+
|
|
3178
|
+
function createMockClient(chainId: number) {
|
|
3179
|
+
return createClient({
|
|
3180
|
+
chain: {
|
|
3181
|
+
id: chainId,
|
|
3182
|
+
name: `Mock Chain ${chainId}`,
|
|
3183
|
+
nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 },
|
|
3184
|
+
rpcUrls: { default: { http: ['http://localhost:1'] } },
|
|
3185
|
+
},
|
|
3186
|
+
transport: http('http://localhost:1'),
|
|
3187
|
+
})
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
function makeRequest(overrides: Partial<Record<string, unknown>> = {}) {
|
|
3191
|
+
return {
|
|
3192
|
+
amount: '1',
|
|
3193
|
+
unitType: 'token',
|
|
3194
|
+
currency: defaultCurrency,
|
|
3195
|
+
decimals: 6,
|
|
3196
|
+
recipient: addressTwo,
|
|
3197
|
+
chainId: 4217,
|
|
3198
|
+
...overrides,
|
|
3199
|
+
}
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
test('request throws when no client exists for requested chain', async () => {
|
|
3203
|
+
const server = session({
|
|
3204
|
+
store: Store.memory(),
|
|
3205
|
+
account: accountOne,
|
|
3206
|
+
currency: defaultCurrency,
|
|
3207
|
+
getClient: async () => {
|
|
3208
|
+
throw new Error('unreachable chain')
|
|
3209
|
+
},
|
|
3210
|
+
} as session.Parameters)
|
|
3211
|
+
|
|
3212
|
+
await expect(
|
|
3213
|
+
server.request!({
|
|
3214
|
+
credential: null,
|
|
3215
|
+
request: makeRequest({ chainId: 31337 }),
|
|
3216
|
+
} as never),
|
|
3217
|
+
).rejects.toThrow('No client configured with chainId 31337.')
|
|
3218
|
+
})
|
|
3219
|
+
|
|
3220
|
+
test('request throws when resolved client chain mismatches requested chain', async () => {
|
|
3221
|
+
const wrongChainClient = createMockClient(42431)
|
|
3222
|
+
const server = session({
|
|
3223
|
+
store: Store.memory(),
|
|
3224
|
+
account: accountOne,
|
|
3225
|
+
currency: defaultCurrency,
|
|
3226
|
+
getClient: async () => wrongChainClient,
|
|
3227
|
+
} as session.Parameters)
|
|
3228
|
+
|
|
3229
|
+
await expect(
|
|
3230
|
+
server.request!({
|
|
3231
|
+
credential: null,
|
|
3232
|
+
request: makeRequest({ chainId: 4217 }),
|
|
3233
|
+
} as never),
|
|
3234
|
+
).rejects.toThrow('Client not configured with chainId 4217.')
|
|
3235
|
+
})
|
|
3236
|
+
|
|
3237
|
+
test('request normalizes fee-payer to boolean for challenge issuance and account for verification', async () => {
|
|
3238
|
+
const client = createMockClient(4217)
|
|
3239
|
+
const server = session({
|
|
3240
|
+
store: Store.memory(),
|
|
3241
|
+
account: accountOne,
|
|
3242
|
+
currency: defaultCurrency,
|
|
3243
|
+
feePayer: 'https://fee-payer.example.com',
|
|
3244
|
+
getClient: async () => client,
|
|
3245
|
+
} as session.Parameters)
|
|
3246
|
+
|
|
3247
|
+
const challengeRequest = await server.request!({
|
|
3248
|
+
credential: null,
|
|
3249
|
+
request: makeRequest(),
|
|
3250
|
+
} as never)
|
|
3251
|
+
expect(challengeRequest.feePayer).toBe(true)
|
|
3252
|
+
|
|
3253
|
+
const verificationRequest = await server.request!({
|
|
3254
|
+
credential: { challenge: {}, payload: {} } as never,
|
|
3255
|
+
request: makeRequest({ feePayer: accounts[1] }),
|
|
3256
|
+
} as never)
|
|
3257
|
+
expect(verificationRequest.feePayer).toBe(accounts[1])
|
|
3258
|
+
})
|
|
3259
|
+
|
|
3260
|
+
test('request allows callers to explicitly disable fee-payer', async () => {
|
|
3261
|
+
const client = createMockClient(4217)
|
|
3262
|
+
const server = session({
|
|
3263
|
+
store: Store.memory(),
|
|
3264
|
+
account: accountOne,
|
|
3265
|
+
currency: defaultCurrency,
|
|
3266
|
+
feePayer: 'https://fee-payer.example.com',
|
|
3267
|
+
getClient: async () => client,
|
|
3268
|
+
} as session.Parameters)
|
|
3269
|
+
|
|
3270
|
+
const normalized = await server.request!({
|
|
3271
|
+
credential: null,
|
|
3272
|
+
request: makeRequest({ feePayer: false }),
|
|
3273
|
+
} as never)
|
|
3274
|
+
expect(normalized.feePayer).toBeUndefined()
|
|
3275
|
+
})
|
|
3276
|
+
|
|
3277
|
+
test('request leaves escrowContract undefined when chain has no configured default', async () => {
|
|
3278
|
+
const unknownChainId = 999_999
|
|
3279
|
+
const client = createMockClient(unknownChainId)
|
|
3280
|
+
const server = session({
|
|
3281
|
+
store: Store.memory(),
|
|
3282
|
+
account: accountOne,
|
|
3283
|
+
currency: defaultCurrency,
|
|
3284
|
+
getClient: async () => client,
|
|
3285
|
+
} as session.Parameters)
|
|
3286
|
+
|
|
3287
|
+
const normalized = await server.request!({
|
|
3288
|
+
credential: null,
|
|
3289
|
+
request: makeRequest({ chainId: unknownChainId }),
|
|
3290
|
+
} as never)
|
|
3291
|
+
|
|
3292
|
+
expect(normalized.escrowContract).toBeUndefined()
|
|
3293
|
+
})
|
|
3294
|
+
|
|
3295
|
+
test('verify rejects unknown session actions', async () => {
|
|
3296
|
+
const client = createMockClient(4217)
|
|
3297
|
+
const server = session({
|
|
3298
|
+
store: Store.memory(),
|
|
3299
|
+
account: accountOne,
|
|
3300
|
+
currency: defaultCurrency,
|
|
3301
|
+
getClient: async () => client,
|
|
3302
|
+
escrowContract: defaultEscrow,
|
|
3303
|
+
chainId: 4217,
|
|
3304
|
+
} as session.Parameters)
|
|
3305
|
+
|
|
3306
|
+
await expect(
|
|
3307
|
+
server.verify({
|
|
3308
|
+
credential: {
|
|
3309
|
+
challenge: {
|
|
3310
|
+
id: 'guard-unknown-action',
|
|
3311
|
+
realm: 'api.example.com',
|
|
3312
|
+
method: 'tempo',
|
|
3313
|
+
intent: 'session',
|
|
3314
|
+
request: {
|
|
3315
|
+
amount: '1000000',
|
|
3316
|
+
currency: defaultCurrency,
|
|
3317
|
+
recipient: addressTwo,
|
|
3318
|
+
methodDetails: {
|
|
3319
|
+
chainId: 4217,
|
|
3320
|
+
escrowContract: defaultEscrow,
|
|
3321
|
+
},
|
|
3322
|
+
},
|
|
3323
|
+
},
|
|
3324
|
+
payload: { action: 'rewind' },
|
|
3325
|
+
},
|
|
3326
|
+
request: makeRequest(),
|
|
3327
|
+
} as never),
|
|
3328
|
+
).rejects.toThrow('unknown action: rewind')
|
|
3329
|
+
})
|
|
3330
|
+
})
|
|
3331
|
+
|
|
2288
3332
|
describe('session default currency resolution', () => {
|
|
2289
3333
|
const mockAccount = accounts[0]
|
|
2290
3334
|
const mockClient = createClient({ transport: http('http://localhost:1') })
|
|
@@ -2509,10 +3553,14 @@ function makeRequest() {
|
|
|
2509
3553
|
}
|
|
2510
3554
|
}
|
|
2511
3555
|
|
|
2512
|
-
async function signTestVoucher(
|
|
3556
|
+
async function signTestVoucher(
|
|
3557
|
+
channelId: Hex,
|
|
3558
|
+
amount: bigint,
|
|
3559
|
+
account: (typeof accounts)[number] = payer,
|
|
3560
|
+
) {
|
|
2513
3561
|
return signVoucher(
|
|
2514
3562
|
client,
|
|
2515
|
-
|
|
3563
|
+
account,
|
|
2516
3564
|
{ channelId, cumulativeAmount: amount },
|
|
2517
3565
|
escrowContract,
|
|
2518
3566
|
chain.id,
|
|
@@ -2535,3 +3583,53 @@ async function createSignedOpenTransaction(
|
|
|
2535
3583
|
})
|
|
2536
3584
|
return { channelId, serializedTransaction }
|
|
2537
3585
|
}
|
|
3586
|
+
|
|
3587
|
+
function toCompactSignature(signature: Hex): Hex {
|
|
3588
|
+
const compact = signatureToCompactSignature(parseSignature(signature))
|
|
3589
|
+
return serializeCompactSignature(compact)
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
function mutateSignature(signature: Hex): Hex {
|
|
3593
|
+
const last = signature.at(-1)
|
|
3594
|
+
const replacement = last === '0' ? '1' : '0'
|
|
3595
|
+
return `${signature.slice(0, -1)}${replacement}` as Hex
|
|
3596
|
+
}
|
|
3597
|
+
|
|
3598
|
+
function withFaultHooks(store: Store.Store, options: { failPutAt: number }) {
|
|
3599
|
+
let putCalls = 0
|
|
3600
|
+
return Store.from({
|
|
3601
|
+
get: (key) => store.get(key),
|
|
3602
|
+
delete: (key) => store.delete(key),
|
|
3603
|
+
put: async (key, value) => {
|
|
3604
|
+
putCalls++
|
|
3605
|
+
if (putCalls === options.failPutAt)
|
|
3606
|
+
throw new Error(`simulated store crash before persisting key ${key}`)
|
|
3607
|
+
await store.put(key, value)
|
|
3608
|
+
},
|
|
3609
|
+
})
|
|
3610
|
+
}
|
|
3611
|
+
|
|
3612
|
+
function withReadDropHooks(store: Store.Store) {
|
|
3613
|
+
const pending = new Map<string, number>()
|
|
3614
|
+
const wrapped = Store.from({
|
|
3615
|
+
async get(key) {
|
|
3616
|
+
const remaining = pending.get(key)
|
|
3617
|
+
if (remaining !== undefined) {
|
|
3618
|
+
if (remaining === 0) {
|
|
3619
|
+
pending.delete(key)
|
|
3620
|
+
return null
|
|
3621
|
+
}
|
|
3622
|
+
pending.set(key, remaining - 1)
|
|
3623
|
+
}
|
|
3624
|
+
return store.get(key)
|
|
3625
|
+
},
|
|
3626
|
+
put: (key, value) => store.put(key, value),
|
|
3627
|
+
delete: (key) => store.delete(key),
|
|
3628
|
+
})
|
|
3629
|
+
return {
|
|
3630
|
+
store: wrapped,
|
|
3631
|
+
dropOnRead(channelId: Hex, readsBeforeDrop = 0) {
|
|
3632
|
+
pending.set(channelId, readsBeforeDrop)
|
|
3633
|
+
},
|
|
3634
|
+
}
|
|
3635
|
+
}
|