mppx 0.4.12 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/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/cli/account.d.ts.map +1 -1
- package/dist/cli/account.js +12 -2
- package/dist/cli/account.js.map +1 -1
- package/dist/server/Mppx.js +6 -5
- 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 +3 -0
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +1 -0
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/Charge.d.ts +3 -0
- package/dist/tempo/client/Charge.d.ts.map +1 -1
- package/dist/tempo/client/Charge.js +18 -2
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +3 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/internal/proof.d.ts +29 -0
- package/dist/tempo/internal/proof.d.ts.map +1 -0
- package/dist/tempo/internal/proof.js +32 -0
- package/dist/tempo/internal/proof.js.map +1 -0
- package/dist/tempo/server/Charge.d.ts +11 -3
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +54 -4
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +3 -0
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/Expires.ts +25 -0
- package/src/cli/account.ts +13 -2
- package/src/cli/cli.test.ts +230 -1
- package/src/middlewares/elysia.test.ts +130 -9
- package/src/middlewares/express.test.ts +123 -59
- package/src/middlewares/hono.test.ts +81 -39
- package/src/middlewares/nextjs.test.ts +162 -41
- package/src/server/Mppx.test.ts +86 -0
- package/src/server/Mppx.ts +5 -5
- package/src/stripe/server/Charge.ts +3 -7
- package/src/tempo/Methods.test.ts +26 -0
- package/src/tempo/Methods.ts +1 -0
- package/src/tempo/client/Charge.ts +26 -3
- package/src/tempo/internal/charge.test.ts +66 -0
- package/src/tempo/internal/proof.test.ts +83 -0
- package/src/tempo/internal/proof.ts +35 -0
- package/src/tempo/server/Charge.test.ts +660 -1
- package/src/tempo/server/Charge.ts +80 -5
- package/src/tempo/server/Session.test.ts +1123 -53
- package/src/tempo/server/internal/transport.test.ts +32 -0
- package/src/tempo/session/Chain.test.ts +35 -0
- 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)
|
|
@@ -1391,6 +1435,185 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
1391
1435
|
expect(chAfter).not.toBeNull()
|
|
1392
1436
|
expect(chAfter!.highestVoucherAmount).toBe(7000000n)
|
|
1393
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
|
+
})
|
|
1394
1617
|
})
|
|
1395
1618
|
|
|
1396
1619
|
describe('charge', () => {
|
|
@@ -1873,77 +2096,558 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
1873
2096
|
})
|
|
1874
2097
|
})
|
|
1875
2098
|
|
|
1876
|
-
describe('
|
|
1877
|
-
test('
|
|
1878
|
-
const { channelId } = await createSignedOpenTransaction(10000000n)
|
|
2099
|
+
describe('signature compatibility', () => {
|
|
2100
|
+
test('rejects malformed compact signatures', async () => {
|
|
2101
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
1879
2102
|
const server = createServer()
|
|
1880
2103
|
|
|
1881
|
-
|
|
1882
|
-
|
|
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({
|
|
1883
2122
|
credential: {
|
|
1884
|
-
challenge: makeChallenge({ channelId }),
|
|
2123
|
+
challenge: makeChallenge({ id: 'voucher-invalid-compact', channelId }),
|
|
1885
2124
|
payload: {
|
|
1886
2125
|
action: 'voucher' as const,
|
|
1887
2126
|
channelId,
|
|
1888
|
-
cumulativeAmount: '
|
|
1889
|
-
signature:
|
|
2127
|
+
cumulativeAmount: '2000000',
|
|
2128
|
+
signature: mutateSignature(compact),
|
|
1890
2129
|
},
|
|
1891
2130
|
},
|
|
1892
2131
|
request: makeRequest(),
|
|
1893
|
-
})
|
|
1894
|
-
|
|
1895
|
-
} catch (e) {
|
|
1896
|
-
expect(e).toBeInstanceOf(ChannelNotFoundError)
|
|
1897
|
-
expect((e as ChannelNotFoundError).status).toBe(410)
|
|
1898
|
-
}
|
|
2132
|
+
}),
|
|
2133
|
+
).rejects.toThrow('invalid voucher signature')
|
|
1899
2134
|
})
|
|
2135
|
+
})
|
|
1900
2136
|
|
|
1901
|
-
|
|
2137
|
+
describe('session-level concurrency', () => {
|
|
2138
|
+
test('concurrent voucher submissions linearize to monotonic final state', async () => {
|
|
1902
2139
|
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
1903
2140
|
const server = createServer()
|
|
1904
2141
|
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
signature: `0x${'ab'.repeat(65)}` as Hex,
|
|
1916
|
-
},
|
|
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),
|
|
1917
2152
|
},
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
2153
|
+
},
|
|
2154
|
+
request: makeRequest(),
|
|
2155
|
+
})
|
|
2156
|
+
|
|
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(),
|
|
2171
|
+
}),
|
|
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)
|
|
1925
2181
|
})
|
|
1926
2182
|
})
|
|
1927
2183
|
|
|
1928
|
-
describe('
|
|
1929
|
-
test('
|
|
1930
|
-
const
|
|
1931
|
-
const
|
|
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({
|
|
1932
2215
|
credential: {
|
|
1933
|
-
challenge: makeChallenge({
|
|
1934
|
-
|
|
1935
|
-
}),
|
|
1936
|
-
payload: { action: 'open' },
|
|
2216
|
+
challenge: makeChallenge({ id: 'open-crash-retry', channelId }),
|
|
2217
|
+
payload: openPayload,
|
|
1937
2218
|
},
|
|
1938
|
-
|
|
1939
|
-
}
|
|
1940
|
-
|
|
1941
|
-
expect(
|
|
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)
|
|
1942
2226
|
})
|
|
1943
2227
|
|
|
1944
|
-
test('
|
|
1945
|
-
const
|
|
1946
|
-
const
|
|
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
|
+
})
|
|
2647
|
+
|
|
2648
|
+
test('returns 204 for POST with topUp action', () => {
|
|
2649
|
+
const server = createServer()
|
|
2650
|
+
const result = server.respond!({
|
|
1947
2651
|
credential: {
|
|
1948
2652
|
challenge: makeChallenge({
|
|
1949
2653
|
channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
|
|
@@ -2203,6 +2907,158 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
2203
2907
|
}
|
|
2204
2908
|
})
|
|
2205
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
|
+
|
|
2206
3062
|
test('behavior: charge withReceipt returns Response', async () => {
|
|
2207
3063
|
const handler = Mppx_server.create({
|
|
2208
3064
|
methods: [tempo_server.charge({ account: accounts[0], currency: asset })],
|
|
@@ -2313,6 +3169,166 @@ describe('monotonicity and TOCTOU (unit tests)', () => {
|
|
|
2313
3169
|
})
|
|
2314
3170
|
})
|
|
2315
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
|
+
|
|
2316
3332
|
describe('session default currency resolution', () => {
|
|
2317
3333
|
const mockAccount = accounts[0]
|
|
2318
3334
|
const mockClient = createClient({ transport: http('http://localhost:1') })
|
|
@@ -2537,10 +3553,14 @@ function makeRequest() {
|
|
|
2537
3553
|
}
|
|
2538
3554
|
}
|
|
2539
3555
|
|
|
2540
|
-
async function signTestVoucher(
|
|
3556
|
+
async function signTestVoucher(
|
|
3557
|
+
channelId: Hex,
|
|
3558
|
+
amount: bigint,
|
|
3559
|
+
account: (typeof accounts)[number] = payer,
|
|
3560
|
+
) {
|
|
2541
3561
|
return signVoucher(
|
|
2542
3562
|
client,
|
|
2543
|
-
|
|
3563
|
+
account,
|
|
2544
3564
|
{ channelId, cumulativeAmount: amount },
|
|
2545
3565
|
escrowContract,
|
|
2546
3566
|
chain.id,
|
|
@@ -2563,3 +3583,53 @@ async function createSignedOpenTransaction(
|
|
|
2563
3583
|
})
|
|
2564
3584
|
return { channelId, serializedTransaction }
|
|
2565
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
|
+
}
|