mppx 0.8.12 → 0.8.14

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 (266) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +16 -1
  3. package/dist/Errors.d.ts +10 -0
  4. package/dist/Errors.d.ts.map +1 -1
  5. package/dist/Errors.js +11 -2
  6. package/dist/Errors.js.map +1 -1
  7. package/dist/Method.d.ts +105 -3
  8. package/dist/Method.d.ts.map +1 -1
  9. package/dist/Method.js +102 -2
  10. package/dist/Method.js.map +1 -1
  11. package/dist/cli/account.d.ts +88 -0
  12. package/dist/cli/account.d.ts.map +1 -1
  13. package/dist/cli/account.js +39 -9
  14. package/dist/cli/account.js.map +1 -1
  15. package/dist/cli/cli.d.ts +2 -1
  16. package/dist/cli/cli.d.ts.map +1 -1
  17. package/dist/cli/cli.js +59 -2
  18. package/dist/cli/cli.js.map +1 -1
  19. package/dist/cli/sessions/Manager.d.ts +31 -0
  20. package/dist/cli/sessions/Manager.d.ts.map +1 -0
  21. package/dist/cli/sessions/Manager.js +56 -0
  22. package/dist/cli/sessions/Manager.js.map +1 -0
  23. package/dist/cli/sessions/commands.d.ts +36 -0
  24. package/dist/cli/sessions/commands.d.ts.map +1 -0
  25. package/dist/cli/sessions/commands.js +407 -0
  26. package/dist/cli/sessions/commands.js.map +1 -0
  27. package/dist/cli/sessions/request.d.ts +35 -0
  28. package/dist/cli/sessions/request.d.ts.map +1 -0
  29. package/dist/cli/sessions/request.js +271 -0
  30. package/dist/cli/sessions/request.js.map +1 -0
  31. package/dist/cli/sessions/store.d.ts +134 -0
  32. package/dist/cli/sessions/store.d.ts.map +1 -0
  33. package/dist/cli/sessions/store.js +723 -0
  34. package/dist/cli/sessions/store.js.map +1 -0
  35. package/dist/cli/utils.d.ts +2 -0
  36. package/dist/cli/utils.d.ts.map +1 -1
  37. package/dist/cli/utils.js +12 -0
  38. package/dist/cli/utils.js.map +1 -1
  39. package/dist/cli/validate/index.d.ts.map +1 -1
  40. package/dist/cli/validate/index.js +12 -6
  41. package/dist/cli/validate/index.js.map +1 -1
  42. package/dist/cli/validate/messages.d.ts +2 -0
  43. package/dist/cli/validate/messages.d.ts.map +1 -0
  44. package/dist/cli/validate/messages.js +8 -0
  45. package/dist/cli/validate/messages.js.map +1 -0
  46. package/dist/client/internal/Fetch.d.ts.map +1 -1
  47. package/dist/client/internal/Fetch.js +12 -2
  48. package/dist/client/internal/Fetch.js.map +1 -1
  49. package/dist/client/internal/MethodChallenge.d.ts +17 -0
  50. package/dist/client/internal/MethodChallenge.d.ts.map +1 -0
  51. package/dist/client/internal/MethodChallenge.js +11 -0
  52. package/dist/client/internal/MethodChallenge.js.map +1 -0
  53. package/dist/client/node.d.ts +29 -0
  54. package/dist/client/node.d.ts.map +1 -0
  55. package/dist/client/node.js +177 -0
  56. package/dist/client/node.js.map +1 -0
  57. package/dist/internal/types.d.ts +24 -0
  58. package/dist/internal/types.d.ts.map +1 -1
  59. package/dist/server/Mppx.d.ts +23 -4
  60. package/dist/server/Mppx.d.ts.map +1 -1
  61. package/dist/server/Mppx.js +91 -82
  62. package/dist/server/Mppx.js.map +1 -1
  63. package/dist/stripe/server/Charge.d.ts +8 -4
  64. package/dist/stripe/server/Charge.d.ts.map +1 -1
  65. package/dist/stripe/server/Charge.js.map +1 -1
  66. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  67. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  68. package/dist/stripe/server/internal/html.gen.js +1 -1
  69. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  70. package/dist/tempo/client/Methods.d.ts +2 -1
  71. package/dist/tempo/client/Methods.d.ts.map +1 -1
  72. package/dist/tempo/internal/fee-payer.d.ts +1 -0
  73. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  74. package/dist/tempo/internal/fee-payer.js +7 -2
  75. package/dist/tempo/internal/fee-payer.js.map +1 -1
  76. package/dist/tempo/internal/types.d.ts +10 -1
  77. package/dist/tempo/internal/types.d.ts.map +1 -1
  78. package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
  79. package/dist/tempo/legacy/client/ChannelOps.js +2 -1
  80. package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
  81. package/dist/tempo/legacy/client/Session.d.ts +2 -2
  82. package/dist/tempo/legacy/server/Session.d.ts +3 -3
  83. package/dist/tempo/legacy/server/Session.d.ts.map +1 -1
  84. package/dist/tempo/legacy/server/Session.js.map +1 -1
  85. package/dist/tempo/server/Charge.d.ts +40 -78
  86. package/dist/tempo/server/Charge.d.ts.map +1 -1
  87. package/dist/tempo/server/Charge.js +289 -209
  88. package/dist/tempo/server/Charge.js.map +1 -1
  89. package/dist/tempo/server/Methods.d.ts +13 -23
  90. package/dist/tempo/server/Methods.d.ts.map +1 -1
  91. package/dist/tempo/server/Methods.js +6 -2
  92. package/dist/tempo/server/Methods.js.map +1 -1
  93. package/dist/tempo/server/Relay.d.ts +48 -0
  94. package/dist/tempo/server/Relay.d.ts.map +1 -0
  95. package/dist/tempo/server/Relay.js +177 -0
  96. package/dist/tempo/server/Relay.js.map +1 -0
  97. package/dist/tempo/server/SponsorBudget.d.ts +59 -0
  98. package/dist/tempo/server/SponsorBudget.d.ts.map +1 -0
  99. package/dist/tempo/server/SponsorBudget.js +144 -0
  100. package/dist/tempo/server/SponsorBudget.js.map +1 -0
  101. package/dist/tempo/server/Subscription.d.ts +3 -2
  102. package/dist/tempo/server/Subscription.d.ts.map +1 -1
  103. package/dist/tempo/server/Subscription.js.map +1 -1
  104. package/dist/tempo/server/index.d.ts +1 -1
  105. package/dist/tempo/server/index.d.ts.map +1 -1
  106. package/dist/tempo/server/index.js.map +1 -1
  107. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  108. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  109. package/dist/tempo/server/internal/html.gen.js +1 -1
  110. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  111. package/dist/tempo/session/Snapshot.d.ts +9 -0
  112. package/dist/tempo/session/Snapshot.d.ts.map +1 -1
  113. package/dist/tempo/session/Snapshot.js +6 -0
  114. package/dist/tempo/session/Snapshot.js.map +1 -1
  115. package/dist/tempo/session/client/ChannelOps.d.ts +7 -1
  116. package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -1
  117. package/dist/tempo/session/client/ChannelOps.js +17 -3
  118. package/dist/tempo/session/client/ChannelOps.js.map +1 -1
  119. package/dist/tempo/session/client/CredentialState.d.ts +26 -2
  120. package/dist/tempo/session/client/CredentialState.d.ts.map +1 -1
  121. package/dist/tempo/session/client/CredentialState.js +99 -16
  122. package/dist/tempo/session/client/CredentialState.js.map +1 -1
  123. package/dist/tempo/session/client/Runtime.d.ts +8 -0
  124. package/dist/tempo/session/client/Runtime.d.ts.map +1 -1
  125. package/dist/tempo/session/client/Runtime.js +17 -0
  126. package/dist/tempo/session/client/Runtime.js.map +1 -1
  127. package/dist/tempo/session/client/Session.d.ts +16 -6
  128. package/dist/tempo/session/client/Session.d.ts.map +1 -1
  129. package/dist/tempo/session/client/Session.js +93 -36
  130. package/dist/tempo/session/client/Session.js.map +1 -1
  131. package/dist/tempo/session/client/SessionManager.d.ts +10 -3
  132. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  133. package/dist/tempo/session/client/SessionManager.js +176 -50
  134. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  135. package/dist/tempo/session/client/Transports.d.ts +18 -7
  136. package/dist/tempo/session/client/Transports.d.ts.map +1 -1
  137. package/dist/tempo/session/client/Transports.js +81 -33
  138. package/dist/tempo/session/client/Transports.js.map +1 -1
  139. package/dist/tempo/session/client/internal/SessionManager.d.ts +19 -0
  140. package/dist/tempo/session/client/internal/SessionManager.d.ts.map +1 -0
  141. package/dist/tempo/session/client/internal/SessionManager.js +13 -0
  142. package/dist/tempo/session/client/internal/SessionManager.js.map +1 -0
  143. package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
  144. package/dist/tempo/session/precompile/Chain.js +109 -12
  145. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  146. package/dist/tempo/session/precompile/Channel.d.ts +2 -0
  147. package/dist/tempo/session/precompile/Channel.d.ts.map +1 -1
  148. package/dist/tempo/session/precompile/Channel.js +4 -0
  149. package/dist/tempo/session/precompile/Channel.js.map +1 -1
  150. package/dist/tempo/session/server/CredentialVerification.d.ts +3 -0
  151. package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -1
  152. package/dist/tempo/session/server/CredentialVerification.js +14 -0
  153. package/dist/tempo/session/server/CredentialVerification.js.map +1 -1
  154. package/dist/tempo/session/server/MeteredStream.d.ts +7 -1
  155. package/dist/tempo/session/server/MeteredStream.d.ts.map +1 -1
  156. package/dist/tempo/session/server/MeteredStream.js +5 -4
  157. package/dist/tempo/session/server/MeteredStream.js.map +1 -1
  158. package/dist/tempo/session/server/RequestState.d.ts +5 -1
  159. package/dist/tempo/session/server/RequestState.d.ts.map +1 -1
  160. package/dist/tempo/session/server/RequestState.js +13 -5
  161. package/dist/tempo/session/server/RequestState.js.map +1 -1
  162. package/dist/tempo/session/server/Session.d.ts +21 -6
  163. package/dist/tempo/session/server/Session.d.ts.map +1 -1
  164. package/dist/tempo/session/server/Session.js +4 -1
  165. package/dist/tempo/session/server/Session.js.map +1 -1
  166. package/dist/tempo/session/server/Settlement.d.ts +20 -0
  167. package/dist/tempo/session/server/Settlement.d.ts.map +1 -1
  168. package/dist/tempo/session/server/Settlement.js +20 -0
  169. package/dist/tempo/session/server/Settlement.js.map +1 -1
  170. package/dist/tempo/session/server/Ws.d.ts +2 -0
  171. package/dist/tempo/session/server/Ws.d.ts.map +1 -1
  172. package/dist/tempo/session/server/Ws.js.map +1 -1
  173. package/dist/tempo/session/server/index.d.ts +1 -1
  174. package/dist/tempo/session/server/index.d.ts.map +1 -1
  175. package/dist/tempo/subscription/KeyAuthorization.d.ts +21 -21
  176. package/dist/validation/core.d.ts.map +1 -1
  177. package/dist/validation/core.js +6 -2
  178. package/dist/validation/core.js.map +1 -1
  179. package/dist/viem/Client.d.ts.map +1 -1
  180. package/dist/viem/Client.js +24 -17
  181. package/dist/viem/Client.js.map +1 -1
  182. package/package.json +6 -1
  183. package/src/Errors.test.ts +23 -0
  184. package/src/Errors.ts +21 -2
  185. package/src/Method.test.ts +102 -1
  186. package/src/Method.ts +241 -5
  187. package/src/cli/account.ts +45 -10
  188. package/src/cli/cli.test.ts +165 -70
  189. package/src/cli/cli.ts +67 -2
  190. package/src/cli/mcp.test.ts +11 -0
  191. package/src/cli/sessions/Manager.test.ts +249 -0
  192. package/src/cli/sessions/Manager.ts +93 -0
  193. package/src/cli/sessions/commands.ts +444 -0
  194. package/src/cli/sessions/request.test.ts +51 -0
  195. package/src/cli/sessions/request.ts +353 -0
  196. package/src/cli/sessions/store.test.ts +581 -0
  197. package/src/cli/sessions/store.ts +940 -0
  198. package/src/cli/utils.test.ts +23 -0
  199. package/src/cli/utils.ts +10 -0
  200. package/src/cli/validate/index.ts +12 -14
  201. package/src/cli/validate/messages.ts +7 -0
  202. package/src/cli/validate.test.ts +38 -5
  203. package/src/client/Mppx.test-d.ts +3 -3
  204. package/src/client/internal/Fetch.ts +16 -7
  205. package/src/client/internal/MethodChallenge.ts +30 -0
  206. package/src/client/node.test.ts +115 -0
  207. package/src/client/node.ts +247 -0
  208. package/src/internal/types.test-d.ts +21 -0
  209. package/src/internal/types.ts +33 -0
  210. package/src/server/Mppx.test-d.ts +44 -0
  211. package/src/server/Mppx.test.ts +205 -0
  212. package/src/server/Mppx.ts +167 -103
  213. package/src/stripe/Methods.test.ts +10 -0
  214. package/src/stripe/server/Charge.test-d.ts +66 -0
  215. package/src/stripe/server/Charge.ts +8 -5
  216. package/src/stripe/server/internal/html.gen.ts +1 -1
  217. package/src/tempo/PublicExports.test-d.ts +20 -0
  218. package/src/tempo/internal/fee-payer.ts +7 -3
  219. package/src/tempo/internal/types.ts +13 -2
  220. package/src/tempo/legacy/client/ChannelOps.test.ts +7 -0
  221. package/src/tempo/legacy/client/ChannelOps.ts +2 -1
  222. package/src/tempo/legacy/server/Defaults.test-d.ts +9 -0
  223. package/src/tempo/legacy/server/Session.ts +6 -5
  224. package/src/tempo/server/Charge.test.ts +337 -63
  225. package/src/tempo/server/Charge.ts +440 -270
  226. package/src/tempo/server/Methods.ts +16 -4
  227. package/src/tempo/server/Relay.test.ts +523 -0
  228. package/src/tempo/server/Relay.ts +288 -0
  229. package/src/tempo/server/SponsorBudget.test.ts +125 -0
  230. package/src/tempo/server/SponsorBudget.ts +213 -0
  231. package/src/tempo/server/Subscription.ts +6 -4
  232. package/src/tempo/server/index.ts +5 -1
  233. package/src/tempo/server/internal/html.gen.ts +1 -1
  234. package/src/tempo/session/README.md +201 -0
  235. package/src/tempo/session/Snapshot.ts +21 -0
  236. package/src/tempo/session/client/ChannelOps.test.ts +55 -0
  237. package/src/tempo/session/client/ChannelOps.ts +21 -3
  238. package/src/tempo/session/client/CredentialState.test.ts +2 -2
  239. package/src/tempo/session/client/CredentialState.ts +159 -16
  240. package/src/tempo/session/client/Runtime.test.ts +12 -0
  241. package/src/tempo/session/client/Runtime.ts +22 -0
  242. package/src/tempo/session/client/Session.test.ts +238 -5
  243. package/src/tempo/session/client/Session.ts +130 -41
  244. package/src/tempo/session/client/SessionManager.test.ts +239 -8
  245. package/src/tempo/session/client/SessionManager.ts +204 -54
  246. package/src/tempo/session/client/Transports.test.ts +181 -8
  247. package/src/tempo/session/client/Transports.ts +127 -45
  248. package/src/tempo/session/client/internal/SessionManager.ts +36 -0
  249. package/src/tempo/session/precompile/Chain.integration.test.ts +19 -1
  250. package/src/tempo/session/precompile/Chain.test.ts +223 -10
  251. package/src/tempo/session/precompile/Chain.ts +119 -12
  252. package/src/tempo/session/precompile/Channel.test.ts +9 -0
  253. package/src/tempo/session/precompile/Channel.ts +5 -0
  254. package/src/tempo/session/server/CredentialVerification.ts +18 -1
  255. package/src/tempo/session/server/MeteredStream.ts +12 -5
  256. package/src/tempo/session/server/RequestState.test.ts +47 -4
  257. package/src/tempo/session/server/RequestState.ts +15 -8
  258. package/src/tempo/session/server/Session.test.ts +461 -0
  259. package/src/tempo/session/server/Session.ts +31 -8
  260. package/src/tempo/session/server/Settlement.ts +45 -0
  261. package/src/tempo/session/server/Ws.test.ts +113 -0
  262. package/src/tempo/session/server/Ws.ts +2 -0
  263. package/src/tempo/session/server/index.ts +2 -0
  264. package/src/validation/core.ts +7 -1
  265. package/src/viem/Client.test.ts +44 -1
  266. package/src/viem/Client.ts +31 -17
@@ -9,6 +9,7 @@ import type { ChannelEntry } from '../client/ChannelOps.js'
9
9
  import type { SessionContext } from '../client/CredentialState.js'
10
10
  import {
11
11
  createSessionReceipt,
12
+ formatNeedVoucherEvent,
12
13
  serializeSessionReceipt,
13
14
  tip20ChannelEscrow,
14
15
  type ChannelDescriptor,
@@ -18,6 +19,7 @@ import { initialState, type SessionState } from './Runtime.js'
18
19
  import {
19
20
  applyTopUpResult,
20
21
  closeHttpSession,
22
+ consumeSseSessionResponse,
21
23
  createActiveSocketSession,
22
24
  driveSseResponse,
23
25
  isExpectedSocketReceipt,
@@ -83,6 +85,7 @@ describe('HttpManagement', () => {
83
85
  methodDetails: {
84
86
  chainId: 4217,
85
87
  escrowContract: undefined,
88
+ sessionProtocol: Constants.SessionProtocols.v2,
86
89
  ...(snapshot && { [Constants.MethodDetailKeys.sessionSnapshot]: snapshot }),
87
90
  },
88
91
  },
@@ -112,9 +115,15 @@ describe('HttpManagement', () => {
112
115
  })
113
116
  }
114
117
 
115
- function receiptHeader(acceptedCumulative: bigint, spent: bigint) {
118
+ function receiptHeader(acceptedCumulative: bigint, spent: bigint, challengeId = 'challenge-1') {
116
119
  return serializeSessionReceipt(
117
- createSessionReceipt({ acceptedCumulative, challengeId: 'challenge-1', channelId, spent }),
120
+ createSessionReceipt({
121
+ acceptedCumulative,
122
+ challengeId,
123
+ channelId,
124
+ spent,
125
+ txHash: `0x${'aa'.repeat(32)}`,
126
+ }),
118
127
  )
119
128
  }
120
129
 
@@ -139,9 +148,12 @@ describe('HttpManagement', () => {
139
148
  )
140
149
  })
141
150
 
142
- test('strips resource query state from management URLs', () => {
151
+ test('preserves query state and strips fragments from management URLs', () => {
143
152
  expect(managementInput('https://example.test/resource?cursor=abc').toString()).toBe(
144
- 'https://example.test/resource',
153
+ 'https://example.test/resource?cursor=abc',
154
+ )
155
+ expect(managementInput('https://example.test/resource?cursor=abc#result').toString()).toBe(
156
+ 'https://example.test/resource?cursor=abc',
145
157
  )
146
158
  })
147
159
 
@@ -185,7 +197,7 @@ describe('HttpManagement', () => {
185
197
  return 'top-up-credential'
186
198
  })
187
199
  const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
188
- expect(input.toString()).toBe('https://example.test/resource')
200
+ expect(input.toString()).toBe('https://example.test/resource?cursor=abc')
189
201
  expect(init?.method).toBe('POST')
190
202
  expect(authorizationHeader(init)).toBe('top-up-credential')
191
203
  return new Response(null, {
@@ -209,6 +221,36 @@ describe('HttpManagement', () => {
209
221
  expect(fetch).toHaveBeenCalledOnce()
210
222
  })
211
223
 
224
+ test('postTopUp retries once with the management route challenge', async () => {
225
+ const routeChallenge = { ...challenge(), id: 'management-challenge' } as TempoSessionChallenge
226
+ const createSessionCredential = vi.fn(async (challenge_) => `top-up-${challenge_.id}`)
227
+ const fetch = vi
228
+ .fn()
229
+ .mockResolvedValueOnce(response402(routeChallenge))
230
+ .mockResolvedValueOnce(
231
+ new Response(null, {
232
+ status: 204,
233
+ headers: {
234
+ [Constants.Headers.paymentReceipt]: receiptHeader(8n, 5n, routeChallenge.id),
235
+ },
236
+ }),
237
+ )
238
+
239
+ const receipt = await postTopUp({
240
+ additionalDeposit: 3n,
241
+ challenge: challenge(),
242
+ channel: channel(),
243
+ channelId,
244
+ createSessionCredential,
245
+ fetch,
246
+ input: 'https://example.test/resource',
247
+ })
248
+
249
+ expect(receipt?.challengeId).toBe(routeChallenge.id)
250
+ expect(createSessionCredential).toHaveBeenCalledTimes(2)
251
+ expect(authorizationHeader(fetch.mock.calls[1]?.[1])).toBe(`top-up-${routeChallenge.id}`)
252
+ })
253
+
212
254
  test('retryHttpPaymentRequired signs the server-required cumulative voucher', async () => {
213
255
  let entry = channel({ cumulativeAmount: 5n, deposit: 6n })
214
256
  const topUpIfNeeded = vi.fn(async () => {
@@ -301,16 +343,20 @@ describe('HttpManagement', () => {
301
343
  const entry = channel()
302
344
  const retryChallenge = { ...challenge(), id: 'challenge-2' } as TempoSessionChallenge
303
345
  const setChallenge = vi.fn()
304
- const createSessionCredential = vi.fn(async (challenge_) => {
346
+ const closeAmounts: string[] = []
347
+ const createSessionCredential = vi.fn(async (challenge_, context: SessionContext) => {
348
+ if (context.action !== 'close') throw new Error('expected close context')
349
+ closeAmounts.push(context.cumulativeAmountRaw!)
305
350
  return `close-${challenge_.id}`
306
351
  })
352
+ const resolveSignedCloseAmount = vi.fn(() => '6')
307
353
  const fetch = vi
308
354
  .fn()
309
355
  .mockResolvedValueOnce(response402(retryChallenge))
310
356
  .mockResolvedValueOnce(
311
357
  new Response(null, {
312
358
  status: 200,
313
- headers: { [Constants.Headers.paymentReceipt]: receiptHeader(5n, 5n) },
359
+ headers: { [Constants.Headers.paymentReceipt]: receiptHeader(6n, 6n, 'challenge-2') },
314
360
  }),
315
361
  )
316
362
 
@@ -318,18 +364,71 @@ describe('HttpManagement', () => {
318
364
  createSessionCredential,
319
365
  fetch,
320
366
  lastUrl: 'https://example.test/resource',
367
+ resolveSignedCloseAmount,
321
368
  signedCloseAmount: '5',
322
369
  setChallenge,
323
370
  target: { challenge: challenge(), channel: entry, channelId },
324
371
  })
325
372
 
326
- expect(receipt?.spent).toBe('5')
373
+ expect(receipt?.spent).toBe('6')
327
374
  expect(setChallenge).toHaveBeenCalledWith(retryChallenge)
375
+ expect(resolveSignedCloseAmount).toHaveBeenCalledOnce()
376
+ expect(resolveSignedCloseAmount).toHaveBeenCalledWith(retryChallenge)
377
+ expect(closeAmounts).toEqual(['5', '6'])
328
378
  expect(createSessionCredential).toHaveBeenCalledTimes(2)
329
379
  expect(createSessionCredential.mock.calls[1]?.[0]).toMatchObject({ id: retryChallenge.id })
330
380
  expect(authorizationHeader(fetch.mock.calls[1]?.[1])).toBe('close-challenge-2')
331
381
  })
332
382
 
383
+ test('closeHttpSession rejects a receipt without settlement proof', async () => {
384
+ const receipt = serializeSessionReceipt(
385
+ createSessionReceipt({
386
+ acceptedCumulative: 5n,
387
+ challengeId: 'challenge-1',
388
+ channelId,
389
+ spent: 5n,
390
+ }),
391
+ )
392
+ await expect(
393
+ closeHttpSession({
394
+ createSessionCredential: async () => 'close-credential',
395
+ fetch: async () =>
396
+ new Response(null, {
397
+ headers: { [Constants.Headers.paymentReceipt]: receipt },
398
+ }),
399
+ lastUrl: 'https://example.test/resource',
400
+ signedCloseAmount: '5',
401
+ target: { challenge: challenge(), channel: channel(), channelId },
402
+ }),
403
+ ).rejects.toThrow('Session close response included a mismatched payment receipt.')
404
+ })
405
+
406
+ test('closeHttpSession rejects an invalid settlement transaction hash', async () => {
407
+ for (const txHash of ['0x1234', `0x${'gg'.repeat(32)}`]) {
408
+ const receipt = serializeSessionReceipt(
409
+ createSessionReceipt({
410
+ acceptedCumulative: 5n,
411
+ challengeId: 'challenge-1',
412
+ channelId,
413
+ spent: 5n,
414
+ txHash: txHash as Hex.Hex,
415
+ }),
416
+ )
417
+ await expect(
418
+ closeHttpSession({
419
+ createSessionCredential: async () => 'close-credential',
420
+ fetch: async () =>
421
+ new Response(null, {
422
+ headers: { [Constants.Headers.paymentReceipt]: receipt },
423
+ }),
424
+ lastUrl: 'https://example.test/resource',
425
+ signedCloseAmount: '5',
426
+ target: { challenge: challenge(), channel: channel(), channelId },
427
+ }),
428
+ ).rejects.toThrow('Session close response included a mismatched payment receipt.')
429
+ }
430
+ })
431
+
333
432
  test('closeHttpSession includes problem detail and challenge header on failure', async () => {
334
433
  await expect(
335
434
  closeHttpSession({
@@ -747,6 +846,79 @@ describe('SseDriver', () => {
747
846
  await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce())
748
847
  },
749
848
  )
849
+
850
+ test('keeps voucher events bound to the challenge that opened the stream', async () => {
851
+ const channelId = `0x${'01'.repeat(32)}` as Hex.Hex
852
+ const token = '0x20c0000000000000000000000000000000000001' as Address
853
+ const payee = '0x0000000000000000000000000000000000000002' as Address
854
+ const descriptor: ChannelDescriptor = {
855
+ authorizedSigner: '0x0000000000000000000000000000000000000001',
856
+ expiringNonceHash: `0x${'03'.repeat(32)}` as Hex.Hex,
857
+ operator: '0x0000000000000000000000000000000000000000',
858
+ payee,
859
+ payer: '0x0000000000000000000000000000000000000001',
860
+ salt: `0x${'02'.repeat(32)}` as Hex.Hex,
861
+ token,
862
+ }
863
+ const makeChallenge = (id: string) =>
864
+ Challenge.from({
865
+ id,
866
+ intent: Constants.Intents.session,
867
+ method: Constants.Methods.tempo,
868
+ realm: 'example.test',
869
+ request: {
870
+ amount: '1',
871
+ currency: token,
872
+ methodDetails: { chainId: 4217, escrowContract: tip20ChannelEscrow },
873
+ recipient: payee,
874
+ unitType: 'request',
875
+ },
876
+ }) as TempoSessionChallenge
877
+ const streamChallenge = makeChallenge('challenge-1')
878
+ let currentChallenge = streamChallenge
879
+ const createSessionCredential = vi.fn(async () => 'Payment credential')
880
+ const channel: ChannelEntry = {
881
+ chainId: 4217,
882
+ channelId,
883
+ cumulativeAmount: 1n,
884
+ deposit: 10n,
885
+ descriptor,
886
+ escrow: tip20ChannelEscrow,
887
+ opened: true,
888
+ }
889
+ const response = new Response(
890
+ formatNeedVoucherEvent({
891
+ acceptedCumulative: '1',
892
+ channelId,
893
+ deposit: '10',
894
+ requiredCumulative: '2',
895
+ }),
896
+ { headers: { 'Content-Type': 'text/event-stream' } },
897
+ )
898
+ const stream = consumeSseSessionResponse('https://example.test/stream', response, undefined, {
899
+ acceptReceipt() {},
900
+ assertVoucherWithinLocalLimit() {},
901
+ createSessionCredential,
902
+ async doFetch() {
903
+ throw new Error('unexpected resource fetch')
904
+ },
905
+ fetch: vi.fn(async () => new Response(null, { status: 204 })),
906
+ getChallenge: () => currentChallenge,
907
+ getChannel: () => channel,
908
+ async topUpIfNeeded() {},
909
+ })
910
+
911
+ currentChallenge = makeChallenge('challenge-2')
912
+ const frames: string[] = []
913
+ for await (const frame of stream) frames.push(frame)
914
+
915
+ expect(frames).toEqual([])
916
+ expect(createSessionCredential).toHaveBeenCalledOnce()
917
+ expect(createSessionCredential).toHaveBeenCalledWith(
918
+ streamChallenge,
919
+ expect.objectContaining({ action: 'voucher', cumulativeAmountRaw: '2' }),
920
+ )
921
+ })
750
922
  })
751
923
 
752
924
  describe('WsDriver', () => {
@@ -869,6 +1041,7 @@ describe('WsDriver', () => {
869
1041
  methodDetails: {
870
1042
  chainId: 4217,
871
1043
  escrowContract: tip20ChannelEscrow,
1044
+ sessionProtocol: Constants.SessionProtocols.v2,
872
1045
  },
873
1046
  recipient: '0x742d35cc6634c0532925a3b844bc9e7595f8fe00',
874
1047
  },
@@ -1,4 +1,4 @@
1
- import type { Hex } from 'ox'
1
+ import { Hex } from 'ox'
2
2
 
3
3
  import * as Challenge from '../../../Challenge.js'
4
4
  import * as Fetch from '../../../client/internal/Fetch.js'
@@ -268,6 +268,8 @@ export type ApplyTopUpResultParameters = {
268
268
  challengeId?: string | undefined
269
269
  /** Current active machine state, used to preserve paid unit count when possible. */
270
270
  currentState: SessionState
271
+ /** Highest deposit known before the top-up. */
272
+ knownDeposit?: bigint | undefined
271
273
  /** Receipt returned by the top-up POST, when present. */
272
274
  receipt?: SessionReceipt | undefined
273
275
  /** Latest locally observed spend in raw units. */
@@ -362,26 +364,36 @@ export function resolveManualTopUp(parameters: ResolveManualTopUpParameters): Ma
362
364
  /**
363
365
  * Applies local deposit and state bookkeeping for a top-up response.
364
366
  *
365
- * `deposit` is updated from the top-up amount. Receipt cumulative values only
366
- * update accepted spend authorization; they do not replace deposit.
367
+ * Deposit advances from the highest known baseline. Receipt cumulative values
368
+ * only update accepted spend authorization; they do not replace deposit.
367
369
  */
368
370
  export function applyTopUpResult(
369
371
  parameters: ApplyTopUpResultParameters,
370
372
  ): AppliedTopUpResult | undefined {
371
- const { additionalDeposit, channel, channelId, challengeId, currentState, receipt, spent } =
372
- parameters
373
+ const {
374
+ additionalDeposit,
375
+ channel,
376
+ channelId,
377
+ challengeId,
378
+ currentState,
379
+ knownDeposit,
380
+ receipt,
381
+ spent,
382
+ } = parameters
373
383
  if (channel?.channelId !== channelId) return undefined
374
384
 
375
- channel.deposit += additionalDeposit
385
+ const baseline =
386
+ knownDeposit !== undefined && knownDeposit > channel.deposit ? knownDeposit : channel.deposit
387
+ const updated = { ...channel, deposit: baseline + additionalDeposit }
376
388
 
377
- if (receipt) return { channel, state: activeStateFromReceipt(receipt, channel) }
378
- if (!challengeId) return { channel }
389
+ if (receipt) return { channel: updated, state: activeStateFromReceipt(receipt, updated) }
390
+ if (!challengeId) return { channel: updated }
379
391
 
380
392
  return {
381
- channel,
393
+ channel: updated,
382
394
  state: activeStateFromChannel({
383
395
  challengeId,
384
- entry: channel,
396
+ entry: updated,
385
397
  spent: spent.toString(),
386
398
  units: currentState.status === 'active' ? currentState.units : 0,
387
399
  }),
@@ -502,6 +514,8 @@ export type CloseHttpSessionParameters = {
502
514
  fetch: typeof globalThis.fetch
503
515
  /** Last paid resource URL; used as the management endpoint base. */
504
516
  lastUrl: RequestInfo | URL | null
517
+ /** Resolves the close amount again when the server refreshes the challenge. */
518
+ resolveSignedCloseAmount?: ((challenge: TempoSessionChallenge) => string) | undefined
505
519
  /** Final cumulative amount the client is willing to sign. */
506
520
  signedCloseAmount: string
507
521
  /** Stores a fresh close challenge when the first close credential expired. */
@@ -519,6 +533,19 @@ export function isTempoSessionChallenge(
519
533
  )
520
534
  }
521
535
 
536
+ /** Returns true when a challenge selects the TIP-1034 session protocol. */
537
+ export function isTip1034SessionChallenge(
538
+ challenge: Challenge.Challenge,
539
+ ): challenge is TempoSessionChallenge {
540
+ return (
541
+ isTempoSessionChallenge(challenge) &&
542
+ Constants.getMethodDetail(
543
+ challenge.request.methodDetails,
544
+ Constants.MethodDetailKeys.sessionProtocol,
545
+ ) === Constants.SessionProtocols.v2
546
+ )
547
+ }
548
+
522
549
  /** Reads a server-provided session snapshot from challenge method details. */
523
550
  export function getSessionSnapshot(challenge: TempoSessionChallenge): SessionSnapshot | undefined {
524
551
  return Constants.getMethodDetail<SessionSnapshot>(
@@ -544,7 +571,7 @@ export function requestInitWithAuthorization(
544
571
  }
545
572
  }
546
573
 
547
- /** Returns the URL used for out-of-band management POSTs, stripping resource query state. */
574
+ /** Returns the exact resource URL used for out-of-band management POSTs, without its fragment. */
548
575
  export function managementInput(input: RequestInfo | URL): RequestInfo | URL {
549
576
  try {
550
577
  const base =
@@ -555,7 +582,7 @@ export function managementInput(input: RequestInfo | URL): RequestInfo | URL {
555
582
  : input instanceof URL
556
583
  ? new URL(input)
557
584
  : new URL(String(input), base)
558
- url.search = ''
585
+ url.hash = ''
559
586
  return url
560
587
  } catch {
561
588
  return input
@@ -592,15 +619,17 @@ export async function postTopUp(
592
619
  throw new Error('Cannot top up session: no local channel descriptor available.')
593
620
  }
594
621
 
595
- const credential = await parameters.createSessionCredential(parameters.challenge, {
596
- action: 'topUp',
597
- channelId,
598
- descriptor: channel.descriptor,
599
- additionalDepositRaw: parameters.additionalDeposit.toString(),
600
- })
601
- const response = await parameters.fetch(managementInput(parameters.input), {
602
- method: 'POST',
603
- headers: { [Constants.Headers.authorization]: credential },
622
+ const response = await postManagementCredential({
623
+ challenge: parameters.challenge,
624
+ context: {
625
+ action: 'topUp',
626
+ channelId,
627
+ descriptor: channel.descriptor,
628
+ additionalDepositRaw: parameters.additionalDeposit.toString(),
629
+ },
630
+ createSessionCredential: parameters.createSessionCredential,
631
+ fetch: parameters.fetch,
632
+ input: parameters.input,
604
633
  })
605
634
  if (!response.ok) throw new Error(`Top-up POST failed with status ${response.status}`)
606
635
 
@@ -608,6 +637,30 @@ export async function postTopUp(
608
637
  return receiptHeader ? deserializeSessionReceipt(receiptHeader) : undefined
609
638
  }
610
639
 
640
+ /** Posts a management credential and retries once with a refreshed TIP-1034 challenge. */
641
+ async function postManagementCredential(parameters: {
642
+ challenge: TempoSessionChallenge
643
+ context: SessionContext
644
+ createSessionCredential: CreateSessionCredential
645
+ fetch: typeof globalThis.fetch
646
+ input: RequestInfo | URL
647
+ }): Promise<Response> {
648
+ const post = async (challenge: TempoSessionChallenge) =>
649
+ parameters.fetch(managementInput(parameters.input), {
650
+ method: 'POST',
651
+ headers: {
652
+ [Constants.Headers.authorization]: await parameters.createSessionCredential(
653
+ challenge,
654
+ parameters.context,
655
+ ),
656
+ },
657
+ })
658
+ const response = await post(parameters.challenge)
659
+ if (response.status !== 402) return response
660
+ const challenge = Challenge.fromResponseList(response).find(isTip1034SessionChallenge)
661
+ return challenge ? post(challenge) : response
662
+ }
663
+
611
664
  /** Retries an HTTP 402 when the server snapshot asks for more voucher headroom. */
612
665
  export async function retryHttpPaymentRequired(
613
666
  parameters: RetryHttpPaymentRequiredParameters,
@@ -660,7 +713,7 @@ export function resolveRetryHttpPaymentContext(parameters: {
660
713
  channel: ChannelEntry | null
661
714
  response: Response
662
715
  }): RetryHttpPaymentContext | undefined {
663
- const challenge = Challenge.fromResponseList(parameters.response).find(isTempoSessionChallenge)
716
+ const challenge = Challenge.fromResponseList(parameters.response).find(isTip1034SessionChallenge)
664
717
  if (!challenge) return undefined
665
718
 
666
719
  const snapshot = getSessionSnapshot(challenge)
@@ -682,12 +735,18 @@ export async function closeHttpSession(
682
735
  )
683
736
  }
684
737
 
685
- const postClose = async (challenge: TempoSessionChallenge) => {
738
+ let closeChallenge = parameters.target.challenge
739
+ let signedCloseAmount = parameters.signedCloseAmount
740
+ const postClose = async (challenge: TempoSessionChallenge, refreshed = false) => {
741
+ closeChallenge = challenge
742
+ signedCloseAmount =
743
+ (refreshed ? parameters.resolveSignedCloseAmount?.(challenge) : undefined) ??
744
+ parameters.signedCloseAmount
686
745
  const credential = await parameters.createSessionCredential(challenge, {
687
746
  action: 'close',
688
747
  channelId: parameters.target.channelId,
689
748
  descriptor: parameters.target.channel.descriptor,
690
- cumulativeAmountRaw: parameters.signedCloseAmount,
749
+ cumulativeAmountRaw: signedCloseAmount,
691
750
  })
692
751
  return parameters.fetch(parameters.lastUrl!, {
693
752
  method: 'POST',
@@ -697,10 +756,10 @@ export async function closeHttpSession(
697
756
 
698
757
  let response = await postClose(parameters.target.challenge)
699
758
  if (response.status === 402) {
700
- const challenge = Challenge.fromResponseList(response).find(isTempoSessionChallenge)
759
+ const challenge = Challenge.fromResponseList(response).find(isTip1034SessionChallenge)
701
760
  if (challenge) {
702
761
  parameters.setChallenge?.(challenge)
703
- response = await postClose(challenge)
762
+ response = await postClose(challenge, true)
704
763
  }
705
764
  }
706
765
  if (!response.ok) {
@@ -712,7 +771,21 @@ export async function closeHttpSession(
712
771
  }
713
772
 
714
773
  const receiptHeader = response.headers.get(Constants.Headers.paymentReceipt)
715
- return receiptHeader ? deserializeSessionReceipt(receiptHeader) : undefined
774
+ if (!receiptHeader) return undefined
775
+ const receipt = deserializeSessionReceipt(receiptHeader)
776
+ if (
777
+ !receipt.txHash ||
778
+ !Hex.validate(receipt.txHash, { strict: true }) ||
779
+ Hex.size(receipt.txHash) !== 32 ||
780
+ !isExpectedCloseReceipt({
781
+ challengeId: closeChallenge.id,
782
+ channelId: parameters.target.channelId,
783
+ expectedCloseAmount: signedCloseAmount,
784
+ receipt,
785
+ })
786
+ )
787
+ throw new Error('Session close response included a mismatched payment receipt.')
788
+ return receipt
716
789
  }
717
790
 
718
791
  /** Options accepted by the auto-driving SSE session flow. */
@@ -723,6 +796,12 @@ export type SseDriverOptions = RequestInit & {
723
796
  signal?: AbortSignal | undefined
724
797
  }
725
798
 
799
+ /** @internal Options used when consuming an already-paid SSE response. */
800
+ export type SseResponseOptions = {
801
+ onReceipt?: ((receipt: SessionReceipt) => void) | undefined
802
+ signal?: AbortSignal | undefined
803
+ }
804
+
726
805
  /** Dependencies the SSE driver needs from `SessionManager`. */
727
806
  export type OpenSseSessionParameters = {
728
807
  /** Creates a session credential for the selected challenge/context. */
@@ -740,8 +819,6 @@ export type OpenSseSessionParameters = {
740
819
  getChallenge(): TempoSessionChallenge | null
741
820
  /** Validates a cumulative amount against local client policy. */
742
821
  assertVoucherWithinLocalLimit(cumulativeAmount: bigint): void
743
- /** Converts a resource URL to the server's session management URL. */
744
- managementInput(input: RequestInfo | URL): RequestInfo | URL
745
822
  /** Applies an incoming receipt to manager state. */
746
823
  acceptReceipt(receipt: SessionReceipt): void
747
824
  /** Performs an automatic channel top-up when deposit is insufficient. */
@@ -755,7 +832,6 @@ export type SsePaymentDriver = Pick<
755
832
  | 'createSessionCredential'
756
833
  | 'fetch'
757
834
  | 'getChannel'
758
- | 'managementInput'
759
835
  | 'topUpIfNeeded'
760
836
  >
761
837
 
@@ -784,18 +860,28 @@ export async function openSseSession(
784
860
  if (!isEventStream(response) && driver.getChannel()?.opened)
785
861
  response = await driver.doFetch(input, sseInit)
786
862
 
787
- const challenge = driver.getChallenge()
863
+ return consumeSseSessionResponse(input, response, { onReceipt, signal }, driver)
864
+ }
865
+
866
+ /** @internal Consumes an already-paid SSE response with the session transport driver. */
867
+ export function consumeSseSessionResponse(
868
+ input: RequestInfo | URL,
869
+ response: Response,
870
+ options: SseResponseOptions | undefined,
871
+ driver: OpenSseSessionParameters,
872
+ ): AsyncIterable<string> {
788
873
  if (!isEventStream(response)) throw new Error('SSE response is not an event stream.')
789
874
  if (!response.body) throw new Error('Response has no body.')
875
+ const challenge = driver.getChallenge()
790
876
 
791
877
  return iterateSseMessages({
792
878
  onNeedVoucher: (event) => handleSseNeedVoucher({ challenge, driver, input }, event),
793
879
  onReceipt(receipt) {
794
880
  driver.acceptReceipt(receipt)
795
- onReceipt?.(receipt)
881
+ options?.onReceipt?.(receipt)
796
882
  },
797
883
  response,
798
- signal,
884
+ signal: options?.signal,
799
885
  })
800
886
  }
801
887
 
@@ -980,17 +1066,13 @@ export async function handleSseNeedVoucher(
980
1066
  })
981
1067
  if (resolution.status !== 'ready') return
982
1068
 
983
- const credential = await parameters.driver.createSessionCredential(
984
- parameters.challenge,
985
- resolution.context,
986
- )
987
- const voucherResponse = await parameters.driver.fetch(
988
- parameters.driver.managementInput(parameters.input),
989
- {
990
- method: 'POST',
991
- headers: { [Constants.Headers.authorization]: credential },
992
- },
993
- )
1069
+ const voucherResponse = await postManagementCredential({
1070
+ challenge: parameters.challenge,
1071
+ context: resolution.context,
1072
+ createSessionCredential: parameters.driver.createSessionCredential,
1073
+ fetch: parameters.driver.fetch,
1074
+ input: parameters.input,
1075
+ })
994
1076
  if (!voucherResponse.ok) {
995
1077
  throw new Error(`Voucher POST failed with status ${voucherResponse.status}`)
996
1078
  }
@@ -1148,7 +1230,7 @@ export async function prepareWebSocketSession(
1148
1230
  )
1149
1231
  }
1150
1232
 
1151
- const challenge = Challenge.fromResponseList(probe).find(isTempoSessionChallenge)
1233
+ const challenge = Challenge.fromResponseList(probe).find(isTip1034SessionChallenge)
1152
1234
  if (!challenge) {
1153
1235
  throw new Error(
1154
1236
  'No payment challenge received from HTTP endpoint for this WebSocket URL. The server may not require payment or did not advertise a challenge.',
@@ -0,0 +1,36 @@
1
+ import type { ChannelEntry } from '../ChannelOps.js'
2
+ import type { PaymentResponse, SessionManager } from '../SessionManager.js'
3
+ import type { SseResponseOptions, TempoSessionChallenge } from '../Transports.js'
4
+
5
+ type RehydrateParameters = {
6
+ channel: ChannelEntry
7
+ challenge: TempoSessionChallenge
8
+ input: RequestInfo | URL
9
+ spent: bigint
10
+ }
11
+
12
+ type SessionManagerInternals = {
13
+ consumeSseResponse(
14
+ input: RequestInfo | URL,
15
+ response: PaymentResponse,
16
+ options?: SseResponseOptions | undefined,
17
+ ): AsyncIterable<string>
18
+ rehydrate(parameters: RehydrateParameters): void
19
+ }
20
+
21
+ const internals = new WeakMap<SessionManager, SessionManagerInternals>()
22
+
23
+ /** @internal Registers private transport and recovery hooks for a session manager. */
24
+ export function registerSessionManagerInternals(
25
+ manager: SessionManager,
26
+ value: SessionManagerInternals,
27
+ ): void {
28
+ internals.set(manager, value)
29
+ }
30
+
31
+ /** @internal Returns private transport and recovery hooks for a session manager. */
32
+ export function getSessionManagerInternals(manager: SessionManager): SessionManagerInternals {
33
+ const value = internals.get(manager)
34
+ if (!value) throw new Error('Session manager internals are unavailable.')
35
+ return value
36
+ }
@@ -1,5 +1,12 @@
1
1
  import { Hex } from 'ox'
2
- import { type Address, encodeFunctionData, isAddressEqual, parseEventLogs, zeroAddress } from 'viem'
2
+ import {
3
+ type Address,
4
+ encodeFunctionData,
5
+ isAddressEqual,
6
+ maxUint256,
7
+ parseEventLogs,
8
+ zeroAddress,
9
+ } from 'viem'
3
10
  import { sendTransaction, waitForTransactionReceipt } from 'viem/actions'
4
11
  import { Transaction } from 'viem/tempo'
5
12
  import { describe, expect, test } from 'vp/test'
@@ -42,6 +49,15 @@ function getSingleEvent(receipt: { logs: readonly unknown[] }, name: string) {
42
49
  return logs[0] as unknown as { args: Record<string, unknown> }
43
50
  }
44
51
 
52
+ function expectExpiringNonce(serializedTransaction: Hex.Hex) {
53
+ const transaction = Transaction.deserialize(
54
+ serializedTransaction as Transaction.TransactionSerializedTempo,
55
+ ) as unknown as { nonce: number; nonceKey: bigint; validBefore?: bigint }
56
+ expect(transaction.nonce).toBe(0)
57
+ expect(transaction.nonceKey).toBe(maxUint256)
58
+ expect(transaction.validBefore).toBeDefined()
59
+ }
60
+
45
61
  async function openChannel(parameters: { deposit?: bigint | undefined } = {}) {
46
62
  const deposit = uint96(parameters.deposit ?? 1_000n)
47
63
  const salt = Hex.random(32)
@@ -120,6 +136,7 @@ describe.runIf(isPrecompileTestnet)('TIP20EscrowChannel precompile chain operati
120
136
  token: asset,
121
137
  })
122
138
  if (payload.action !== 'open') throw new Error('expected open payload')
139
+ expectExpiringNonce(payload.transaction)
123
140
 
124
141
  const transaction = Transaction.deserialize(
125
142
  payload.transaction as Transaction.TransactionSerializedTempo,
@@ -224,6 +241,7 @@ describe.runIf(isPrecompileTestnet)('TIP20EscrowChannel precompile chain operati
224
241
  chain.id,
225
242
  )
226
243
  if (topUp.action !== 'topUp') throw new Error('expected topUp payload')
244
+ expectExpiringNonce(topUp.transaction)
227
245
 
228
246
  const result = await Chain.broadcastTopUpTransaction({
229
247
  additionalDeposit,