mppx 0.4.12 → 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.
Files changed (52) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/Expires.d.ts +7 -0
  3. package/dist/Expires.d.ts.map +1 -1
  4. package/dist/Expires.js +21 -0
  5. package/dist/Expires.js.map +1 -1
  6. package/dist/server/Mppx.js +6 -5
  7. package/dist/server/Mppx.js.map +1 -1
  8. package/dist/stripe/server/Charge.d.ts.map +1 -1
  9. package/dist/stripe/server/Charge.js +3 -3
  10. package/dist/stripe/server/Charge.js.map +1 -1
  11. package/dist/tempo/Methods.d.ts +3 -0
  12. package/dist/tempo/Methods.d.ts.map +1 -1
  13. package/dist/tempo/Methods.js +1 -0
  14. package/dist/tempo/Methods.js.map +1 -1
  15. package/dist/tempo/client/Charge.d.ts +3 -0
  16. package/dist/tempo/client/Charge.d.ts.map +1 -1
  17. package/dist/tempo/client/Charge.js +18 -2
  18. package/dist/tempo/client/Charge.js.map +1 -1
  19. package/dist/tempo/client/Methods.d.ts +3 -0
  20. package/dist/tempo/client/Methods.d.ts.map +1 -1
  21. package/dist/tempo/internal/proof.d.ts +23 -0
  22. package/dist/tempo/internal/proof.d.ts.map +1 -0
  23. package/dist/tempo/internal/proof.js +17 -0
  24. package/dist/tempo/internal/proof.js.map +1 -0
  25. package/dist/tempo/server/Charge.d.ts +3 -0
  26. package/dist/tempo/server/Charge.d.ts.map +1 -1
  27. package/dist/tempo/server/Charge.js +32 -4
  28. package/dist/tempo/server/Charge.js.map +1 -1
  29. package/dist/tempo/server/Methods.d.ts +3 -0
  30. package/dist/tempo/server/Methods.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/src/Expires.ts +25 -0
  33. package/src/cli/cli.test.ts +230 -1
  34. package/src/middlewares/elysia.test.ts +127 -4
  35. package/src/middlewares/express.test.ts +120 -54
  36. package/src/middlewares/hono.test.ts +73 -34
  37. package/src/middlewares/nextjs.test.ts +159 -36
  38. package/src/server/Mppx.test.ts +86 -0
  39. package/src/server/Mppx.ts +5 -5
  40. package/src/stripe/server/Charge.ts +3 -7
  41. package/src/tempo/Methods.test.ts +26 -0
  42. package/src/tempo/Methods.ts +1 -0
  43. package/src/tempo/client/Charge.ts +26 -3
  44. package/src/tempo/internal/charge.test.ts +66 -0
  45. package/src/tempo/internal/proof.test.ts +36 -0
  46. package/src/tempo/internal/proof.ts +19 -0
  47. package/src/tempo/server/Charge.test.ts +362 -1
  48. package/src/tempo/server/Charge.ts +40 -2
  49. package/src/tempo/server/Session.test.ts +1123 -53
  50. package/src/tempo/server/internal/transport.test.ts +32 -0
  51. package/src/tempo/session/Chain.test.ts +35 -0
  52. 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 { type Address, createClient, type Hex } from 'viem'
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('structured errors', () => {
1877
- test('ChannelNotFoundError on unknown channel', async () => {
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
- try {
1882
- await server.verify({
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: '1000000',
1889
- signature: await signTestVoucher(channelId, 1000000n),
2127
+ cumulativeAmount: '2000000',
2128
+ signature: mutateSignature(compact),
1890
2129
  },
1891
2130
  },
1892
2131
  request: makeRequest(),
1893
- })
1894
- expect.unreachable()
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
- test('InvalidSignatureError has status 402', async () => {
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
- try {
1906
- await server.verify({
1907
- credential: {
1908
- challenge: makeChallenge({ channelId }),
1909
- payload: {
1910
- action: 'open' as const,
1911
- type: 'transaction' as const,
1912
- channelId,
1913
- transaction: serializedTransaction,
1914
- cumulativeAmount: '1000000',
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
- request: makeRequest(),
1919
- })
1920
- expect.unreachable()
1921
- } catch (e) {
1922
- expect(e).toBeInstanceOf(InvalidSignatureError)
1923
- expect((e as InvalidSignatureError).status).toBe(402)
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('respond', () => {
1929
- test('returns 204 for POST with open action', () => {
1930
- const server = createServer()
1931
- const result = server.respond!({
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
- channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
1935
- }),
1936
- payload: { action: 'open' },
2216
+ challenge: makeChallenge({ id: 'open-crash-retry', channelId }),
2217
+ payload: openPayload,
1937
2218
  },
1938
- input: new Request('http://localhost', { method: 'POST' }),
1939
- } as any)
1940
- expect(result).toBeInstanceOf(Response)
1941
- expect((result as Response).status).toBe(204)
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('returns 204 for POST with topUp action', () => {
1945
- const server = createServer()
1946
- const result = server.respond!({
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(channelId: Hex, amount: bigint) {
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
- payer,
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
+ }