mppx 0.4.11 → 0.5.0

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