mppx 0.6.30 → 0.7.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 (483) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/Challenge.d.ts.map +1 -1
  3. package/dist/Challenge.js +9 -7
  4. package/dist/Challenge.js.map +1 -1
  5. package/dist/Constants.d.ts +46 -0
  6. package/dist/Constants.d.ts.map +1 -0
  7. package/dist/Constants.js +46 -0
  8. package/dist/Constants.js.map +1 -0
  9. package/dist/Credential.d.ts.map +1 -1
  10. package/dist/Credential.js +5 -4
  11. package/dist/Credential.js.map +1 -1
  12. package/dist/Method.d.ts +32 -4
  13. package/dist/Method.d.ts.map +1 -1
  14. package/dist/Method.js +5 -2
  15. package/dist/Method.js.map +1 -1
  16. package/dist/Receipt.d.ts.map +1 -1
  17. package/dist/Receipt.js +3 -2
  18. package/dist/Receipt.js.map +1 -1
  19. package/dist/cli/cli.d.ts.map +1 -1
  20. package/dist/cli/cli.js +19 -11
  21. package/dist/cli/cli.js.map +1 -1
  22. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  23. package/dist/cli/plugins/tempo.js +17 -6
  24. package/dist/cli/plugins/tempo.js.map +1 -1
  25. package/dist/cli/utils.d.ts +5 -0
  26. package/dist/cli/utils.d.ts.map +1 -1
  27. package/dist/cli/utils.js +10 -0
  28. package/dist/cli/utils.js.map +1 -1
  29. package/dist/client/Methods.d.ts +5 -2
  30. package/dist/client/Methods.d.ts.map +1 -1
  31. package/dist/client/Methods.js +5 -2
  32. package/dist/client/Methods.js.map +1 -1
  33. package/dist/client/Transport.d.ts.map +1 -1
  34. package/dist/client/Transport.js +4 -5
  35. package/dist/client/Transport.js.map +1 -1
  36. package/dist/client/index.d.ts +2 -1
  37. package/dist/client/index.d.ts.map +1 -1
  38. package/dist/client/index.js +2 -1
  39. package/dist/client/index.js.map +1 -1
  40. package/dist/client/internal/Fetch.d.ts.map +1 -1
  41. package/dist/client/internal/Fetch.js +14 -6
  42. package/dist/client/internal/Fetch.js.map +1 -1
  43. package/dist/evm/server/Methods.d.ts +1 -1
  44. package/dist/evm/server/Methods.d.ts.map +1 -1
  45. package/dist/index.d.ts +1 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +1 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/internal/AcceptPayment.d.ts +3 -0
  50. package/dist/internal/AcceptPayment.d.ts.map +1 -1
  51. package/dist/internal/AcceptPayment.js +15 -11
  52. package/dist/internal/AcceptPayment.js.map +1 -1
  53. package/dist/mcp-sdk/client/McpClient.d.ts +12 -5
  54. package/dist/mcp-sdk/client/McpClient.d.ts.map +1 -1
  55. package/dist/mcp-sdk/client/McpClient.js +55 -42
  56. package/dist/mcp-sdk/client/McpClient.js.map +1 -1
  57. package/dist/server/Mppx.d.ts +11 -3
  58. package/dist/server/Mppx.d.ts.map +1 -1
  59. package/dist/server/Mppx.js +76 -27
  60. package/dist/server/Mppx.js.map +1 -1
  61. package/dist/server/Request.js +24 -10
  62. package/dist/server/Request.js.map +1 -1
  63. package/dist/server/Response.d.ts.map +1 -1
  64. package/dist/server/Response.js +2 -1
  65. package/dist/server/Response.js.map +1 -1
  66. package/dist/server/Transport.d.ts.map +1 -1
  67. package/dist/server/Transport.js +4 -3
  68. package/dist/server/Transport.js.map +1 -1
  69. package/dist/server/index.d.ts +1 -0
  70. package/dist/server/index.d.ts.map +1 -1
  71. package/dist/server/index.js +1 -0
  72. package/dist/server/index.js.map +1 -1
  73. package/dist/stripe/client/Charge.d.ts +1 -1
  74. package/dist/stripe/client/Charge.d.ts.map +1 -1
  75. package/dist/stripe/client/Charge.js +3 -1
  76. package/dist/stripe/client/Charge.js.map +1 -1
  77. package/dist/stripe/server/Charge.d.ts +1 -1
  78. package/dist/stripe/server/Charge.d.ts.map +1 -1
  79. package/dist/stripe/server/Charge.js +9 -2
  80. package/dist/stripe/server/Charge.js.map +1 -1
  81. package/dist/stripe/server/Methods.d.ts +1 -1
  82. package/dist/stripe/server/Methods.d.ts.map +1 -1
  83. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  84. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  85. package/dist/stripe/server/internal/html.gen.js +1 -1
  86. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  87. package/dist/tempo/Methods.d.ts +18 -0
  88. package/dist/tempo/Methods.d.ts.map +1 -1
  89. package/dist/tempo/Methods.js +16 -1
  90. package/dist/tempo/Methods.js.map +1 -1
  91. package/dist/tempo/client/Charge.d.ts +6 -0
  92. package/dist/tempo/client/Charge.d.ts.map +1 -1
  93. package/dist/tempo/client/Charge.js +9 -2
  94. package/dist/tempo/client/Charge.js.map +1 -1
  95. package/dist/tempo/client/Methods.d.ts +36 -7
  96. package/dist/tempo/client/Methods.d.ts.map +1 -1
  97. package/dist/tempo/client/Methods.js +12 -5
  98. package/dist/tempo/client/Methods.js.map +1 -1
  99. package/dist/tempo/client/index.d.ts +7 -4
  100. package/dist/tempo/client/index.d.ts.map +1 -1
  101. package/dist/tempo/client/index.js +5 -3
  102. package/dist/tempo/client/index.js.map +1 -1
  103. package/dist/tempo/index.d.ts +1 -0
  104. package/dist/tempo/index.d.ts.map +1 -1
  105. package/dist/tempo/index.js +1 -0
  106. package/dist/tempo/index.js.map +1 -1
  107. package/dist/tempo/internal/fee-payer.d.ts +21 -1
  108. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  109. package/dist/tempo/internal/fee-payer.js +109 -4
  110. package/dist/tempo/internal/fee-payer.js.map +1 -1
  111. package/dist/tempo/{client → legacy/client}/ChannelOps.d.ts +19 -6
  112. package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -0
  113. package/dist/tempo/{client → legacy/client}/ChannelOps.js +9 -3
  114. package/dist/tempo/legacy/client/ChannelOps.js.map +1 -0
  115. package/dist/tempo/{client → legacy/client}/Session.d.ts +23 -4
  116. package/dist/tempo/legacy/client/Session.d.ts.map +1 -0
  117. package/dist/tempo/{client → legacy/client}/Session.js +14 -7
  118. package/dist/tempo/legacy/client/Session.js.map +1 -0
  119. package/dist/tempo/{client → legacy/client}/SessionManager.d.ts +20 -5
  120. package/dist/tempo/legacy/client/SessionManager.d.ts.map +1 -0
  121. package/dist/tempo/{client → legacy/client}/SessionManager.js +20 -16
  122. package/dist/tempo/legacy/client/SessionManager.js.map +1 -0
  123. package/dist/tempo/legacy/client/index.d.ts +7 -0
  124. package/dist/tempo/legacy/client/index.d.ts.map +1 -0
  125. package/dist/tempo/legacy/client/index.js +5 -0
  126. package/dist/tempo/legacy/client/index.js.map +1 -0
  127. package/dist/tempo/legacy/index.d.ts +7 -0
  128. package/dist/tempo/legacy/index.d.ts.map +1 -0
  129. package/dist/tempo/legacy/index.js +7 -0
  130. package/dist/tempo/legacy/index.js.map +1 -0
  131. package/dist/tempo/{server → legacy/server}/Session.d.ts +28 -11
  132. package/dist/tempo/legacy/server/Session.d.ts.map +1 -0
  133. package/dist/tempo/{server → legacy/server}/Session.js +28 -23
  134. package/dist/tempo/legacy/server/Session.js.map +1 -0
  135. package/dist/tempo/legacy/server/index.d.ts +5 -0
  136. package/dist/tempo/legacy/server/index.d.ts.map +1 -0
  137. package/dist/tempo/legacy/server/index.js +5 -0
  138. package/dist/tempo/legacy/server/index.js.map +1 -0
  139. package/dist/tempo/{session → legacy/session}/Chain.d.ts +30 -23
  140. package/dist/tempo/legacy/session/Chain.d.ts.map +1 -0
  141. package/dist/tempo/{session → legacy/session}/Chain.js +12 -11
  142. package/dist/tempo/legacy/session/Chain.js.map +1 -0
  143. package/dist/tempo/{session → legacy/session}/Channel.d.ts +1 -0
  144. package/dist/tempo/legacy/session/Channel.d.ts.map +1 -0
  145. package/dist/tempo/legacy/session/Channel.js.map +1 -0
  146. package/dist/tempo/legacy/session/ChannelStore.d.ts +22 -0
  147. package/dist/tempo/legacy/session/ChannelStore.d.ts.map +1 -0
  148. package/dist/tempo/legacy/session/ChannelStore.js +6 -0
  149. package/dist/tempo/legacy/session/ChannelStore.js.map +1 -0
  150. package/dist/tempo/legacy/session/Types.d.ts +73 -0
  151. package/dist/tempo/legacy/session/Types.d.ts.map +1 -0
  152. package/dist/tempo/legacy/session/Types.js.map +1 -0
  153. package/dist/tempo/{session → legacy/session}/Voucher.d.ts +4 -4
  154. package/dist/tempo/legacy/session/Voucher.d.ts.map +1 -0
  155. package/dist/tempo/{session → legacy/session}/Voucher.js +1 -1
  156. package/dist/tempo/legacy/session/Voucher.js.map +1 -0
  157. package/dist/tempo/{session → legacy/session}/escrow.abi.d.ts +1 -0
  158. package/dist/tempo/{session → legacy/session}/escrow.abi.d.ts.map +1 -1
  159. package/dist/tempo/{session → legacy/session}/escrow.abi.js +1 -0
  160. package/dist/tempo/legacy/session/escrow.abi.js.map +1 -0
  161. package/dist/tempo/legacy/session/index.d.ts +9 -0
  162. package/dist/tempo/legacy/session/index.d.ts.map +1 -0
  163. package/dist/tempo/legacy/session/index.js +9 -0
  164. package/dist/tempo/legacy/session/index.js.map +1 -0
  165. package/dist/tempo/server/Charge.d.ts +1 -1
  166. package/dist/tempo/server/Charge.d.ts.map +1 -1
  167. package/dist/tempo/server/Charge.js +13 -16
  168. package/dist/tempo/server/Charge.js.map +1 -1
  169. package/dist/tempo/server/Methods.d.ts +63 -6
  170. package/dist/tempo/server/Methods.d.ts.map +1 -1
  171. package/dist/tempo/server/Methods.js +36 -8
  172. package/dist/tempo/server/Methods.js.map +1 -1
  173. package/dist/tempo/server/Subscription.d.ts +1 -1
  174. package/dist/tempo/server/Subscription.d.ts.map +1 -1
  175. package/dist/tempo/server/index.d.ts +6 -5
  176. package/dist/tempo/server/index.d.ts.map +1 -1
  177. package/dist/tempo/server/index.js +5 -5
  178. package/dist/tempo/server/index.js.map +1 -1
  179. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  180. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  181. package/dist/tempo/server/internal/html.gen.js +1 -1
  182. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  183. package/dist/tempo/server/internal/request-body.d.ts +7 -2
  184. package/dist/tempo/server/internal/request-body.d.ts.map +1 -1
  185. package/dist/tempo/server/internal/request-body.js +20 -3
  186. package/dist/tempo/server/internal/request-body.js.map +1 -1
  187. package/dist/tempo/server/internal/transport.d.ts +8 -4
  188. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  189. package/dist/tempo/server/internal/transport.js +8 -7
  190. package/dist/tempo/server/internal/transport.js.map +1 -1
  191. package/dist/tempo/session/Snapshot.d.ts +32 -0
  192. package/dist/tempo/session/Snapshot.d.ts.map +1 -0
  193. package/dist/tempo/session/Snapshot.js +37 -0
  194. package/dist/tempo/session/Snapshot.js.map +1 -0
  195. package/dist/tempo/session/client/ChannelOps.d.ts +82 -0
  196. package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -0
  197. package/dist/tempo/session/client/ChannelOps.js +204 -0
  198. package/dist/tempo/session/client/ChannelOps.js.map +1 -0
  199. package/dist/tempo/session/client/CredentialState.d.ts +262 -0
  200. package/dist/tempo/session/client/CredentialState.d.ts.map +1 -0
  201. package/dist/tempo/session/client/CredentialState.js +417 -0
  202. package/dist/tempo/session/client/CredentialState.js.map +1 -0
  203. package/dist/tempo/session/client/ReceiptCoordinator.d.ts +26 -0
  204. package/dist/tempo/session/client/ReceiptCoordinator.d.ts.map +1 -0
  205. package/dist/tempo/session/client/ReceiptCoordinator.js +61 -0
  206. package/dist/tempo/session/client/ReceiptCoordinator.js.map +1 -0
  207. package/dist/tempo/session/client/Runtime.d.ts +464 -0
  208. package/dist/tempo/session/client/Runtime.d.ts.map +1 -0
  209. package/dist/tempo/session/client/Runtime.js +499 -0
  210. package/dist/tempo/session/client/Runtime.js.map +1 -0
  211. package/dist/tempo/session/client/Session.d.ts +132 -0
  212. package/dist/tempo/session/client/Session.d.ts.map +1 -0
  213. package/dist/tempo/session/client/Session.js +55 -0
  214. package/dist/tempo/session/client/Session.js.map +1 -0
  215. package/dist/tempo/session/client/SessionManager.d.ts +120 -0
  216. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -0
  217. package/dist/tempo/session/client/SessionManager.js +627 -0
  218. package/dist/tempo/session/client/SessionManager.js.map +1 -0
  219. package/dist/tempo/session/client/Transports.d.ts +449 -0
  220. package/dist/tempo/session/client/Transports.d.ts.map +1 -0
  221. package/dist/tempo/session/client/Transports.js +721 -0
  222. package/dist/tempo/session/client/Transports.js.map +1 -0
  223. package/dist/tempo/session/client/index.d.ts +12 -0
  224. package/dist/tempo/session/client/index.d.ts.map +1 -0
  225. package/dist/tempo/session/client/index.js +5 -0
  226. package/dist/tempo/session/client/index.js.map +1 -0
  227. package/dist/tempo/session/index.d.ts +7 -8
  228. package/dist/tempo/session/index.d.ts.map +1 -1
  229. package/dist/tempo/session/index.js +7 -8
  230. package/dist/tempo/session/index.js.map +1 -1
  231. package/dist/tempo/session/precompile/Chain.d.ts +319 -0
  232. package/dist/tempo/session/precompile/Chain.d.ts.map +1 -0
  233. package/dist/tempo/session/precompile/Chain.js +492 -0
  234. package/dist/tempo/session/precompile/Chain.js.map +1 -0
  235. package/dist/tempo/session/precompile/Channel.d.ts +46 -0
  236. package/dist/tempo/session/precompile/Channel.d.ts.map +1 -0
  237. package/dist/tempo/session/precompile/Channel.js +56 -0
  238. package/dist/tempo/session/precompile/Channel.js.map +1 -0
  239. package/dist/tempo/session/precompile/Protocol.d.ts +308 -0
  240. package/dist/tempo/session/precompile/Protocol.d.ts.map +1 -0
  241. package/dist/tempo/session/precompile/Protocol.js +264 -0
  242. package/dist/tempo/session/precompile/Protocol.js.map +1 -0
  243. package/dist/tempo/session/precompile/Voucher.d.ts +40 -0
  244. package/dist/tempo/session/precompile/Voucher.d.ts.map +1 -0
  245. package/dist/tempo/session/precompile/Voucher.js +126 -0
  246. package/dist/tempo/session/precompile/Voucher.js.map +1 -0
  247. package/dist/tempo/session/precompile/escrow.abi.d.ts +522 -0
  248. package/dist/tempo/session/precompile/escrow.abi.d.ts.map +1 -0
  249. package/dist/tempo/session/precompile/escrow.abi.js +224 -0
  250. package/dist/tempo/session/precompile/escrow.abi.js.map +1 -0
  251. package/dist/tempo/session/precompile/index.d.ts +24 -0
  252. package/dist/tempo/session/precompile/index.d.ts.map +1 -0
  253. package/dist/tempo/session/precompile/index.js +22 -0
  254. package/dist/tempo/session/precompile/index.js.map +1 -0
  255. package/dist/tempo/session/server/ChannelOps.d.ts +56 -0
  256. package/dist/tempo/session/server/ChannelOps.d.ts.map +1 -0
  257. package/dist/tempo/session/server/ChannelOps.js +91 -0
  258. package/dist/tempo/session/server/ChannelOps.js.map +1 -0
  259. package/dist/tempo/session/server/ChannelStore.d.ts +347 -0
  260. package/dist/tempo/session/server/ChannelStore.d.ts.map +1 -0
  261. package/dist/tempo/session/server/ChannelStore.js +404 -0
  262. package/dist/tempo/session/server/ChannelStore.js.map +1 -0
  263. package/dist/tempo/session/server/CredentialVerification.d.ts +85 -0
  264. package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -0
  265. package/dist/tempo/session/server/CredentialVerification.js +494 -0
  266. package/dist/tempo/session/server/CredentialVerification.js.map +1 -0
  267. package/dist/tempo/session/server/MeteredStream.d.ts +40 -0
  268. package/dist/tempo/session/server/MeteredStream.d.ts.map +1 -0
  269. package/dist/tempo/session/server/MeteredStream.js +42 -0
  270. package/dist/tempo/session/server/MeteredStream.js.map +1 -0
  271. package/dist/tempo/session/server/RequestState.d.ts +208 -0
  272. package/dist/tempo/session/server/RequestState.d.ts.map +1 -0
  273. package/dist/tempo/session/server/RequestState.js +252 -0
  274. package/dist/tempo/session/server/RequestState.js.map +1 -0
  275. package/dist/tempo/session/server/Session.d.ts +169 -0
  276. package/dist/tempo/session/server/Session.d.ts.map +1 -0
  277. package/dist/tempo/session/server/Session.js +351 -0
  278. package/dist/tempo/session/server/Session.js.map +1 -0
  279. package/dist/tempo/session/server/Settlement.d.ts +185 -0
  280. package/dist/tempo/session/server/Settlement.d.ts.map +1 -0
  281. package/dist/tempo/session/server/Settlement.js +250 -0
  282. package/dist/tempo/session/server/Settlement.js.map +1 -0
  283. package/dist/tempo/session/{Sse.d.ts → server/Sse.d.ts} +9 -56
  284. package/dist/tempo/session/server/Sse.d.ts.map +1 -0
  285. package/dist/tempo/session/server/Sse.js +184 -0
  286. package/dist/tempo/session/server/Sse.js.map +1 -0
  287. package/dist/tempo/session/server/Transports.d.ts +89 -0
  288. package/dist/tempo/session/server/Transports.d.ts.map +1 -0
  289. package/dist/tempo/session/server/Transports.js +149 -0
  290. package/dist/tempo/session/server/Transports.js.map +1 -0
  291. package/dist/tempo/session/server/Ws.d.ts +48 -0
  292. package/dist/tempo/session/server/Ws.d.ts.map +1 -0
  293. package/dist/tempo/session/server/Ws.js +244 -0
  294. package/dist/tempo/session/server/Ws.js.map +1 -0
  295. package/dist/tempo/session/server/index.d.ts +4 -0
  296. package/dist/tempo/session/server/index.d.ts.map +1 -0
  297. package/dist/tempo/session/server/index.js +2 -0
  298. package/dist/tempo/session/server/index.js.map +1 -0
  299. package/package.json +8 -3
  300. package/src/Challenge.ts +9 -7
  301. package/src/Constants.ts +58 -0
  302. package/src/Credential.ts +5 -4
  303. package/src/Method.ts +46 -5
  304. package/src/Receipt.ts +3 -2
  305. package/src/cli/cli.test.ts +23 -28
  306. package/src/cli/cli.ts +23 -10
  307. package/src/cli/mcp.test.ts +21 -7
  308. package/src/cli/plugins/tempo.ts +21 -8
  309. package/src/cli/utils.test.ts +25 -1
  310. package/src/cli/utils.ts +10 -0
  311. package/src/client/Methods.ts +5 -2
  312. package/src/client/Mppx.test-d.ts +10 -0
  313. package/src/client/Mppx.test.ts +75 -0
  314. package/src/client/Transport.ts +4 -5
  315. package/src/client/index.ts +11 -1
  316. package/src/client/internal/Fetch.test.ts +29 -4
  317. package/src/client/internal/Fetch.ts +17 -5
  318. package/src/env.d.ts +1 -1
  319. package/src/index.ts +1 -0
  320. package/src/internal/AcceptPayment.test.ts +61 -0
  321. package/src/internal/AcceptPayment.ts +21 -14
  322. package/src/mcp-sdk/client/McpClient.integration.test.ts +8 -7
  323. package/src/mcp-sdk/client/McpClient.test-d.ts +7 -0
  324. package/src/mcp-sdk/client/McpClient.ts +99 -67
  325. package/src/mcp-sdk/client/McpClient.unit.test.ts +131 -0
  326. package/src/middlewares/elysia.test.ts +8 -4
  327. package/src/middlewares/express.test.ts +8 -4
  328. package/src/middlewares/hono.test.ts +4 -4
  329. package/src/middlewares/nextjs.test.ts +8 -4
  330. package/src/proxy/Proxy.test.ts +8 -8
  331. package/src/server/Mppx.test-d.ts +54 -0
  332. package/src/server/Mppx.test.ts +274 -7
  333. package/src/server/Mppx.ts +487 -406
  334. package/src/server/Request.test.ts +81 -0
  335. package/src/server/Request.ts +23 -9
  336. package/src/server/Response.ts +2 -1
  337. package/src/server/Transport.ts +4 -3
  338. package/src/server/index.ts +1 -0
  339. package/src/stripe/client/Charge.test.ts +20 -5
  340. package/src/stripe/client/Charge.ts +6 -2
  341. package/src/stripe/server/Charge.test.ts +114 -1
  342. package/src/stripe/server/Charge.ts +13 -2
  343. package/src/stripe/server/internal/html/package.json +1 -1
  344. package/src/stripe/server/internal/html.gen.ts +1 -1
  345. package/src/tempo/AccessKeyAuthorization.test.ts +4 -94
  346. package/src/tempo/Methods.test.ts +45 -17
  347. package/src/tempo/Methods.ts +22 -0
  348. package/src/tempo/PublicExports.test-d.ts +105 -0
  349. package/src/tempo/client/Charge.test.ts +85 -0
  350. package/src/tempo/client/Charge.ts +19 -2
  351. package/src/tempo/client/Methods.ts +18 -6
  352. package/src/tempo/client/index.ts +15 -4
  353. package/src/tempo/index.ts +1 -0
  354. package/src/tempo/internal/fee-payer.test.ts +241 -17
  355. package/src/tempo/internal/fee-payer.ts +150 -4
  356. package/src/tempo/internal/fee-token.test.ts +14 -9
  357. package/src/tempo/legacy/AccessKeyAuthorization.test.ts +162 -0
  358. package/src/tempo/legacy/README.md +9 -0
  359. package/src/tempo/{client → legacy/client}/ChannelOps.test.ts +6 -7
  360. package/src/tempo/{client → legacy/client}/ChannelOps.ts +22 -9
  361. package/src/tempo/{client → legacy/client}/Session.test.ts +51 -9
  362. package/src/tempo/{client → legacy/client}/Session.ts +25 -11
  363. package/src/tempo/{client → legacy/client}/SessionManager.test.ts +81 -9
  364. package/src/tempo/{client → legacy/client}/SessionManager.ts +41 -20
  365. package/src/tempo/legacy/client/index.ts +6 -0
  366. package/src/tempo/legacy/index.ts +6 -0
  367. package/src/tempo/{server → legacy/server}/Session.test.ts +162 -63
  368. package/src/tempo/{server → legacy/server}/Session.ts +49 -37
  369. package/src/tempo/legacy/server/index.ts +4 -0
  370. package/src/tempo/{session → legacy/session}/Chain.test.ts +3 -4
  371. package/src/tempo/{session → legacy/session}/Chain.ts +94 -63
  372. package/src/tempo/{session → legacy/session}/Channel.ts +1 -0
  373. package/src/tempo/legacy/session/ChannelStore.test.ts +58 -0
  374. package/src/tempo/legacy/session/ChannelStore.ts +39 -0
  375. package/src/tempo/legacy/session/Types.ts +91 -0
  376. package/src/tempo/{session → legacy/session}/Voucher.ts +12 -8
  377. package/src/tempo/{session → legacy/session}/escrow.abi.ts +1 -0
  378. package/src/tempo/legacy/session/index.ts +8 -0
  379. package/src/tempo/server/AtomicStore.test-d.ts +16 -11
  380. package/src/tempo/server/Charge.test.ts +92 -14
  381. package/src/tempo/server/Charge.ts +18 -16
  382. package/src/tempo/server/Methods.ts +54 -8
  383. package/src/tempo/server/Sse.test.ts +2 -2
  384. package/src/tempo/server/index.ts +6 -5
  385. package/src/tempo/server/internal/html/package.json +1 -1
  386. package/src/tempo/server/internal/html.gen.ts +1 -1
  387. package/src/tempo/server/internal/request-body.test.ts +37 -4
  388. package/src/tempo/server/internal/request-body.ts +25 -6
  389. package/src/tempo/server/internal/transport.test.ts +4 -4
  390. package/src/tempo/server/internal/transport.ts +19 -10
  391. package/src/tempo/session/Snapshot.test.ts +41 -0
  392. package/src/tempo/session/Snapshot.ts +74 -0
  393. package/src/tempo/session/client/ChannelOps.test.ts +163 -0
  394. package/src/tempo/session/client/ChannelOps.ts +344 -0
  395. package/src/tempo/session/client/CredentialState.test.ts +645 -0
  396. package/src/tempo/session/client/CredentialState.ts +814 -0
  397. package/src/tempo/session/client/ReceiptCoordinator.ts +95 -0
  398. package/src/tempo/session/client/Runtime.test.ts +1092 -0
  399. package/src/tempo/session/client/Runtime.ts +986 -0
  400. package/src/tempo/session/client/Session.test.ts +734 -0
  401. package/src/tempo/session/client/Session.ts +97 -0
  402. package/src/tempo/session/client/SessionManager.test.ts +1308 -0
  403. package/src/tempo/session/client/SessionManager.ts +845 -0
  404. package/src/tempo/session/client/Transports.test.ts +837 -0
  405. package/src/tempo/session/client/Transports.ts +1292 -0
  406. package/src/tempo/session/client/index.ts +37 -0
  407. package/src/tempo/session/index.ts +7 -8
  408. package/src/tempo/session/precompile/Chain.integration.test.ts +321 -0
  409. package/src/tempo/session/precompile/Chain.test.ts +1258 -0
  410. package/src/tempo/session/precompile/Chain.ts +979 -0
  411. package/src/tempo/session/precompile/Channel.test.ts +138 -0
  412. package/src/tempo/session/precompile/Channel.ts +103 -0
  413. package/src/tempo/session/precompile/Protocol.test.ts +358 -0
  414. package/src/tempo/session/precompile/Protocol.ts +520 -0
  415. package/src/tempo/session/precompile/Voucher.test.ts +316 -0
  416. package/src/tempo/session/precompile/Voucher.ts +160 -0
  417. package/src/tempo/session/precompile/escrow.abi.ts +226 -0
  418. package/src/tempo/session/precompile/index.ts +33 -0
  419. package/src/tempo/session/server/ChannelOps.test.ts +129 -0
  420. package/src/tempo/session/server/ChannelOps.ts +157 -0
  421. package/src/tempo/session/{ChannelStore.test.ts → server/ChannelStore.test.ts} +536 -29
  422. package/src/tempo/session/server/ChannelStore.ts +835 -0
  423. package/src/tempo/session/server/CredentialVerification.test.ts +146 -0
  424. package/src/tempo/session/server/CredentialVerification.ts +710 -0
  425. package/src/tempo/session/server/MeteredStream.ts +88 -0
  426. package/src/tempo/session/server/RequestState.test.ts +531 -0
  427. package/src/tempo/session/server/RequestState.ts +499 -0
  428. package/src/tempo/session/server/Session.integration.test.ts +444 -0
  429. package/src/tempo/session/server/Session.test.ts +3253 -0
  430. package/src/tempo/session/server/Session.ts +543 -0
  431. package/src/tempo/session/server/Settlement.test.ts +242 -0
  432. package/src/tempo/session/server/Settlement.ts +470 -0
  433. package/src/tempo/session/{Sse.test.ts → server/Sse.test.ts} +37 -3
  434. package/src/tempo/session/server/Sse.ts +256 -0
  435. package/src/tempo/session/server/Transports.test.ts +346 -0
  436. package/src/tempo/session/server/Transports.ts +255 -0
  437. package/src/tempo/session/{Ws.test.ts → server/Ws.test.ts} +4 -4
  438. package/src/tempo/session/server/Ws.ts +384 -0
  439. package/src/tempo/session/server/index.ts +8 -0
  440. package/dist/tempo/client/ChannelOps.d.ts.map +0 -1
  441. package/dist/tempo/client/ChannelOps.js.map +0 -1
  442. package/dist/tempo/client/Session.d.ts.map +0 -1
  443. package/dist/tempo/client/Session.js.map +0 -1
  444. package/dist/tempo/client/SessionManager.d.ts.map +0 -1
  445. package/dist/tempo/client/SessionManager.js.map +0 -1
  446. package/dist/tempo/server/Session.d.ts.map +0 -1
  447. package/dist/tempo/server/Session.js.map +0 -1
  448. package/dist/tempo/session/Chain.d.ts.map +0 -1
  449. package/dist/tempo/session/Chain.js.map +0 -1
  450. package/dist/tempo/session/Channel.d.ts.map +0 -1
  451. package/dist/tempo/session/Channel.js.map +0 -1
  452. package/dist/tempo/session/ChannelStore.d.ts +0 -117
  453. package/dist/tempo/session/ChannelStore.d.ts.map +0 -1
  454. package/dist/tempo/session/ChannelStore.js +0 -172
  455. package/dist/tempo/session/ChannelStore.js.map +0 -1
  456. package/dist/tempo/session/Receipt.d.ts +0 -22
  457. package/dist/tempo/session/Receipt.d.ts.map +0 -1
  458. package/dist/tempo/session/Receipt.js +0 -34
  459. package/dist/tempo/session/Receipt.js.map +0 -1
  460. package/dist/tempo/session/Sse.d.ts.map +0 -1
  461. package/dist/tempo/session/Sse.js +0 -363
  462. package/dist/tempo/session/Sse.js.map +0 -1
  463. package/dist/tempo/session/Types.d.ts +0 -78
  464. package/dist/tempo/session/Types.d.ts.map +0 -1
  465. package/dist/tempo/session/Types.js.map +0 -1
  466. package/dist/tempo/session/Voucher.d.ts.map +0 -1
  467. package/dist/tempo/session/Voucher.js.map +0 -1
  468. package/dist/tempo/session/Ws.d.ts +0 -87
  469. package/dist/tempo/session/Ws.d.ts.map +0 -1
  470. package/dist/tempo/session/Ws.js +0 -443
  471. package/dist/tempo/session/Ws.js.map +0 -1
  472. package/dist/tempo/session/escrow.abi.js.map +0 -1
  473. package/src/tempo/session/ChannelStore.ts +0 -308
  474. package/src/tempo/session/Receipt.test.ts +0 -89
  475. package/src/tempo/session/Receipt.ts +0 -46
  476. package/src/tempo/session/Sse.ts +0 -462
  477. package/src/tempo/session/Types.ts +0 -86
  478. package/src/tempo/session/Ws.ts +0 -576
  479. /package/dist/tempo/{session → legacy/session}/Channel.js +0 -0
  480. /package/dist/tempo/{session → legacy/session}/Types.js +0 -0
  481. /package/src/tempo/{session → legacy/session}/Channel.test.ts +0 -0
  482. /package/src/tempo/{session → legacy/session}/Voucher.test.ts +0 -0
  483. /package/src/tempo/session/{Sse.fuzz.test.ts → server/Sse.fuzz.test.ts} +0 -0
@@ -0,0 +1,3253 @@
1
+ import * as node_http from 'node:http'
2
+
3
+ import { Challenge, Constants, Credential } from 'mppx'
4
+ import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
5
+ import {
6
+ type Address,
7
+ createClient,
8
+ custom,
9
+ defineChain,
10
+ encodeAbiParameters,
11
+ encodeEventTopics,
12
+ encodeFunctionData,
13
+ encodeFunctionResult,
14
+ type Hex,
15
+ zeroAddress,
16
+ } from 'viem'
17
+ import { privateKeyToAccount } from 'viem/accounts'
18
+ import { Transaction } from 'viem/tempo'
19
+ import { describe, expect, test, vi } from 'vp/test'
20
+ import { WebSocket, WebSocketServer } from 'ws'
21
+ import * as Http from '~test/Http.js'
22
+
23
+ import * as NodeRequest from '../../../server/Request.js'
24
+ import * as Store from '../../../Store.js'
25
+ import { charge as clientCharge } from '../../client/Charge.js'
26
+ import * as Methods from '../../Methods.js'
27
+ import * as ClientOps from '../client/ChannelOps.js'
28
+ import { sessionManager as precompileSessionManager } from '../client/SessionManager.js'
29
+ import * as Channel from '../precompile/Channel.js'
30
+ import { escrowAbi } from '../precompile/escrow.abi.js'
31
+ import { tip20ChannelEscrow } from '../precompile/Protocol.js'
32
+ import { deserializeSessionReceipt } from '../precompile/Protocol.js'
33
+ import type { SessionReceipt } from '../precompile/Protocol.js'
34
+ import type { SessionCredentialPayload } from '../precompile/Protocol.js'
35
+ import * as Types from '../precompile/Protocol.js'
36
+ import * as Voucher from '../precompile/Voucher.js'
37
+ import * as ChannelStore from './ChannelStore.js'
38
+ import { charge, session, type ResolveSessionChannelId } from './Session.js'
39
+ import * as TempoWs from './Ws.js'
40
+
41
+ const payer = privateKeyToAccount(
42
+ '0xac0974bec39a17e36ba6a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
43
+ )
44
+ const wrongPayer = privateKeyToAccount(
45
+ '0x59c6995e998f97a5a0044966f094538a009d74290f5811cfba6a6b4d238ff944',
46
+ )
47
+ const chainId = 42431
48
+ const payee = '0x0000000000000000000000000000000000000002' as Address
49
+ const token = '0x0000000000000000000000000000000000000003' as Address
50
+ const wrongTarget = '0x0000000000000000000000000000000000000004' as Address
51
+ const testChain = defineChain({
52
+ id: chainId,
53
+ name: 'Tempo Test',
54
+ nativeCurrency: { name: 'Tempo', symbol: 'TEMPO', decimals: 18 },
55
+ rpcUrls: { default: { http: ['http://localhost'] } },
56
+ })
57
+
58
+ type RpcCall = { method: string; params?: unknown }
59
+ type ChainState = { settled: bigint; deposit: bigint; closeRequestedAt: number }
60
+ type SessionRequest = ReturnType<typeof Methods.session.schema.request.parse>
61
+
62
+ function channelStore(store: Store.Store | Store.AtomicStore): ChannelStore.ChannelStore {
63
+ return ChannelStore.fromStore(store)
64
+ }
65
+
66
+ function createSigningClient(account = payer) {
67
+ return createClient({
68
+ account,
69
+ chain: testChain,
70
+ transport: custom({
71
+ async request(args) {
72
+ if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
73
+ if (args.method === 'eth_getTransactionCount') return '0x0'
74
+ if (args.method === 'eth_estimateGas') return '0x5208'
75
+ if (args.method === 'eth_maxPriorityFeePerGas') return '0x1'
76
+ if (args.method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' }
77
+ throw new Error(`unexpected signing rpc request: ${args.method}`)
78
+ },
79
+ }),
80
+ })
81
+ }
82
+
83
+ function createServerClient(
84
+ calls: RpcCall[] = [],
85
+ account: typeof payer | null = payer,
86
+ _eventChannelId: Hex = `0x${'00'.repeat(32)}` as Hex,
87
+ options: {
88
+ descriptor?: Channel.ChannelDescriptor
89
+ receipt?: Record<string, unknown>
90
+ state?: ChainState
91
+ } = {},
92
+ ) {
93
+ return createClient({
94
+ ...(account ? { account } : {}),
95
+ chain: testChain,
96
+ transport: custom({
97
+ async request(args) {
98
+ calls.push(args)
99
+ if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
100
+ if (args.method === 'eth_getTransactionCount') return '0x0'
101
+ if (args.method === 'eth_estimateGas') return '0x5208'
102
+ if (args.method === 'eth_maxPriorityFeePerGas') return '0x1'
103
+ if (args.method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' }
104
+ if (args.method === 'eth_sendRawTransaction') return `0x${'aa'.repeat(32)}`
105
+ if (args.method === 'eth_getTransactionReceipt') return options.receipt ?? null
106
+ if (args.method === 'eth_sendTransaction') return `0x${'bb'.repeat(32)}`
107
+ if (args.method === 'eth_call') {
108
+ const state = options.state ?? { settled: 100n, deposit: 1_000n, closeRequestedAt: 0 }
109
+ const data = (args.params as [{ data?: Hex }])[0].data
110
+ const getChannelSelector = options.descriptor
111
+ ? encodeFunctionData({
112
+ abi: escrowAbi,
113
+ functionName: 'getChannel',
114
+ args: [options.descriptor],
115
+ }).slice(0, 10)
116
+ : undefined
117
+ if (data && getChannelSelector && data.slice(0, 10) === getChannelSelector)
118
+ return encodeFunctionResult({
119
+ abi: escrowAbi,
120
+ functionName: 'getChannel',
121
+ result: { descriptor: options.descriptor!, state },
122
+ })
123
+ return encodeFunctionResult({
124
+ abi: escrowAbi,
125
+ functionName: 'getChannelState',
126
+ result: state,
127
+ })
128
+ }
129
+ throw new Error(`unexpected rpc request: ${args.method}`)
130
+ },
131
+ }),
132
+ })
133
+ }
134
+
135
+ function createStateClient(
136
+ account: typeof payer | null = payer,
137
+ state: ChainState = {
138
+ settled: 0n,
139
+ deposit: 1_000n,
140
+ closeRequestedAt: 0,
141
+ },
142
+ ) {
143
+ return createClient({
144
+ ...(account ? { account } : {}),
145
+ chain: testChain,
146
+ transport: custom({
147
+ async request(args) {
148
+ if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
149
+ if (args.method === 'eth_call')
150
+ return encodeFunctionResult({
151
+ abi: escrowAbi,
152
+ functionName: 'getChannelState',
153
+ result: state,
154
+ })
155
+ if (args.method === 'eth_getTransactionCount') return '0x0'
156
+ if (args.method === 'eth_estimateGas') return '0x5208'
157
+ if (args.method === 'eth_maxPriorityFeePerGas') return '0x1'
158
+ if (args.method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' }
159
+ throw new Error(`unexpected rpc request: ${args.method}`)
160
+ },
161
+ }),
162
+ })
163
+ }
164
+
165
+ function createServer(parameters: Partial<session.Parameters> = {}) {
166
+ const rawStore = Store.memory()
167
+ const rpcCalls: RpcCall[] = []
168
+ const serverClient = createServerClient(rpcCalls)
169
+ const method = session({
170
+ amount: '1',
171
+ chainId,
172
+ currency: token,
173
+ decimals: 0,
174
+ recipient: payee,
175
+ store: rawStore,
176
+ unitType: 'request',
177
+ getClient: () => serverClient,
178
+ ...parameters,
179
+ })
180
+ return { method, store: channelStore(rawStore), rpcCalls }
181
+ }
182
+
183
+ function makeChallenge(
184
+ channelId?: Hex,
185
+ options: { operator?: Address | undefined } = {},
186
+ ): Challenge.Challenge<SessionRequest, 'session', 'tempo'> {
187
+ return {
188
+ id: 'challenge-id',
189
+ realm: 'api.example.com',
190
+ method: 'tempo',
191
+ intent: 'session',
192
+ request: makeRequest(channelId, options),
193
+ } as Challenge.Challenge<SessionRequest, 'session', 'tempo'>
194
+ }
195
+
196
+ function makeRequest(
197
+ channelId?: Hex,
198
+ options: { operator?: Address | undefined } = {},
199
+ ): SessionRequest {
200
+ return {
201
+ amount: '100',
202
+ currency: token,
203
+ recipient: payee,
204
+ unitType: 'request',
205
+ methodDetails: {
206
+ chainId,
207
+ escrowContract: tip20ChannelEscrow,
208
+ ...(channelId && { channelId }),
209
+ ...(options.operator ? { operator: options.operator } : {}),
210
+ },
211
+ }
212
+ }
213
+
214
+ type VerifyRequest = Parameters<NonNullable<ReturnType<typeof session>['verify']>>[0]['request']
215
+
216
+ function verifyRequest(
217
+ channelId?: Hex,
218
+ options: { operator?: Address | undefined } = {},
219
+ ): VerifyRequest {
220
+ // `verify()` receives the HMAC-bound canonical challenge request. The public
221
+ // request schema input still includes parse-only fields like `decimals`, so
222
+ // keep this test bridge in one place instead of casting every verification.
223
+ return makeRequest(channelId, options) as unknown as VerifyRequest
224
+ }
225
+
226
+ function verifyRequestWithFeePayer(channelId: Hex, feePayer: typeof payer): VerifyRequest {
227
+ const request = makeRequest(channelId)
228
+ return {
229
+ ...request,
230
+ feePayer,
231
+ methodDetails: {
232
+ ...request.methodDetails,
233
+ feePayer: true,
234
+ },
235
+ } as unknown as VerifyRequest
236
+ }
237
+
238
+ let saltCounter = 0
239
+
240
+ async function createOpenPayload(
241
+ parameters: {
242
+ deposit?: bigint | undefined
243
+ initialAmount?: bigint | undefined
244
+ escrow?: Address | undefined
245
+ account?: typeof payer | undefined
246
+ operator?: Address | undefined
247
+ authorizedSigner?: Address | undefined
248
+ } = {},
249
+ ): Promise<Extract<SessionCredentialPayload, { action: 'open' }>> {
250
+ const account = parameters.account ?? payer
251
+ const escrow = parameters.escrow ?? tip20ChannelEscrow
252
+ const initialAmount = Types.uint96(parameters.initialAmount ?? 100n)
253
+ const deposit = Types.uint96(parameters.deposit ?? 1_000n)
254
+ const salt = `0x${(++saltCounter).toString(16).padStart(64, '0')}` as Hex
255
+ const operator = parameters.operator ?? zeroAddress
256
+ const authorizedSigner = parameters.authorizedSigner ?? account.address
257
+ const data = encodeFunctionData({
258
+ abi: escrowAbi,
259
+ functionName: 'open',
260
+ args: [payee, operator, token, deposit, salt, authorizedSigner],
261
+ })
262
+ const signingClient = createSigningClient(account)
263
+ const transaction = (await Transaction.serialize({
264
+ chainId,
265
+ calls: [{ to: escrow, data }],
266
+ feeToken: token,
267
+ nonce: 0,
268
+ })) as Hex
269
+ const expiringNonceHash = Channel.computeExpiringNonceHash(
270
+ Transaction.deserialize(
271
+ transaction as Transaction.TransactionSerializedTempo,
272
+ ) as Channel.ExpiringNonceTransaction,
273
+ { sender: account.address },
274
+ )
275
+ const descriptor = {
276
+ payer: account.address,
277
+ payee,
278
+ operator,
279
+ token,
280
+ salt,
281
+ authorizedSigner,
282
+ expiringNonceHash,
283
+ } satisfies Channel.ChannelDescriptor
284
+ const channelId = Channel.computeId({ ...descriptor, chainId, escrow })
285
+ const signature = await Voucher.signVoucher(
286
+ signingClient,
287
+ account,
288
+ { channelId, cumulativeAmount: initialAmount },
289
+ escrow,
290
+ chainId,
291
+ )
292
+ return {
293
+ action: 'open',
294
+ type: 'transaction',
295
+ channelId,
296
+ transaction,
297
+ signature,
298
+ descriptor,
299
+ cumulativeAmount: initialAmount.toString(),
300
+ authorizedSigner: descriptor.authorizedSigner,
301
+ }
302
+ }
303
+
304
+ function transactionReceipt(logs: readonly Record<string, unknown>[]) {
305
+ return {
306
+ blockHash: `0x${'01'.repeat(32)}`,
307
+ blockNumber: '0x1',
308
+ contractAddress: null,
309
+ cumulativeGasUsed: '0x1',
310
+ effectiveGasPrice: '0x1',
311
+ from: payer.address,
312
+ gasUsed: '0x1',
313
+ logs,
314
+ logsBloom: `0x${'00'.repeat(256)}`,
315
+ status: '0x1',
316
+ to: tip20ChannelEscrow,
317
+ transactionHash: `0x${'aa'.repeat(32)}`,
318
+ transactionIndex: '0x0',
319
+ type: '0x76',
320
+ }
321
+ }
322
+
323
+ function openedLog(
324
+ payload: Extract<SessionCredentialPayload, { action: 'open' }>,
325
+ deposit = 1_000n,
326
+ ) {
327
+ return {
328
+ address: tip20ChannelEscrow,
329
+ data: encodeAbiParameters(
330
+ [
331
+ { type: 'address' },
332
+ { type: 'address' },
333
+ { type: 'address' },
334
+ { type: 'bytes32' },
335
+ { type: 'bytes32' },
336
+ { type: 'uint96' },
337
+ ],
338
+ [
339
+ payload.descriptor.operator,
340
+ payload.descriptor.token,
341
+ payload.descriptor.authorizedSigner,
342
+ payload.descriptor.salt,
343
+ payload.descriptor.expiringNonceHash,
344
+ deposit,
345
+ ],
346
+ ),
347
+ topics: encodeEventTopics({
348
+ abi: escrowAbi,
349
+ eventName: 'ChannelOpened',
350
+ args: {
351
+ channelId: payload.channelId,
352
+ payer: payload.descriptor.payer,
353
+ payee: payload.descriptor.payee,
354
+ },
355
+ }),
356
+ }
357
+ }
358
+
359
+ function settledLog(channelId: Hex, newSettled: bigint) {
360
+ return {
361
+ address: tip20ChannelEscrow,
362
+ data: encodeAbiParameters(
363
+ [{ type: 'uint96' }, { type: 'uint96' }, { type: 'uint96' }],
364
+ [newSettled, newSettled, newSettled],
365
+ ),
366
+ topics: encodeEventTopics({
367
+ abi: escrowAbi,
368
+ eventName: 'Settled',
369
+ args: { channelId, payer: payer.address, payee: payer.address },
370
+ }),
371
+ }
372
+ }
373
+
374
+ function closedLog(channelId: Hex, settledToPayee: bigint, refundedToPayer: bigint) {
375
+ return {
376
+ address: tip20ChannelEscrow,
377
+ data: encodeAbiParameters(
378
+ [{ type: 'uint96' }, { type: 'uint96' }],
379
+ [settledToPayee, refundedToPayer],
380
+ ),
381
+ topics: encodeEventTopics({
382
+ abi: escrowAbi,
383
+ eventName: 'ChannelClosed',
384
+ args: { channelId, payer: payer.address, payee: payer.address },
385
+ }),
386
+ }
387
+ }
388
+
389
+ function topUpLog(
390
+ payload: Extract<SessionCredentialPayload, { action: 'topUp' }>,
391
+ newDeposit: bigint,
392
+ ) {
393
+ return {
394
+ address: tip20ChannelEscrow,
395
+ data: encodeAbiParameters([{ type: 'uint96' }, { type: 'uint96' }], [1_000n, newDeposit]),
396
+ topics: encodeEventTopics({
397
+ abi: escrowAbi,
398
+ eventName: 'TopUp',
399
+ args: { channelId: payload.channelId, payer: payer.address, payee },
400
+ }),
401
+ }
402
+ }
403
+
404
+ async function createTopUpPayload(
405
+ descriptor: Channel.ChannelDescriptor,
406
+ additionalDeposit = 500n,
407
+ ): Promise<Extract<SessionCredentialPayload, { action: 'topUp' }>> {
408
+ const data = encodeFunctionData({
409
+ abi: escrowAbi,
410
+ functionName: 'topUp',
411
+ args: [descriptor, Types.uint96(additionalDeposit)],
412
+ })
413
+ const transaction = (await Transaction.serialize({
414
+ chainId,
415
+ calls: [{ to: tip20ChannelEscrow, data }],
416
+ feeToken: token,
417
+ nonce: 0,
418
+ })) as Hex
419
+ const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow })
420
+ return {
421
+ action: 'topUp',
422
+ type: 'transaction',
423
+ channelId,
424
+ descriptor,
425
+ transaction,
426
+ additionalDeposit: additionalDeposit.toString(),
427
+ }
428
+ }
429
+
430
+ async function persistPrecompileChannel(
431
+ store: ChannelStore.ChannelStore,
432
+ payload: Extract<SessionCredentialPayload, { action: 'open' }>,
433
+ overrides: Partial<ChannelStore.State> = {},
434
+ ) {
435
+ await store.updateChannel(payload.channelId, () => ({
436
+ backend: 'precompile',
437
+ channelId: payload.channelId,
438
+ chainId,
439
+ escrowContract: tip20ChannelEscrow,
440
+ closeRequestedAt: 0n,
441
+ payer: payload.descriptor.payer,
442
+ payee,
443
+ token,
444
+ authorizedSigner: payload.descriptor.authorizedSigner,
445
+ deposit: 1_000n,
446
+ settledOnChain: 0n,
447
+ highestVoucherAmount: BigInt(payload.cumulativeAmount),
448
+ highestVoucher: {
449
+ channelId: payload.channelId,
450
+ cumulativeAmount: BigInt(payload.cumulativeAmount),
451
+ signature: payload.signature,
452
+ },
453
+ spent: 0n,
454
+ units: 0,
455
+ finalized: false,
456
+ createdAt: new Date(0).toISOString(),
457
+ descriptor: payload.descriptor,
458
+ operator: payload.descriptor.operator,
459
+ salt: payload.descriptor.salt,
460
+ expiringNonceHash: payload.descriptor.expiringNonceHash,
461
+ ...overrides,
462
+ }))
463
+ }
464
+
465
+ describe('precompile server session unit guardrails', () => {
466
+ test('request marks TIP-1034 session challenges', async () => {
467
+ const { method } = createServer()
468
+
469
+ const challengeRequest = await method.request!({
470
+ credential: null,
471
+ request: {
472
+ amount: '1',
473
+ currency: token,
474
+ decimals: 0,
475
+ recipient: payee,
476
+ unitType: 'request',
477
+ },
478
+ } as never)
479
+
480
+ expect(challengeRequest.sessionProtocol).toBe(Constants.SessionProtocols.v2)
481
+ })
482
+
483
+ test('request normalizes fee-payer to boolean for challenge issuance and account for verification', async () => {
484
+ const { method } = createServer({ feePayer: wrongPayer })
485
+
486
+ const challengeRequest = await method.request!({
487
+ credential: null,
488
+ request: {
489
+ amount: '1',
490
+ currency: token,
491
+ decimals: 0,
492
+ recipient: payee,
493
+ unitType: 'request',
494
+ },
495
+ } as never)
496
+ expect(challengeRequest.feePayer).toBe(true)
497
+
498
+ const verificationRequest = await method.request!({
499
+ credential: { challenge: {}, payload: {} } as never,
500
+ request: {
501
+ amount: '1',
502
+ currency: token,
503
+ decimals: 0,
504
+ feePayer: payer,
505
+ recipient: payee,
506
+ unitType: 'request',
507
+ },
508
+ } as never)
509
+ expect(verificationRequest.feePayer).toBe(payer)
510
+ })
511
+
512
+ test('request allows callers to explicitly disable precompile fee-payer', async () => {
513
+ const { method } = createServer({ feePayer: wrongPayer })
514
+
515
+ const challengeRequest = await method.request!({
516
+ credential: null,
517
+ request: {
518
+ amount: '1',
519
+ currency: token,
520
+ decimals: 0,
521
+ feePayer: false,
522
+ recipient: payee,
523
+ unitType: 'request',
524
+ },
525
+ } as never)
526
+ expect(challengeRequest.feePayer).toBeUndefined()
527
+
528
+ const verificationRequest = await method.request!({
529
+ credential: { challenge: {}, payload: {} } as never,
530
+ request: {
531
+ amount: '1',
532
+ currency: token,
533
+ decimals: 0,
534
+ feePayer: false,
535
+ recipient: payee,
536
+ unitType: 'request',
537
+ },
538
+ } as never)
539
+ expect(verificationRequest.feePayer).toBe(false)
540
+ })
541
+
542
+ test('request returns a server session snapshot for a known precompile channel', async () => {
543
+ const { method, store } = createServer()
544
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
545
+ await persistPrecompileChannel(store, openPayload, {
546
+ highestVoucherAmount: 250n,
547
+ spent: 200n,
548
+ units: 2,
549
+ })
550
+
551
+ const request = await method.request!({
552
+ credential: {
553
+ challenge: {},
554
+ payload: {},
555
+ } as never,
556
+ request: {
557
+ amount: '1',
558
+ channelId: openPayload.channelId,
559
+ currency: token,
560
+ decimals: 0,
561
+ recipient: payee,
562
+ unitType: 'request',
563
+ },
564
+ } as never)
565
+
566
+ expect(request.sessionSnapshot).toEqual({
567
+ acceptedCumulative: '250',
568
+ chainId,
569
+ channelId: openPayload.channelId,
570
+ closeRequestedAt: undefined,
571
+ deposit: '1000',
572
+ descriptor: openPayload.descriptor,
573
+ escrow: tip20ChannelEscrow,
574
+ requiredCumulative: '201',
575
+ settled: '0',
576
+ spent: '200',
577
+ units: 2,
578
+ })
579
+ })
580
+
581
+ test('request can resolve a server session snapshot from request identity', async () => {
582
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
583
+ const { method, store } = createServer({
584
+ resolveChannelId({ request, credential, paymentRequest, store: hookStore }) {
585
+ expect(request?.headers.get('cookie')).toBe('sid=session-1')
586
+ expect(credential).toBeNull()
587
+ expect(paymentRequest.unitType).toBe('request')
588
+ expect(hookStore).toBe(store)
589
+ return openPayload.channelId
590
+ },
591
+ })
592
+ await persistPrecompileChannel(store, openPayload, {
593
+ highestVoucherAmount: 250n,
594
+ spent: 200n,
595
+ units: 2,
596
+ })
597
+
598
+ const request = await method.request!({
599
+ capturedRequest: {
600
+ hasBody: false,
601
+ headers: new Headers({ cookie: 'sid=session-1' }),
602
+ method: 'GET',
603
+ url: new URL('https://api.example.com/resource'),
604
+ },
605
+ credential: null,
606
+ request: {
607
+ amount: '1',
608
+ currency: token,
609
+ decimals: 0,
610
+ recipient: payee,
611
+ unitType: 'request',
612
+ },
613
+ } as never)
614
+
615
+ expect(request.sessionSnapshot).toMatchObject({
616
+ channelId: openPayload.channelId,
617
+ descriptor: openPayload.descriptor,
618
+ requiredCumulative: '201',
619
+ spent: '200',
620
+ })
621
+ })
622
+
623
+ test('request prefers explicit channel ID over custom channel resolver', async () => {
624
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
625
+ const { method, store } = createServer({
626
+ resolveChannelId() {
627
+ throw new Error('unexpected resolver call')
628
+ },
629
+ })
630
+ await persistPrecompileChannel(store, openPayload)
631
+
632
+ const request = await method.request!({
633
+ credential: null,
634
+ request: {
635
+ amount: '1',
636
+ channelId: openPayload.channelId,
637
+ currency: token,
638
+ decimals: 0,
639
+ recipient: payee,
640
+ unitType: 'request',
641
+ },
642
+ } as never)
643
+
644
+ expect(request.sessionSnapshot?.channelId).toBe(openPayload.channelId)
645
+ })
646
+
647
+ test('request uses zero effective amount for non-content snapshot hints', async () => {
648
+ const { method, store } = createServer()
649
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
650
+ await persistPrecompileChannel(store, openPayload, {
651
+ highestVoucherAmount: 250n,
652
+ spent: 250n,
653
+ units: 2,
654
+ })
655
+
656
+ const request = await method.request!({
657
+ capturedRequest: {
658
+ hasBody: false,
659
+ headers: new Headers(),
660
+ method: 'HEAD',
661
+ url: new URL('https://api.example.com/resource'),
662
+ },
663
+ credential: {
664
+ challenge: {},
665
+ payload: {},
666
+ } as never,
667
+ request: {
668
+ amount: '1',
669
+ channelId: openPayload.channelId,
670
+ currency: token,
671
+ decimals: 0,
672
+ recipient: payee,
673
+ unitType: 'request',
674
+ },
675
+ } as never)
676
+
677
+ expect(request.sessionSnapshot).toMatchObject({
678
+ acceptedCumulative: '250',
679
+ requiredCumulative: '250',
680
+ spent: '250',
681
+ })
682
+ })
683
+
684
+ test('request throws when resolved precompile client chain mismatches requested chain', async () => {
685
+ const { method } = createServer({ chainId: 1 })
686
+
687
+ await expect(
688
+ method.request!({
689
+ credential: null,
690
+ request: {
691
+ amount: '1',
692
+ chainId: 1,
693
+ currency: token,
694
+ decimals: 0,
695
+ recipient: payee,
696
+ unitType: 'request',
697
+ },
698
+ } as never),
699
+ ).rejects.toThrow('Client not configured with chainId 1.')
700
+ })
701
+
702
+ test('rejects open transactions targeting the wrong address', async () => {
703
+ const { method } = createServer()
704
+ const payload = await createOpenPayload({ escrow: wrongTarget })
705
+
706
+ await expect(
707
+ method.verify({
708
+ credential: { challenge: makeChallenge(payload.channelId), payload },
709
+ request: verifyRequest(payload.channelId),
710
+ }),
711
+ ).rejects.toThrow(/descriptor does not match channelId|wrong address/)
712
+ })
713
+
714
+ test('rejects smuggled extra calls in open transactions', async () => {
715
+ const { method } = createServer()
716
+ const payload = await createOpenPayload()
717
+ const tampered = await createOpenPayload()
718
+
719
+ // Reuse a valid descriptor/signature, but submit a transaction whose calls
720
+ // do not correspond to that descriptor. This exercises the same one-call /
721
+ // smuggling guard as legacy server session tests without requiring a live
722
+ // chain-backed precompile.
723
+ const smuggled = { ...payload, transaction: tampered.transaction }
724
+
725
+ await expect(
726
+ method.verify({
727
+ credential: {
728
+ challenge: makeChallenge(payload.channelId),
729
+ payload: smuggled,
730
+ },
731
+ request: verifyRequest(payload.channelId),
732
+ }),
733
+ ).rejects.toThrow(/does not match/)
734
+ })
735
+
736
+ test('rejects descriptors that do not match the challenge channel ID', async () => {
737
+ const { method } = createServer()
738
+ const payload = await createOpenPayload()
739
+ const badDescriptor = {
740
+ ...payload.descriptor,
741
+ token: '0x0000000000000000000000000000000000000005' as Address,
742
+ }
743
+
744
+ await expect(
745
+ method.verify({
746
+ credential: {
747
+ challenge: makeChallenge(payload.channelId),
748
+ payload: { ...payload, descriptor: badDescriptor },
749
+ },
750
+ request: verifyRequest(payload.channelId),
751
+ }),
752
+ ).rejects.toThrow(/descriptor does not match channelId/)
753
+ })
754
+
755
+ test('rejects invalid initial voucher signatures', async () => {
756
+ const { method } = createServer()
757
+ const payload = await createOpenPayload()
758
+ const badSignaturePayload = {
759
+ ...payload,
760
+ signature: (await createOpenPayload({ account: wrongPayer })).signature,
761
+ }
762
+
763
+ await expect(
764
+ method.verify({
765
+ credential: {
766
+ challenge: makeChallenge(payload.channelId),
767
+ payload: badSignaturePayload,
768
+ },
769
+ request: verifyRequest(payload.channelId),
770
+ }),
771
+ ).rejects.toThrow(/invalid voucher signature/)
772
+ })
773
+
774
+ test('rejects missing precompile descriptors with a verification error', async () => {
775
+ const { method } = createServer()
776
+ const payload = await createOpenPayload()
777
+ const { descriptor: _descriptor, ...payloadWithoutDescriptor } = payload
778
+
779
+ await expect(
780
+ method.verify({
781
+ credential: {
782
+ challenge: makeChallenge(payload.channelId),
783
+ payload: payloadWithoutDescriptor,
784
+ },
785
+ request: verifyRequest(payload.channelId),
786
+ }),
787
+ ).rejects.toThrow(/descriptor required for TIP-1034 session action/)
788
+ })
789
+
790
+ test('rejects uint96 overflow in credential amount parsing', async () => {
791
+ const { method } = createServer()
792
+ const payload = await createOpenPayload()
793
+
794
+ await expect(
795
+ method.verify({
796
+ credential: {
797
+ challenge: makeChallenge(payload.channelId),
798
+ payload: { ...payload, cumulativeAmount: (1n << 96n).toString() },
799
+ },
800
+ request: verifyRequest(payload.channelId),
801
+ }),
802
+ ).rejects.toThrow(/outside uint96 bounds/)
803
+ })
804
+
805
+ test('rejects settle when no account is available', async () => {
806
+ const { store } = createServer()
807
+ const openPayload = await createOpenPayload()
808
+ await persistPrecompileChannel(store, openPayload)
809
+
810
+ const { settle } = await import('./Session.js')
811
+ await expect(
812
+ settle(store, createServerClient([], null), openPayload.channelId),
813
+ ).rejects.toThrow(/no account available/)
814
+ })
815
+
816
+ test('rejects settle when sender is not the channel payee or operator', async () => {
817
+ const { store } = createServer()
818
+ const openPayload = await createOpenPayload()
819
+ await persistPrecompileChannel(store, openPayload)
820
+
821
+ const { settle } = await import('./Session.js')
822
+ await expect(
823
+ settle(store, createServerClient([], wrongPayer), openPayload.channelId),
824
+ ).rejects.toThrow(/tx sender .* is not the channel payee/)
825
+ })
826
+
827
+ test('accepts settle sender matching a nonzero precompile operator', async () => {
828
+ const { store } = createServer()
829
+ const openPayload = await createOpenPayload({
830
+ operator: wrongPayer.address,
831
+ })
832
+ await persistPrecompileChannel(store, openPayload)
833
+
834
+ const { settle } = await import('./Session.js')
835
+ const client = createClient({
836
+ account: wrongPayer,
837
+ chain: testChain,
838
+ transport: custom({
839
+ async request(args) {
840
+ if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
841
+ throw new Error(`unexpected rpc request: ${args.method}`)
842
+ },
843
+ }),
844
+ })
845
+ await expect(settle(store, client, openPayload.channelId)).rejects.toThrow(
846
+ /eth_getTransactionCount/,
847
+ )
848
+ })
849
+
850
+ test('precompile settle fee payer options still enforce payee sender policy', async () => {
851
+ const { store } = createServer()
852
+ const openPayload = await createOpenPayload()
853
+ await persistPrecompileChannel(store, openPayload)
854
+
855
+ const { settle } = await import('./Session.js')
856
+ await expect(
857
+ settle(store, createServerClient([], payer), openPayload.channelId, {
858
+ feePayer: wrongPayer,
859
+ }),
860
+ ).rejects.toThrow(/tx sender .* is not the channel payee/)
861
+ })
862
+
863
+ test('accepts precompile settle fee token options', async () => {
864
+ const { store } = createServer()
865
+ const openPayload = await createOpenPayload()
866
+ await persistPrecompileChannel(store, openPayload, {
867
+ payee: payer.address,
868
+ })
869
+ const client = createClient({
870
+ account: payer,
871
+ chain: testChain,
872
+ transport: custom({
873
+ async request(args) {
874
+ if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
875
+ if (args.method === 'eth_sendTransaction') throw new Error('sent fee-token settle')
876
+ throw new Error(`unexpected rpc request: ${args.method}`)
877
+ },
878
+ }),
879
+ })
880
+
881
+ const { settle } = await import('./Session.js')
882
+ await expect(
883
+ settle(store, client, openPayload.channelId, {
884
+ feeToken: token,
885
+ }),
886
+ ).rejects.toThrow(/eth_getTransactionCount/)
887
+ })
888
+
889
+ test('accepts settle account override matching the channel payee', async () => {
890
+ const { store } = createServer()
891
+ const openPayload = await createOpenPayload()
892
+ await persistPrecompileChannel(store, openPayload, {
893
+ payee: wrongPayer.address,
894
+ })
895
+ const client = createClient({
896
+ account: payer,
897
+ chain: testChain,
898
+ transport: custom({
899
+ async request(args) {
900
+ if (args.method === 'eth_sendTransaction') throw new Error('sent settle transaction')
901
+ if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
902
+ throw new Error(`unexpected rpc request: ${args.method}`)
903
+ },
904
+ }),
905
+ })
906
+
907
+ const { settle } = await import('./Session.js')
908
+ await expect(
909
+ settle(store, client, openPayload.channelId, {
910
+ account: wrongPayer,
911
+ }),
912
+ ).rejects.toThrow(/eth_getTransactionCount/)
913
+ })
914
+
915
+ test('rejects precompile settle fee-payer policy violations', async () => {
916
+ const { store } = createServer()
917
+ const openPayload = await createOpenPayload()
918
+ await persistPrecompileChannel(store, openPayload, {
919
+ payee: payer.address,
920
+ })
921
+
922
+ const { settle } = await import('./Session.js')
923
+ await expect(
924
+ settle(store, createServerClient([], payer), openPayload.channelId, {
925
+ feePayer: wrongPayer,
926
+ feePayerPolicy: { maxGas: 1n },
927
+ feeToken: token,
928
+ }),
929
+ ).rejects.toThrow(/fee-payer policy maxGas exceeded/)
930
+ })
931
+
932
+ test('rejects close voucher below local spent', async () => {
933
+ const rawStore = Store.memory()
934
+ const store = channelStore(rawStore)
935
+ const openPayload = await createOpenPayload()
936
+ await persistPrecompileChannel(store, openPayload, {
937
+ payee: payer.address,
938
+ spent: 150n,
939
+ })
940
+ const method = session({
941
+ account: payer,
942
+ amount: '1',
943
+ chainId,
944
+ currency: token,
945
+ decimals: 0,
946
+ recipient: payee,
947
+ store: rawStore,
948
+ unitType: 'request',
949
+ getClient: () => createStateClient(payer),
950
+ })
951
+ const payload = await ClientOps.createClosePayload(
952
+ createSigningClient(),
953
+ payer,
954
+ openPayload.descriptor,
955
+ Types.uint96(100n),
956
+ chainId,
957
+ )
958
+
959
+ await expect(
960
+ method.verify({
961
+ credential: {
962
+ challenge: makeChallenge(openPayload.channelId),
963
+ payload,
964
+ },
965
+ request: verifyRequest(openPayload.channelId),
966
+ }),
967
+ ).rejects.toThrow(/close voucher amount must be >= 150 \(spent\)/)
968
+ })
969
+
970
+ test('rejects close voucher below on-chain settled', async () => {
971
+ const rawStore = Store.memory()
972
+ const store = channelStore(rawStore)
973
+ const openPayload = await createOpenPayload()
974
+ await persistPrecompileChannel(store, openPayload, {
975
+ payee: payer.address,
976
+ })
977
+ const method = session({
978
+ account: payer,
979
+ amount: '1',
980
+ chainId,
981
+ currency: token,
982
+ decimals: 0,
983
+ recipient: payee,
984
+ store: rawStore,
985
+ unitType: 'request',
986
+ getClient: () =>
987
+ createStateClient(payer, {
988
+ settled: 100n,
989
+ deposit: 1_000n,
990
+ closeRequestedAt: 0,
991
+ }),
992
+ })
993
+ const payload = await ClientOps.createClosePayload(
994
+ createSigningClient(),
995
+ payer,
996
+ openPayload.descriptor,
997
+ Types.uint96(99n),
998
+ chainId,
999
+ )
1000
+
1001
+ await expect(
1002
+ method.verify({
1003
+ credential: {
1004
+ challenge: makeChallenge(openPayload.channelId),
1005
+ payload,
1006
+ },
1007
+ request: verifyRequest(openPayload.channelId),
1008
+ }),
1009
+ ).rejects.toThrow(/close voucher amount must be >= 100 \(on-chain settled\)/)
1010
+ })
1011
+
1012
+ test('rejects close capture exceeding on-chain precompile deposit', async () => {
1013
+ const rawStore = Store.memory()
1014
+ const store = channelStore(rawStore)
1015
+ const openPayload = await createOpenPayload()
1016
+ await persistPrecompileChannel(store, openPayload, {
1017
+ payee: payer.address,
1018
+ spent: 100n,
1019
+ })
1020
+ const method = session({
1021
+ account: payer,
1022
+ amount: '1',
1023
+ chainId,
1024
+ currency: token,
1025
+ decimals: 0,
1026
+ recipient: payee,
1027
+ store: rawStore,
1028
+ unitType: 'request',
1029
+ getClient: () =>
1030
+ createStateClient(payer, {
1031
+ settled: 0n,
1032
+ deposit: 99n,
1033
+ closeRequestedAt: 0,
1034
+ }),
1035
+ })
1036
+ const payload = await ClientOps.createClosePayload(
1037
+ createSigningClient(),
1038
+ payer,
1039
+ openPayload.descriptor,
1040
+ Types.uint96(100n),
1041
+ chainId,
1042
+ )
1043
+
1044
+ await expect(
1045
+ method.verify({
1046
+ credential: {
1047
+ challenge: makeChallenge(openPayload.channelId),
1048
+ payload,
1049
+ },
1050
+ request: verifyRequest(openPayload.channelId),
1051
+ }),
1052
+ ).rejects.toThrow(/close capture amount exceeds on-chain deposit/)
1053
+ })
1054
+
1055
+ test('rejects close for locally finalized and pending precompile channels', async () => {
1056
+ const rawStore = Store.memory()
1057
+ const store = channelStore(rawStore)
1058
+ const openPayload = await createOpenPayload()
1059
+ const payload = await ClientOps.createClosePayload(
1060
+ createSigningClient(),
1061
+ payer,
1062
+ openPayload.descriptor,
1063
+ Types.uint96(100n),
1064
+ chainId,
1065
+ )
1066
+ const method = session({
1067
+ account: payer,
1068
+ amount: '1',
1069
+ chainId,
1070
+ currency: token,
1071
+ decimals: 0,
1072
+ recipient: payee,
1073
+ store: rawStore,
1074
+ unitType: 'request',
1075
+ getClient: () => createStateClient(payer),
1076
+ })
1077
+
1078
+ await persistPrecompileChannel(store, openPayload, {
1079
+ finalized: true,
1080
+ payee: payer.address,
1081
+ })
1082
+ await expect(
1083
+ method.verify({
1084
+ credential: {
1085
+ challenge: makeChallenge(openPayload.channelId),
1086
+ payload,
1087
+ },
1088
+ request: verifyRequest(openPayload.channelId),
1089
+ }),
1090
+ ).rejects.toThrow(/channel is already finalized/)
1091
+
1092
+ await persistPrecompileChannel(store, openPayload, {
1093
+ closeRequestedAt: 1n,
1094
+ payee: payer.address,
1095
+ })
1096
+ await expect(
1097
+ method.verify({
1098
+ credential: {
1099
+ challenge: makeChallenge(openPayload.channelId),
1100
+ payload,
1101
+ },
1102
+ request: verifyRequest(openPayload.channelId),
1103
+ }),
1104
+ ).rejects.toThrow(/channel has a pending close request/)
1105
+ })
1106
+
1107
+ test('accepts valid precompile open with voucher and stores state', async () => {
1108
+ const rawStore = Store.memory()
1109
+ const store = channelStore(rawStore)
1110
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
1111
+ const method = session({
1112
+ amount: '1',
1113
+ chainId,
1114
+ currency: token,
1115
+ decimals: 0,
1116
+ recipient: payee,
1117
+ store: rawStore,
1118
+ unitType: 'request',
1119
+ getClient: () =>
1120
+ createServerClient([], payer, openPayload.channelId, {
1121
+ descriptor: openPayload.descriptor,
1122
+ receipt: transactionReceipt([openedLog(openPayload)]),
1123
+ state: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 },
1124
+ }),
1125
+ })
1126
+
1127
+ const receipt = (await method.verify({
1128
+ credential: {
1129
+ challenge: makeChallenge(openPayload.channelId),
1130
+ payload: openPayload,
1131
+ },
1132
+ request: verifyRequest(openPayload.channelId),
1133
+ })) as SessionReceipt
1134
+
1135
+ const stored = await store.getChannel(openPayload.channelId)
1136
+ expect(receipt.acceptedCumulative).toBe('100')
1137
+ expect(stored?.backend).toBe('precompile')
1138
+ expect(stored?.deposit).toBe(1_000n)
1139
+ expect(stored?.highestVoucherAmount).toBe(100n)
1140
+ if (!stored || !ChannelStore.isPrecompileState(stored))
1141
+ throw new Error('expected precompile state')
1142
+ expect(stored.descriptor).toEqual(openPayload.descriptor)
1143
+ })
1144
+
1145
+ test('reopening existing precompile channel with higher voucher updates highest voucher only', async () => {
1146
+ const rawStore = Store.memory()
1147
+ const store = channelStore(rawStore)
1148
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
1149
+ await persistPrecompileChannel(store, openPayload, {
1150
+ highestVoucherAmount: 100n,
1151
+ spent: 75n,
1152
+ units: 3,
1153
+ })
1154
+ const reopenPayload = { ...openPayload, cumulativeAmount: '250' }
1155
+ reopenPayload.signature = await Voucher.signVoucher(
1156
+ createSigningClient(),
1157
+ payer,
1158
+ { channelId: openPayload.channelId, cumulativeAmount: 250n },
1159
+ tip20ChannelEscrow,
1160
+ chainId,
1161
+ )
1162
+ const method = session({
1163
+ amount: '1',
1164
+ chainId,
1165
+ currency: token,
1166
+ decimals: 0,
1167
+ recipient: payee,
1168
+ store: rawStore,
1169
+ unitType: 'request',
1170
+ getClient: () =>
1171
+ createServerClient([], payer, openPayload.channelId, {
1172
+ descriptor: openPayload.descriptor,
1173
+ receipt: transactionReceipt([openedLog(openPayload)]),
1174
+ state: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 },
1175
+ }),
1176
+ })
1177
+
1178
+ const receipt = (await method.verify({
1179
+ credential: {
1180
+ challenge: makeChallenge(openPayload.channelId),
1181
+ payload: reopenPayload,
1182
+ },
1183
+ request: verifyRequest(openPayload.channelId),
1184
+ })) as SessionReceipt
1185
+
1186
+ const stored = await store.getChannel(openPayload.channelId)
1187
+ expect(receipt.acceptedCumulative).toBe('250')
1188
+ expect(receipt.spent).toBe('75')
1189
+ expect(receipt.units).toBe(3)
1190
+ expect(stored?.highestVoucherAmount).toBe(250n)
1191
+ expect(stored?.spent).toBe(75n)
1192
+ expect(stored?.units).toBe(3)
1193
+ })
1194
+
1195
+ test('reopening existing precompile channel with same voucher preserves accounting', async () => {
1196
+ const rawStore = Store.memory()
1197
+ const store = channelStore(rawStore)
1198
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
1199
+ await persistPrecompileChannel(store, openPayload, {
1200
+ highestVoucherAmount: 100n,
1201
+ spent: 75n,
1202
+ units: 3,
1203
+ })
1204
+ const method = session({
1205
+ amount: '1',
1206
+ chainId,
1207
+ currency: token,
1208
+ decimals: 0,
1209
+ recipient: payee,
1210
+ store: rawStore,
1211
+ unitType: 'request',
1212
+ getClient: () =>
1213
+ createServerClient([], payer, openPayload.channelId, {
1214
+ descriptor: openPayload.descriptor,
1215
+ receipt: transactionReceipt([openedLog(openPayload)]),
1216
+ state: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 },
1217
+ }),
1218
+ })
1219
+
1220
+ const receipt = (await method.verify({
1221
+ credential: {
1222
+ challenge: makeChallenge(openPayload.channelId),
1223
+ payload: openPayload,
1224
+ },
1225
+ request: verifyRequest(openPayload.channelId),
1226
+ })) as SessionReceipt
1227
+
1228
+ const stored = await store.getChannel(openPayload.channelId)
1229
+ expect(receipt.acceptedCumulative).toBe('100')
1230
+ expect(receipt.spent).toBe('75')
1231
+ expect(receipt.units).toBe(3)
1232
+ expect(stored?.highestVoucherAmount).toBe(100n)
1233
+ expect(stored?.spent).toBe(75n)
1234
+ expect(stored?.units).toBe(3)
1235
+ })
1236
+
1237
+ test('case-variant precompile channelId does not reset open accounting', async () => {
1238
+ const rawStore = Store.memory()
1239
+ const store = channelStore(rawStore)
1240
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
1241
+ await persistPrecompileChannel(store, openPayload, { spent: 75n, units: 3 })
1242
+ const mixedCaseChannelId = openPayload.channelId.replace(/[a-f]/g, (char) =>
1243
+ char.toUpperCase(),
1244
+ ) as Hex
1245
+ const method = session({
1246
+ amount: '1',
1247
+ chainId,
1248
+ currency: token,
1249
+ decimals: 0,
1250
+ recipient: payee,
1251
+ store: rawStore,
1252
+ unitType: 'request',
1253
+ getClient: () =>
1254
+ createServerClient([], payer, openPayload.channelId, {
1255
+ descriptor: openPayload.descriptor,
1256
+ receipt: transactionReceipt([openedLog(openPayload)]),
1257
+ state: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 },
1258
+ }),
1259
+ })
1260
+
1261
+ const receipt = (await method.verify({
1262
+ credential: {
1263
+ challenge: makeChallenge(mixedCaseChannelId),
1264
+ payload: { ...openPayload, channelId: mixedCaseChannelId },
1265
+ },
1266
+ request: verifyRequest(mixedCaseChannelId),
1267
+ })) as SessionReceipt
1268
+
1269
+ const stored = await store.getChannel(openPayload.channelId)
1270
+ expect(receipt.spent).toBe('75')
1271
+ expect(receipt.units).toBe(3)
1272
+ expect(stored?.spent).toBe(75n)
1273
+ expect(stored?.units).toBe(3)
1274
+ })
1275
+
1276
+ test('uses payer as precompile voucher signer when authorized signer is zero', async () => {
1277
+ const rawStore = Store.memory()
1278
+ const store = channelStore(rawStore)
1279
+ const openPayload = await createOpenPayload({
1280
+ authorizedSigner: zeroAddress,
1281
+ initialAmount: 100n,
1282
+ })
1283
+ expect(openPayload.descriptor.authorizedSigner).toBe(zeroAddress)
1284
+ const method = session({
1285
+ amount: '1',
1286
+ chainId,
1287
+ currency: token,
1288
+ decimals: 0,
1289
+ recipient: payee,
1290
+ store: rawStore,
1291
+ unitType: 'request',
1292
+ getClient: () =>
1293
+ createServerClient([], payer, openPayload.channelId, {
1294
+ descriptor: openPayload.descriptor,
1295
+ receipt: transactionReceipt([openedLog(openPayload)]),
1296
+ state: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 },
1297
+ }),
1298
+ })
1299
+
1300
+ const receipt = (await method.verify({
1301
+ credential: {
1302
+ challenge: makeChallenge(openPayload.channelId),
1303
+ payload: openPayload,
1304
+ },
1305
+ request: verifyRequest(openPayload.channelId),
1306
+ })) as SessionReceipt
1307
+
1308
+ const stored = await store.getChannel(openPayload.channelId)
1309
+ expect(receipt.acceptedCumulative).toBe('100')
1310
+ expect(stored?.authorizedSigner).toBe(payer.address)
1311
+ })
1312
+
1313
+ test('accepts precompile top-up and preserves spent accounting', async () => {
1314
+ const rawStore = Store.memory()
1315
+ const store = channelStore(rawStore)
1316
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
1317
+ await persistPrecompileChannel(store, openPayload, {
1318
+ deposit: 1_000n,
1319
+ spent: 125n,
1320
+ units: 4,
1321
+ })
1322
+ const topUpPayload = await createTopUpPayload(openPayload.descriptor, 500n)
1323
+ const method = session({
1324
+ amount: '1',
1325
+ chainId,
1326
+ currency: token,
1327
+ decimals: 0,
1328
+ recipient: payee,
1329
+ store: rawStore,
1330
+ unitType: 'request',
1331
+ getClient: () =>
1332
+ createServerClient([], payer, topUpPayload.channelId, {
1333
+ receipt: transactionReceipt([topUpLog(topUpPayload, 1_500n)]),
1334
+ state: { settled: 0n, deposit: 1_500n, closeRequestedAt: 0 },
1335
+ }),
1336
+ })
1337
+
1338
+ const receipt = (await method.verify({
1339
+ credential: {
1340
+ challenge: makeChallenge(openPayload.channelId),
1341
+ payload: topUpPayload,
1342
+ },
1343
+ request: verifyRequest(openPayload.channelId),
1344
+ })) as SessionReceipt
1345
+
1346
+ const stored = await store.getChannel(openPayload.channelId)
1347
+ expect(receipt.spent).toBe('125')
1348
+ expect(receipt.units).toBe(4)
1349
+ expect(stored?.deposit).toBe(1_500n)
1350
+ expect(stored?.spent).toBe(125n)
1351
+ expect(stored?.units).toBe(4)
1352
+ })
1353
+
1354
+ test('rejects precompile top-up when on-chain state has pending close', async () => {
1355
+ const rawStore = Store.memory()
1356
+ const store = channelStore(rawStore)
1357
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
1358
+ await persistPrecompileChannel(store, openPayload, { deposit: 1_000n })
1359
+ const topUpPayload = await createTopUpPayload(openPayload.descriptor, 500n)
1360
+ const method = session({
1361
+ amount: '1',
1362
+ chainId,
1363
+ currency: token,
1364
+ decimals: 0,
1365
+ recipient: payee,
1366
+ store: rawStore,
1367
+ unitType: 'request',
1368
+ getClient: () =>
1369
+ createServerClient([], payer, topUpPayload.channelId, {
1370
+ receipt: transactionReceipt([topUpLog(topUpPayload, 1_500n)]),
1371
+ state: { settled: 0n, deposit: 1_500n, closeRequestedAt: 1 },
1372
+ }),
1373
+ })
1374
+
1375
+ await expect(
1376
+ method.verify({
1377
+ credential: {
1378
+ challenge: makeChallenge(openPayload.channelId),
1379
+ payload: topUpPayload,
1380
+ },
1381
+ request: verifyRequest(openPayload.channelId),
1382
+ }),
1383
+ ).rejects.toThrow(/pending close request/)
1384
+ })
1385
+
1386
+ test('rejects precompile top-up on unknown channel', async () => {
1387
+ const { method } = createServer()
1388
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
1389
+ const topUpPayload = await createTopUpPayload(openPayload.descriptor, 500n)
1390
+
1391
+ await expect(
1392
+ method.verify({
1393
+ credential: {
1394
+ challenge: makeChallenge(openPayload.channelId),
1395
+ payload: topUpPayload,
1396
+ },
1397
+ request: verifyRequest(openPayload.channelId),
1398
+ }),
1399
+ ).rejects.toThrow(/channel not found/)
1400
+ })
1401
+
1402
+ test('rejects precompile top-up descriptor mismatches', async () => {
1403
+ const { method, store } = createServer()
1404
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
1405
+ await persistPrecompileChannel(store, openPayload)
1406
+ const badDescriptor = { ...openPayload.descriptor, payee: wrongTarget }
1407
+ const topUpPayload = await createTopUpPayload(badDescriptor, 500n)
1408
+
1409
+ await expect(
1410
+ method.verify({
1411
+ credential: {
1412
+ challenge: makeChallenge(openPayload.channelId),
1413
+ payload: { ...topUpPayload, channelId: openPayload.channelId },
1414
+ },
1415
+ request: verifyRequest(openPayload.channelId),
1416
+ }),
1417
+ ).rejects.toThrow(/descriptor does not match/)
1418
+ })
1419
+
1420
+ test('accepts increasing precompile voucher and stores accounting state', async () => {
1421
+ const { method, store } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER })
1422
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
1423
+ await persistPrecompileChannel(store, openPayload, {
1424
+ highestVoucherAmount: 100n,
1425
+ spent: 100n,
1426
+ units: 1,
1427
+ })
1428
+ const voucher = await ClientOps.createVoucherPayload(
1429
+ createSigningClient(),
1430
+ payer,
1431
+ openPayload.descriptor,
1432
+ Types.uint96(250n),
1433
+ chainId,
1434
+ )
1435
+
1436
+ const receipt = (await method.verify({
1437
+ credential: {
1438
+ challenge: makeChallenge(openPayload.channelId),
1439
+ payload: voucher,
1440
+ },
1441
+ request: verifyRequest(openPayload.channelId),
1442
+ })) as SessionReceipt
1443
+
1444
+ const stored = await store.getChannel(openPayload.channelId)
1445
+ expect(receipt.acceptedCumulative).toBe('250')
1446
+ expect(receipt.spent).toBe('100')
1447
+ expect(receipt.units).toBe(1)
1448
+ expect(stored?.highestVoucherAmount).toBe(250n)
1449
+ expect(stored?.spent).toBe(100n)
1450
+ expect(stored?.units).toBe(1)
1451
+ })
1452
+
1453
+ test('accepts exact precompile voucher replay idempotently', async () => {
1454
+ const { method, store } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER })
1455
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
1456
+ const voucher = await ClientOps.createVoucherPayload(
1457
+ createSigningClient(),
1458
+ payer,
1459
+ openPayload.descriptor,
1460
+ Types.uint96(250n),
1461
+ chainId,
1462
+ )
1463
+ if (voucher.action !== 'voucher') throw new Error('expected voucher payload')
1464
+ await persistPrecompileChannel(store, openPayload, {
1465
+ highestVoucherAmount: 250n,
1466
+ highestVoucher: {
1467
+ channelId: openPayload.channelId,
1468
+ cumulativeAmount: 250n,
1469
+ signature: voucher.signature,
1470
+ },
1471
+ spent: 250n,
1472
+ units: 2,
1473
+ })
1474
+
1475
+ const receipt = (await method.verify({
1476
+ credential: {
1477
+ challenge: makeChallenge(openPayload.channelId),
1478
+ payload: voucher,
1479
+ },
1480
+ request: verifyRequest(openPayload.channelId),
1481
+ })) as SessionReceipt
1482
+
1483
+ const stored = await store.getChannel(openPayload.channelId)
1484
+ expect(receipt.acceptedCumulative).toBe('250')
1485
+ expect(receipt.spent).toBe('250')
1486
+ expect(receipt.units).toBe(2)
1487
+ expect(stored?.highestVoucherAmount).toBe(250n)
1488
+ expect(stored?.units).toBe(2)
1489
+ })
1490
+
1491
+ test('rejects lower precompile voucher replay', async () => {
1492
+ const { method, store } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER })
1493
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
1494
+ await persistPrecompileChannel(store, openPayload, {
1495
+ highestVoucherAmount: 500n,
1496
+ spent: 500n,
1497
+ units: 5,
1498
+ })
1499
+ const voucher = await ClientOps.createVoucherPayload(
1500
+ createSigningClient(),
1501
+ payer,
1502
+ openPayload.descriptor,
1503
+ Types.uint96(250n),
1504
+ chainId,
1505
+ )
1506
+
1507
+ await expect(
1508
+ method.verify({
1509
+ credential: {
1510
+ challenge: makeChallenge(openPayload.channelId),
1511
+ payload: voucher,
1512
+ },
1513
+ request: verifyRequest(openPayload.channelId),
1514
+ }),
1515
+ ).rejects.toThrow(
1516
+ /strictly greater than highest accepted voucher|non-increasing voucher|voucher replay/,
1517
+ )
1518
+ })
1519
+
1520
+ test('rejects precompile voucher below minVoucherDelta', async () => {
1521
+ const { method, store } = createServer({
1522
+ channelStateTtl: Number.MAX_SAFE_INTEGER,
1523
+ minVoucherDelta: '200',
1524
+ })
1525
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
1526
+ await persistPrecompileChannel(store, openPayload, { highestVoucherAmount: 100n })
1527
+ const voucher = await ClientOps.createVoucherPayload(
1528
+ createSigningClient(),
1529
+ payer,
1530
+ openPayload.descriptor,
1531
+ Types.uint96(250n),
1532
+ chainId,
1533
+ )
1534
+
1535
+ await expect(
1536
+ method.verify({
1537
+ credential: {
1538
+ challenge: makeChallenge(openPayload.channelId),
1539
+ payload: voucher,
1540
+ },
1541
+ request: verifyRequest(openPayload.channelId),
1542
+ }),
1543
+ ).rejects.toThrow(/voucher delta 150 below minimum 200/)
1544
+ })
1545
+
1546
+ test('accepts idempotent precompile voucher replay after on-chain settlement catches up', async () => {
1547
+ const rawStore = Store.memory()
1548
+ const store = channelStore(rawStore)
1549
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
1550
+ await persistPrecompileChannel(store, openPayload, {
1551
+ highestVoucherAmount: 500n,
1552
+ settledOnChain: 500n,
1553
+ spent: 500n,
1554
+ units: 10,
1555
+ })
1556
+ const voucher = await ClientOps.createVoucherPayload(
1557
+ createSigningClient(),
1558
+ payer,
1559
+ openPayload.descriptor,
1560
+ Types.uint96(500n),
1561
+ chainId,
1562
+ )
1563
+ const method = session({
1564
+ amount: '1',
1565
+ chainId,
1566
+ currency: token,
1567
+ decimals: 0,
1568
+ recipient: payee,
1569
+ store: rawStore,
1570
+ unitType: 'request',
1571
+ getClient: () =>
1572
+ createStateClient(payer, { settled: 500n, deposit: 1_000n, closeRequestedAt: 0 }),
1573
+ })
1574
+
1575
+ const receipt = (await method.verify({
1576
+ credential: {
1577
+ challenge: makeChallenge(openPayload.channelId),
1578
+ payload: voucher,
1579
+ },
1580
+ request: verifyRequest(openPayload.channelId),
1581
+ })) as SessionReceipt
1582
+
1583
+ expect(receipt.acceptedCumulative).toBe('500')
1584
+ expect(receipt.spent).toBe('500')
1585
+ expect(receipt.units).toBe(10)
1586
+ })
1587
+
1588
+ test('rejects stale or hijacked precompile voucher signatures', async () => {
1589
+ const { method, store } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER })
1590
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
1591
+ await persistPrecompileChannel(store, openPayload, { highestVoucherAmount: 100n })
1592
+ const signature = await Voucher.signVoucher(
1593
+ createSigningClient(wrongPayer),
1594
+ wrongPayer,
1595
+ { channelId: openPayload.channelId, cumulativeAmount: 250n },
1596
+ tip20ChannelEscrow,
1597
+ chainId,
1598
+ )
1599
+
1600
+ await expect(
1601
+ method.verify({
1602
+ credential: {
1603
+ challenge: makeChallenge(openPayload.channelId),
1604
+ payload: {
1605
+ action: 'voucher',
1606
+ channelId: openPayload.channelId,
1607
+ cumulativeAmount: '250',
1608
+ descriptor: openPayload.descriptor,
1609
+ signature,
1610
+ },
1611
+ },
1612
+ request: verifyRequest(openPayload.channelId),
1613
+ }),
1614
+ ).rejects.toThrow(/invalid voucher signature/)
1615
+ })
1616
+
1617
+ test('rejects precompile voucher exceeding deposit', async () => {
1618
+ const { method, store } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER })
1619
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
1620
+ await persistPrecompileChannel(store, openPayload, { deposit: 300n })
1621
+ const voucher = await ClientOps.createVoucherPayload(
1622
+ createSigningClient(),
1623
+ payer,
1624
+ openPayload.descriptor,
1625
+ Types.uint96(350n),
1626
+ chainId,
1627
+ )
1628
+
1629
+ await expect(
1630
+ method.verify({
1631
+ credential: {
1632
+ challenge: makeChallenge(openPayload.channelId),
1633
+ payload: voucher,
1634
+ },
1635
+ request: verifyRequest(openPayload.channelId),
1636
+ }),
1637
+ ).rejects.toThrow(/exceeds.*deposit|insufficient channel deposit/)
1638
+ })
1639
+
1640
+ test('rejects precompile voucher when on-chain state has pending close', async () => {
1641
+ const rawStore = Store.memory()
1642
+ const store = channelStore(rawStore)
1643
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
1644
+ await persistPrecompileChannel(store, openPayload, { closeRequestedAt: 0n })
1645
+ const voucher = await ClientOps.createVoucherPayload(
1646
+ createSigningClient(),
1647
+ payer,
1648
+ openPayload.descriptor,
1649
+ Types.uint96(250n),
1650
+ chainId,
1651
+ )
1652
+ const method = session({
1653
+ amount: '1',
1654
+ chainId,
1655
+ channelStateTtl: 0,
1656
+ currency: token,
1657
+ decimals: 0,
1658
+ recipient: payee,
1659
+ store: rawStore,
1660
+ unitType: 'request',
1661
+ getClient: () =>
1662
+ createStateClient(payer, { settled: 0n, deposit: 1_000n, closeRequestedAt: 1 }),
1663
+ })
1664
+
1665
+ await expect(
1666
+ method.verify({
1667
+ credential: {
1668
+ challenge: makeChallenge(openPayload.channelId),
1669
+ payload: voucher,
1670
+ },
1671
+ request: verifyRequest(openPayload.channelId),
1672
+ }),
1673
+ ).rejects.toThrow(/pending close request/)
1674
+ })
1675
+
1676
+ test('rejects precompile voucher when on-chain deposit is zero', async () => {
1677
+ const rawStore = Store.memory()
1678
+ const store = channelStore(rawStore)
1679
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
1680
+ await persistPrecompileChannel(store, openPayload, { deposit: 1_000n })
1681
+ const voucher = await ClientOps.createVoucherPayload(
1682
+ createSigningClient(),
1683
+ payer,
1684
+ openPayload.descriptor,
1685
+ Types.uint96(250n),
1686
+ chainId,
1687
+ )
1688
+ const method = session({
1689
+ amount: '1',
1690
+ chainId,
1691
+ channelStateTtl: 0,
1692
+ currency: token,
1693
+ decimals: 0,
1694
+ recipient: payee,
1695
+ store: rawStore,
1696
+ unitType: 'request',
1697
+ getClient: () => createStateClient(payer, { settled: 0n, deposit: 0n, closeRequestedAt: 0 }),
1698
+ })
1699
+
1700
+ await expect(
1701
+ method.verify({
1702
+ credential: {
1703
+ challenge: makeChallenge(openPayload.channelId),
1704
+ payload: voucher,
1705
+ },
1706
+ request: verifyRequest(openPayload.channelId),
1707
+ }),
1708
+ ).rejects.toThrow(/deposit is zero|channel deposit is zero|not found/)
1709
+ })
1710
+
1711
+ test('rejects precompile voucher on unknown channel', async () => {
1712
+ const { method } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER })
1713
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
1714
+ const voucher = await ClientOps.createVoucherPayload(
1715
+ createSigningClient(),
1716
+ payer,
1717
+ openPayload.descriptor,
1718
+ Types.uint96(250n),
1719
+ chainId,
1720
+ )
1721
+
1722
+ await expect(
1723
+ method.verify({
1724
+ credential: {
1725
+ challenge: makeChallenge(openPayload.channelId),
1726
+ payload: voucher,
1727
+ },
1728
+ request: verifyRequest(openPayload.channelId),
1729
+ }),
1730
+ ).rejects.toThrow(/unknown channel|not found/)
1731
+ })
1732
+
1733
+ describe('respond', () => {
1734
+ function respond(action: SessionCredentialPayload['action'], input: Request) {
1735
+ const { method } = createServer()
1736
+ return method.respond!({
1737
+ credential: {
1738
+ challenge: makeChallenge(`0x${'01'.repeat(32)}` as Hex),
1739
+ payload: { action },
1740
+ },
1741
+ input,
1742
+ } as never)
1743
+ }
1744
+
1745
+ test('returns 204 for close management requests', () => {
1746
+ const result = respond('close', new Request('http://localhost', { method: 'GET' }))
1747
+ expect(result).toBeInstanceOf(Response)
1748
+ expect((result as Response).status).toBe(204)
1749
+ })
1750
+
1751
+ test('returns 204 for top-up management requests', () => {
1752
+ const result = respond('topUp', new Request('http://localhost', { method: 'POST' }))
1753
+ expect(result).toBeInstanceOf(Response)
1754
+ expect((result as Response).status).toBe(204)
1755
+ })
1756
+
1757
+ test('returns 204 for open POST management requests', () => {
1758
+ const result = respond('open', new Request('http://localhost', { method: 'POST' }))
1759
+ expect(result).toBeInstanceOf(Response)
1760
+ expect((result as Response).status).toBe(204)
1761
+ })
1762
+
1763
+ test('returns 204 for voucher POST management requests', () => {
1764
+ const result = respond('voucher', new Request('http://localhost', { method: 'POST' }))
1765
+ expect(result).toBeInstanceOf(Response)
1766
+ expect((result as Response).status).toBe(204)
1767
+ })
1768
+
1769
+ test('lets open and voucher GET content requests through', () => {
1770
+ expect(respond('open', new Request('http://localhost', { method: 'GET' }))).toBeUndefined()
1771
+ expect(respond('voucher', new Request('http://localhost', { method: 'GET' }))).toBeUndefined()
1772
+ })
1773
+
1774
+ test('lets open and voucher POST content requests with bodies through', () => {
1775
+ expect(
1776
+ respond(
1777
+ 'open',
1778
+ new Request('http://localhost', { method: 'POST', headers: { 'content-length': '1' } }),
1779
+ ),
1780
+ ).toBeUndefined()
1781
+ expect(
1782
+ respond(
1783
+ 'voucher',
1784
+ new Request('http://localhost', {
1785
+ method: 'POST',
1786
+ headers: { 'transfer-encoding': 'chunked' },
1787
+ }),
1788
+ ),
1789
+ ).toBeUndefined()
1790
+ })
1791
+
1792
+ test('returns 204 for voucher POST with content-length zero', () => {
1793
+ const result = respond(
1794
+ 'voucher',
1795
+ new Request('http://localhost', { method: 'POST', headers: { 'content-length': '0' } }),
1796
+ )
1797
+ expect(result).toBeInstanceOf(Response)
1798
+ expect((result as Response).status).toBe(204)
1799
+ })
1800
+ })
1801
+
1802
+ describe('default HTTP auto-billing', () => {
1803
+ function createRoute(rawStore: Store.AtomicStore) {
1804
+ return Mppx_server.create({
1805
+ methods: [
1806
+ tempo_server.session({
1807
+ amount: '1',
1808
+ chainId,
1809
+ currency: token,
1810
+ decimals: 0,
1811
+ recipient: payee,
1812
+ store: rawStore,
1813
+ unitType: 'request',
1814
+ getClient: () => createStateClient(payer),
1815
+ }),
1816
+ ],
1817
+ realm: 'api.example.com',
1818
+ secretKey: 'secret',
1819
+ }).session({ amount: '1', decimals: 0, unitType: 'request' })
1820
+ }
1821
+
1822
+ test('GET content flow charges once and rejects same-voucher replay', async () => {
1823
+ const rawStore = Store.memory()
1824
+ const store = channelStore(rawStore)
1825
+ const openPayload = await createOpenPayload({ initialAmount: 1n })
1826
+ await persistPrecompileChannel(store, openPayload, {
1827
+ highestVoucherAmount: 1n,
1828
+ spent: 0n,
1829
+ units: 0,
1830
+ })
1831
+ const route = createRoute(rawStore)
1832
+ const voucher = await ClientOps.createVoucherPayload(
1833
+ createSigningClient(),
1834
+ payer,
1835
+ openPayload.descriptor,
1836
+ Types.uint96(1n),
1837
+ chainId,
1838
+ )
1839
+
1840
+ const serve = async (request: Request) => {
1841
+ const result = await route(request)
1842
+ if (result.status === 402) return result.challenge
1843
+ return result.withReceipt(new Response('paid-content'))
1844
+ }
1845
+
1846
+ const first = await route(new Request('https://api.example.com/resource'))
1847
+ expect(first.status).toBe(402)
1848
+ if (first.status !== 402) throw new Error('expected challenge')
1849
+
1850
+ const paid = await serve(
1851
+ new Request('https://api.example.com/resource', {
1852
+ headers: {
1853
+ Authorization: Credential.serialize({
1854
+ challenge: Challenge.fromResponse(first.challenge),
1855
+ payload: voucher,
1856
+ }),
1857
+ },
1858
+ }),
1859
+ )
1860
+ expect(paid.status).toBe(200)
1861
+ expect(await paid.text()).toBe('paid-content')
1862
+ const receipt = deserializeSessionReceipt(paid.headers.get('Payment-Receipt') as string)
1863
+ expect(receipt.acceptedCumulative).toBe('1')
1864
+ expect(receipt.spent).toBe('1')
1865
+ expect(receipt.units).toBe(1)
1866
+
1867
+ const replayChallenge = await route(new Request('https://api.example.com/resource'))
1868
+ expect(replayChallenge.status).toBe(402)
1869
+ if (replayChallenge.status !== 402) throw new Error('expected challenge')
1870
+
1871
+ const replay = await serve(
1872
+ new Request('https://api.example.com/resource', {
1873
+ headers: {
1874
+ Authorization: Credential.serialize({
1875
+ challenge: Challenge.fromResponse(replayChallenge.challenge),
1876
+ payload: voucher,
1877
+ }),
1878
+ },
1879
+ }),
1880
+ )
1881
+ expect(replay.status).toBe(402)
1882
+ expect(replay.headers.get('Payment-Receipt')).toBeNull()
1883
+ })
1884
+
1885
+ test('POST content flow charges once and rejects same-voucher replay', async () => {
1886
+ const rawStore = Store.memory()
1887
+ const store = channelStore(rawStore)
1888
+ const openPayload = await createOpenPayload({ initialAmount: 1n })
1889
+ await persistPrecompileChannel(store, openPayload)
1890
+ const route = createRoute(rawStore)
1891
+ const voucher = await ClientOps.createVoucherPayload(
1892
+ createSigningClient(),
1893
+ payer,
1894
+ openPayload.descriptor,
1895
+ Types.uint96(1n),
1896
+ chainId,
1897
+ )
1898
+ const makeRequest = (authorization?: string) =>
1899
+ new Request('https://api.example.com/resource', {
1900
+ method: 'POST',
1901
+ body: '{}',
1902
+ headers: {
1903
+ 'content-length': '2',
1904
+ 'content-type': 'application/json',
1905
+ ...(authorization ? { Authorization: authorization } : {}),
1906
+ },
1907
+ })
1908
+
1909
+ const first = await route(makeRequest())
1910
+ expect(first.status).toBe(402)
1911
+ if (first.status !== 402) throw new Error('expected challenge')
1912
+
1913
+ const result = await route(
1914
+ makeRequest(
1915
+ Credential.serialize({
1916
+ challenge: Challenge.fromResponse(first.challenge),
1917
+ payload: voucher,
1918
+ }),
1919
+ ),
1920
+ )
1921
+ expect(result.status).toBe(200)
1922
+ if (result.status !== 200) throw new Error('expected paid response')
1923
+ const paid = result.withReceipt(new Response('paid-content'))
1924
+ const receipt = deserializeSessionReceipt(paid.headers.get('Payment-Receipt') as string)
1925
+ expect(receipt.spent).toBe('1')
1926
+ expect(receipt.units).toBe(1)
1927
+
1928
+ const replayChallenge = await route(makeRequest())
1929
+ expect(replayChallenge.status).toBe(402)
1930
+ if (replayChallenge.status !== 402) throw new Error('expected challenge')
1931
+ const replay = await route(
1932
+ makeRequest(
1933
+ Credential.serialize({
1934
+ challenge: Challenge.fromResponse(replayChallenge.challenge),
1935
+ payload: voucher,
1936
+ }),
1937
+ ),
1938
+ )
1939
+ expect(replay.status).toBe(402)
1940
+ if (replay.status !== 402) throw new Error('expected challenge')
1941
+ expect(replay.challenge.headers.get('Payment-Receipt')).toBeNull()
1942
+ })
1943
+
1944
+ test('verification errors do not include Payment-Receipt', async () => {
1945
+ const rawStore = Store.memory()
1946
+ const store = channelStore(rawStore)
1947
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
1948
+ await persistPrecompileChannel(store, openPayload)
1949
+ const route = createRoute(rawStore)
1950
+ const first = await route(new Request('https://api.example.com/resource'))
1951
+ expect(first.status).toBe(402)
1952
+ if (first.status !== 402) throw new Error('expected challenge')
1953
+
1954
+ const failed = await route(
1955
+ new Request('https://api.example.com/resource', {
1956
+ headers: {
1957
+ Authorization: Credential.serialize({
1958
+ challenge: Challenge.fromResponse(first.challenge),
1959
+ payload: {
1960
+ action: 'voucher',
1961
+ channelId: openPayload.channelId,
1962
+ cumulativeAmount: '100',
1963
+ descriptor: openPayload.descriptor,
1964
+ signature: (await createOpenPayload({ account: wrongPayer })).signature,
1965
+ },
1966
+ }),
1967
+ },
1968
+ }),
1969
+ )
1970
+
1971
+ expect(failed.status).toBe(402)
1972
+ if (failed.status !== 402) throw new Error('expected challenge')
1973
+ expect(failed.challenge.headers.get('Payment-Receipt')).toBeNull()
1974
+ })
1975
+ })
1976
+
1977
+ describe('same-route bootstrap', () => {
1978
+ function createBootstrapRoute(parameters: {
1979
+ rawStore: Store.AtomicStore
1980
+ recipient?: Address | undefined
1981
+ resolveChannelId?: ResolveSessionChannelId | undefined
1982
+ }) {
1983
+ return Mppx_server.create({
1984
+ methods: [
1985
+ tempo_server.session({
1986
+ amount: '1',
1987
+ bootstrap: true,
1988
+ chainId,
1989
+ currency: token,
1990
+ decimals: 0,
1991
+ getClient: () => createStateClient(payer),
1992
+ recipient: parameters.recipient ?? payee,
1993
+ resolveChannelId: parameters.resolveChannelId,
1994
+ store: parameters.rawStore,
1995
+ unitType: 'request',
1996
+ }),
1997
+ ],
1998
+ realm: 'api.example.com',
1999
+ secretKey: 'secret',
2000
+ }).session({ amount: '1', decimals: 0, unitType: 'request' })
2001
+ }
2002
+
2003
+ async function createBootstrapCredential(response: Response) {
2004
+ const challenge = Challenge.fromResponse(response)
2005
+ const method = clientCharge({
2006
+ account: payer,
2007
+ getClient: () => createSigningClient(),
2008
+ })
2009
+ return method.createCredential({ challenge: challenge as never, context: {} })
2010
+ }
2011
+
2012
+ async function bootstrapResponse(
2013
+ route: ReturnType<typeof createBootstrapRoute>,
2014
+ authorization?: string,
2015
+ ) {
2016
+ const result = await route(
2017
+ new Request('https://api.example.com/resource', {
2018
+ method: 'HEAD',
2019
+ ...(authorization ? { headers: { Authorization: authorization } } : {}),
2020
+ }),
2021
+ )
2022
+ if (result.status === 402) return result.challenge
2023
+ return result.withReceipt(new Response(null, { status: 500 }))
2024
+ }
2025
+
2026
+ test('HEAD without auth emits a zero-amount tempo charge challenge', async () => {
2027
+ const route = createBootstrapRoute({ rawStore: Store.memory() })
2028
+
2029
+ const response = await bootstrapResponse(route)
2030
+ const challenge = Challenge.fromResponse(response)
2031
+
2032
+ expect(response.status).toBe(402)
2033
+ expect(challenge.method).toBe('tempo')
2034
+ expect(challenge.intent).toBe('charge')
2035
+ expect(challenge.request.amount).toBe('0')
2036
+ })
2037
+
2038
+ test('valid proof resolves channel by source and returns snapshot headers', async () => {
2039
+ const rawStore = Store.memory()
2040
+ const store = channelStore(rawStore)
2041
+ const openPayload = await createOpenPayload({ initialAmount: 1n })
2042
+ await persistPrecompileChannel(store, openPayload, {
2043
+ highestVoucherAmount: 5n,
2044
+ spent: 3n,
2045
+ units: 3,
2046
+ })
2047
+ const resolveChannelId = vi.fn(({ source }) => {
2048
+ expect(source).toContain(payer.address)
2049
+ return openPayload.channelId
2050
+ })
2051
+ const route = createBootstrapRoute({ rawStore, resolveChannelId })
2052
+ const challengeResponse = await bootstrapResponse(route)
2053
+ const authorization = await createBootstrapCredential(challengeResponse)
2054
+
2055
+ const response = await bootstrapResponse(route, authorization)
2056
+
2057
+ expect(response.status).toBe(204)
2058
+ expect(resolveChannelId).toHaveBeenCalledOnce()
2059
+ expect(response.headers.get(Constants.Headers.paymentSession)).toBe(openPayload.channelId)
2060
+ const snapshot = session.deserializeSnapshot(
2061
+ response.headers.get(Constants.Headers.paymentSessionSnapshot)!,
2062
+ )
2063
+ expect(snapshot).toMatchObject({
2064
+ channelId: openPayload.channelId,
2065
+ acceptedCumulative: '5',
2066
+ requiredCumulative: '3',
2067
+ spent: '3',
2068
+ })
2069
+ })
2070
+
2071
+ test('valid proof with no resolved channel returns empty 204', async () => {
2072
+ const rawStore = Store.memory()
2073
+ const route = createBootstrapRoute({
2074
+ rawStore,
2075
+ resolveChannelId: () => undefined,
2076
+ })
2077
+ const authorization = await createBootstrapCredential(await bootstrapResponse(route))
2078
+
2079
+ const response = await bootstrapResponse(route, authorization)
2080
+
2081
+ expect(response.status).toBe(204)
2082
+ expect(response.headers.get(Constants.Headers.paymentSession)).toBeNull()
2083
+ expect(response.headers.get(Constants.Headers.paymentSessionSnapshot)).toBeNull()
2084
+ })
2085
+
2086
+ test('valid proof does not return snapshots for channels with different payment fields', async () => {
2087
+ const rawStore = Store.memory()
2088
+ const store = channelStore(rawStore)
2089
+ const openPayload = await createOpenPayload({ initialAmount: 1n })
2090
+ await persistPrecompileChannel(store, openPayload)
2091
+ const route = createBootstrapRoute({
2092
+ rawStore,
2093
+ recipient: '0x0000000000000000000000000000000000000004',
2094
+ resolveChannelId: () => openPayload.channelId,
2095
+ })
2096
+ const authorization = await createBootstrapCredential(await bootstrapResponse(route))
2097
+
2098
+ const response = await bootstrapResponse(route, authorization)
2099
+
2100
+ expect(response.status).toBe(204)
2101
+ expect(response.headers.get(Constants.Headers.paymentSession)).toBeNull()
2102
+ expect(response.headers.get(Constants.Headers.paymentSessionSnapshot)).toBeNull()
2103
+ })
2104
+
2105
+ test('replayed proof is rejected before channel resolution', async () => {
2106
+ const rawStore = Store.memory()
2107
+ const resolveChannelId = vi.fn(() => undefined)
2108
+ const route = createBootstrapRoute({ rawStore, resolveChannelId })
2109
+ const authorization = await createBootstrapCredential(await bootstrapResponse(route))
2110
+
2111
+ expect((await bootstrapResponse(route, authorization)).status).toBe(204)
2112
+ const replay = await bootstrapResponse(route, authorization)
2113
+
2114
+ expect(replay.status).toBe(402)
2115
+ expect(resolveChannelId).toHaveBeenCalledOnce()
2116
+ })
2117
+ })
2118
+
2119
+ describe('SSE parity', () => {
2120
+ function createManagedSseFetch(
2121
+ options: { amount?: string; maxDeposit?: bigint; unitType?: 'request' | 'token' } = {},
2122
+ ) {
2123
+ const rawStore = Store.memory()
2124
+ let currentPayload: SessionCredentialPayload | undefined
2125
+ let voucherPosts = 0
2126
+ const amount = options.amount ?? '1'
2127
+ const maxDeposit = options.maxDeposit ?? 3n
2128
+ const unitType = options.unitType ?? 'token'
2129
+ const route = Mppx_server.create({
2130
+ methods: [
2131
+ tempo_server.session({
2132
+ amount,
2133
+ chainId,
2134
+ currency: token,
2135
+ decimals: 0,
2136
+ recipient: payer.address,
2137
+ sse: true,
2138
+ store: rawStore,
2139
+ unitType,
2140
+ getClient: () => {
2141
+ const payload = currentPayload
2142
+ if (payload?.action === 'open') {
2143
+ return createServerClient([], payer, payload.channelId, {
2144
+ descriptor: payload.descriptor,
2145
+ receipt: transactionReceipt([openedLog(payload, maxDeposit)]),
2146
+ state: { settled: 0n, deposit: maxDeposit, closeRequestedAt: 0 },
2147
+ })
2148
+ }
2149
+ if (payload?.action === 'close') {
2150
+ return createServerClient([], payer, payload.channelId, {
2151
+ receipt: transactionReceipt([
2152
+ closedLog(payload.channelId, BigInt(payload.cumulativeAmount), 0n),
2153
+ ]),
2154
+ state: { settled: 0n, deposit: maxDeposit, closeRequestedAt: 0 },
2155
+ })
2156
+ }
2157
+ return createStateClient(payer, {
2158
+ settled: 0n,
2159
+ deposit: maxDeposit,
2160
+ closeRequestedAt: 0,
2161
+ })
2162
+ },
2163
+ }),
2164
+ ],
2165
+ realm: 'api.example.com',
2166
+ secretKey: 'secret',
2167
+ }).session({ amount, decimals: 0, suggestedDeposit: maxDeposit.toString(), unitType })
2168
+
2169
+ const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
2170
+ const request = new Request(input, init)
2171
+ currentPayload = undefined
2172
+ if (request.headers.has('Authorization')) {
2173
+ try {
2174
+ currentPayload = Credential.fromRequest<SessionCredentialPayload>(request).payload
2175
+ if (currentPayload.action === 'voucher') voucherPosts++
2176
+ } catch {}
2177
+ }
2178
+
2179
+ const result = await route(request)
2180
+ if (result.status === 402) return result.challenge
2181
+ if (currentPayload?.action === 'voucher') return new Response(null, { status: 200 })
2182
+
2183
+ if (request.headers.get('Accept')?.includes('text/event-stream')) {
2184
+ if (unitType === 'request') {
2185
+ const encoder = new TextEncoder()
2186
+ return result.withReceipt(
2187
+ new Response(
2188
+ new ReadableStream({
2189
+ start(controller) {
2190
+ controller.enqueue(encoder.encode('event: message\ndata: chunk-1\n\n'))
2191
+ controller.enqueue(encoder.encode('event: message\ndata: chunk-2\n\n'))
2192
+ controller.enqueue(encoder.encode('event: message\ndata: chunk-3\n\n'))
2193
+ controller.close()
2194
+ },
2195
+ }),
2196
+ { headers: { 'Content-Type': 'text/event-stream; charset=utf-8' } },
2197
+ ),
2198
+ )
2199
+ }
2200
+
2201
+ return result.withReceipt(async function* (stream) {
2202
+ await stream.charge()
2203
+ yield 'chunk-1'
2204
+ await stream.charge()
2205
+ yield 'chunk-2'
2206
+ await stream.charge()
2207
+ yield 'chunk-3'
2208
+ })
2209
+ }
2210
+
2211
+ return result.withReceipt(new Response('ok'))
2212
+ }
2213
+
2214
+ return {
2215
+ fetch,
2216
+ rawStore,
2217
+ get voucherPosts() {
2218
+ return voucherPosts
2219
+ },
2220
+ }
2221
+ }
2222
+
2223
+ test('open -> stream -> need-voucher -> resume -> close', async () => {
2224
+ const harness = createManagedSseFetch({ maxDeposit: 3n })
2225
+ const manager = precompileSessionManager({
2226
+ account: payer,
2227
+ client: createSigningClient(),
2228
+ decimals: 0,
2229
+ fetch: harness.fetch,
2230
+ maxDeposit: '3',
2231
+ })
2232
+
2233
+ const chunks: string[] = []
2234
+ const stream = await manager.sse('https://api.example.com/stream')
2235
+ for await (const chunk of stream) chunks.push(chunk)
2236
+
2237
+ expect(chunks).toEqual(['chunk-1', 'chunk-2', 'chunk-3'])
2238
+ expect(harness.voucherPosts).toBeGreaterThan(0)
2239
+
2240
+ const closeReceipt = await manager.close()
2241
+ expect(closeReceipt?.status).toBe('success')
2242
+ expect(closeReceipt?.spent).toBe('3')
2243
+
2244
+ const channelId = manager.channelId
2245
+ expect(channelId).toBeTruthy()
2246
+ const persisted = await channelStore(harness.rawStore).getChannel(channelId!)
2247
+ expect(persisted?.finalized).toBe(true)
2248
+ })
2249
+
2250
+ test('unitType=request auto-metered SSE responses charge once across the stream', async () => {
2251
+ const harness = createManagedSseFetch({ maxDeposit: 1n, unitType: 'request' })
2252
+ const manager = precompileSessionManager({
2253
+ account: payer,
2254
+ client: createSigningClient(),
2255
+ decimals: 0,
2256
+ fetch: harness.fetch,
2257
+ maxDeposit: '1',
2258
+ })
2259
+
2260
+ const chunks: string[] = []
2261
+ const stream = await manager.sse('https://api.example.com/stream')
2262
+ for await (const chunk of stream) chunks.push(chunk)
2263
+
2264
+ expect(chunks).toEqual(['chunk-1', 'chunk-2', 'chunk-3'])
2265
+ expect(harness.voucherPosts).toBe(0)
2266
+
2267
+ const closeReceipt = await manager.close()
2268
+ expect(closeReceipt?.status).toBe('success')
2269
+ expect(closeReceipt?.spent).toBe('1')
2270
+ })
2271
+ })
2272
+
2273
+ describe('WebSocket parity', () => {
2274
+ async function createManagedWsHarness(options: { maxDeposit?: bigint } = {}) {
2275
+ const rawStore = Store.memory()
2276
+ let currentPayload: SessionCredentialPayload | undefined
2277
+ let voucherPosts = 0
2278
+ const maxDeposit = options.maxDeposit ?? 3n
2279
+ const routeHandler = Mppx_server.create({
2280
+ methods: [
2281
+ tempo_server.session({
2282
+ amount: '1',
2283
+ chainId,
2284
+ currency: token,
2285
+ decimals: 0,
2286
+ recipient: payer.address,
2287
+ store: rawStore,
2288
+ unitType: 'token',
2289
+ getClient: () => {
2290
+ const payload = currentPayload
2291
+ if (payload?.action === 'open') {
2292
+ return createServerClient([], payer, payload.channelId, {
2293
+ descriptor: payload.descriptor,
2294
+ receipt: transactionReceipt([openedLog(payload, maxDeposit)]),
2295
+ state: { settled: 0n, deposit: maxDeposit, closeRequestedAt: 0 },
2296
+ })
2297
+ }
2298
+ if (payload?.action === 'close') {
2299
+ return createServerClient([], payer, payload.channelId, {
2300
+ receipt: transactionReceipt([
2301
+ closedLog(payload.channelId, BigInt(payload.cumulativeAmount), 0n),
2302
+ ]),
2303
+ state: { settled: 0n, deposit: maxDeposit, closeRequestedAt: 0 },
2304
+ })
2305
+ }
2306
+ return createStateClient(payer, {
2307
+ settled: 0n,
2308
+ deposit: maxDeposit,
2309
+ closeRequestedAt: 0,
2310
+ })
2311
+ },
2312
+ }),
2313
+ ],
2314
+ realm: 'api.example.com',
2315
+ secretKey: 'secret',
2316
+ }).session({ amount: '1', decimals: 0, suggestedDeposit: maxDeposit.toString() })
2317
+
2318
+ const route = async (request: Request) => {
2319
+ currentPayload = undefined
2320
+ if (request.headers.has('Authorization')) {
2321
+ try {
2322
+ currentPayload = Credential.fromRequest<SessionCredentialPayload>(request).payload
2323
+ if (currentPayload.action === 'voucher') voucherPosts++
2324
+ } catch {}
2325
+ }
2326
+ return routeHandler(request)
2327
+ }
2328
+
2329
+ const httpHandler = NodeRequest.toNodeListener(async (request) => {
2330
+ const result = await route(request)
2331
+ if (result.status === 402) return result.challenge
2332
+ return result.withReceipt(new Response('ok'))
2333
+ })
2334
+
2335
+ const nodeServer = node_http.createServer(httpHandler)
2336
+ const wsServer = new WebSocketServer({ noServer: true })
2337
+
2338
+ await new Promise<void>((resolve) => nodeServer.listen(0, resolve))
2339
+ const { port } = nodeServer.address() as { port: number }
2340
+ const server = Http.wrapServer(nodeServer, {
2341
+ port,
2342
+ url: `http://localhost:${port}`,
2343
+ })
2344
+
2345
+ nodeServer.on('upgrade', (req, socket, head) => {
2346
+ if (req.url !== '/ws') {
2347
+ socket.destroy()
2348
+ return
2349
+ }
2350
+ wsServer.handleUpgrade(req, socket, head, (websocket) => {
2351
+ wsServer.emit('connection', websocket, req)
2352
+ })
2353
+ })
2354
+
2355
+ return {
2356
+ rawStore,
2357
+ route,
2358
+ server,
2359
+ wsServer,
2360
+ get port() {
2361
+ return port
2362
+ },
2363
+ get voucherPosts() {
2364
+ return voucherPosts
2365
+ },
2366
+ close() {
2367
+ wsServer.close()
2368
+ server.close()
2369
+ },
2370
+ }
2371
+ }
2372
+
2373
+ test('open -> stream -> need-voucher -> resume -> close', async () => {
2374
+ const harness = await createManagedWsHarness({ maxDeposit: 3n })
2375
+ harness.wsServer.on('connection', (socket: import('ws').WebSocket) => {
2376
+ void TempoWs.serve({
2377
+ socket,
2378
+ store: harness.rawStore,
2379
+ url: `${harness.server.url}/ws`,
2380
+ route: harness.route,
2381
+ generate: async function* (stream: TempoWs.SessionController) {
2382
+ await stream.charge()
2383
+ yield 'chunk-1'
2384
+ await stream.charge()
2385
+ yield 'chunk-2'
2386
+ await stream.charge()
2387
+ yield 'chunk-3'
2388
+ },
2389
+ })
2390
+ })
2391
+
2392
+ try {
2393
+ const manager = precompileSessionManager({
2394
+ account: payer,
2395
+ client: createSigningClient(),
2396
+ decimals: 0,
2397
+ fetch: globalThis.fetch,
2398
+ maxDeposit: '3',
2399
+ webSocket: WebSocket as never,
2400
+ })
2401
+
2402
+ const ws = await manager.ws(`ws://localhost:${harness.port}/ws`)
2403
+ const chunks: string[] = []
2404
+
2405
+ await new Promise<void>((resolve, reject) => {
2406
+ ws.addEventListener('message', (event) => {
2407
+ if (typeof event.data !== 'string') return
2408
+ chunks.push(event.data)
2409
+ })
2410
+ ws.addEventListener('close', () => resolve(), { once: true })
2411
+ ws.addEventListener('error', () => reject(new Error('websocket stream failed')), {
2412
+ once: true,
2413
+ })
2414
+ })
2415
+
2416
+ expect(chunks).toEqual(['chunk-1', 'chunk-2', 'chunk-3'])
2417
+ expect(harness.voucherPosts).toBeGreaterThan(0)
2418
+
2419
+ const closeReceipt = await manager.close()
2420
+ expect(closeReceipt?.status).toBe('success')
2421
+ expect(closeReceipt?.spent).toBe('3')
2422
+
2423
+ const channelId = manager.channelId
2424
+ expect(channelId).toBeTruthy()
2425
+ const persisted = await channelStore(harness.rawStore).getChannel(channelId!)
2426
+ expect(persisted?.finalized).toBe(true)
2427
+ } finally {
2428
+ harness.close()
2429
+ }
2430
+ })
2431
+
2432
+ test('treats control-shaped application payloads as content', async () => {
2433
+ const harness = await createManagedWsHarness({ maxDeposit: 1n })
2434
+ const controlLookingChunk = JSON.stringify({
2435
+ mpp: 'payment-need-voucher',
2436
+ data: {
2437
+ channelId: `0x${'aa'.repeat(32)}`,
2438
+ requiredCumulative: '9',
2439
+ acceptedCumulative: '0',
2440
+ deposit: '9',
2441
+ },
2442
+ })
2443
+ harness.wsServer.on('connection', (socket: import('ws').WebSocket) => {
2444
+ void TempoWs.serve({
2445
+ socket,
2446
+ store: harness.rawStore,
2447
+ url: `${harness.server.url}/ws`,
2448
+ route: harness.route,
2449
+ generate: async function* (stream: TempoWs.SessionController) {
2450
+ await stream.charge()
2451
+ yield controlLookingChunk
2452
+ },
2453
+ })
2454
+ })
2455
+
2456
+ try {
2457
+ const manager = precompileSessionManager({
2458
+ account: payer,
2459
+ client: createSigningClient(),
2460
+ decimals: 0,
2461
+ fetch: globalThis.fetch,
2462
+ maxDeposit: '1',
2463
+ webSocket: WebSocket as never,
2464
+ })
2465
+
2466
+ const ws = await manager.ws(`ws://localhost:${harness.port}/ws`)
2467
+ const chunks: string[] = []
2468
+
2469
+ await new Promise<void>((resolve, reject) => {
2470
+ ws.addEventListener('message', (event) => {
2471
+ if (typeof event.data !== 'string') return
2472
+ chunks.push(event.data)
2473
+ })
2474
+ ws.addEventListener('close', () => resolve(), { once: true })
2475
+ ws.addEventListener('error', () => reject(new Error('websocket stream failed')), {
2476
+ once: true,
2477
+ })
2478
+ })
2479
+
2480
+ expect(chunks).toEqual([controlLookingChunk])
2481
+ expect(harness.voucherPosts).toBe(0)
2482
+
2483
+ const closeReceipt = await manager.close()
2484
+ expect(closeReceipt?.status).toBe('success')
2485
+ expect(closeReceipt?.spent).toBe('1')
2486
+ } finally {
2487
+ harness.close()
2488
+ }
2489
+ })
2490
+
2491
+ test('refuses websocket voucher requests beyond local maxDeposit', async () => {
2492
+ const harness = await createManagedWsHarness({ maxDeposit: 1n })
2493
+ harness.wsServer.on('connection', (socket: import('ws').WebSocket) => {
2494
+ void TempoWs.serve({
2495
+ socket,
2496
+ store: harness.rawStore,
2497
+ url: `${harness.server.url}/ws`,
2498
+ route: harness.route,
2499
+ generate: async function* (stream: TempoWs.SessionController) {
2500
+ await stream.charge()
2501
+ yield 'chunk-1'
2502
+ await stream.charge()
2503
+ yield 'chunk-2'
2504
+ },
2505
+ })
2506
+ })
2507
+
2508
+ try {
2509
+ const manager = precompileSessionManager({
2510
+ account: payer,
2511
+ client: createSigningClient(),
2512
+ decimals: 0,
2513
+ fetch: globalThis.fetch,
2514
+ maxDeposit: '1',
2515
+ webSocket: WebSocket as never,
2516
+ })
2517
+
2518
+ const ws = await manager.ws(`ws://localhost:${harness.port}/ws`)
2519
+ const closeEvent = await new Promise<{ code: number; reason: string }>(
2520
+ (resolve, reject) => {
2521
+ ws.addEventListener(
2522
+ 'close',
2523
+ (event) => resolve({ code: event.code, reason: event.reason }),
2524
+ { once: true },
2525
+ )
2526
+ ws.addEventListener('error', () => reject(new Error('websocket stream failed')), {
2527
+ once: true,
2528
+ })
2529
+ },
2530
+ )
2531
+
2532
+ expect(closeEvent.code).toBe(3008)
2533
+ expect(closeEvent.reason).toBe('requested voucher amount 2 exceeds local maxDeposit 1')
2534
+ } finally {
2535
+ harness.close()
2536
+ }
2537
+ })
2538
+
2539
+ test('rejects websocket receipts bound to a different channel', async () => {
2540
+ const harness = await createManagedWsHarness({ maxDeposit: 1n })
2541
+ const wrongChannelId = `0x${'11'.repeat(32)}` as Hex
2542
+ harness.wsServer.on('connection', (socket: import('ws').WebSocket) => {
2543
+ socket.once('message', (data) => {
2544
+ const message = TempoWs.parseMessage(data.toString())
2545
+ if (!message || message.mpp !== 'authorization') return
2546
+
2547
+ const credential = Credential.deserialize<SessionCredentialPayload>(message.authorization)
2548
+ socket.send(
2549
+ TempoWs.formatReceiptMessage({
2550
+ method: 'tempo',
2551
+ intent: 'session',
2552
+ status: 'success',
2553
+ timestamp: new Date().toISOString(),
2554
+ reference: wrongChannelId,
2555
+ challengeId: credential.challenge.id,
2556
+ channelId: wrongChannelId,
2557
+ acceptedCumulative: '1',
2558
+ spent: '0',
2559
+ units: 0,
2560
+ }),
2561
+ )
2562
+ })
2563
+ })
2564
+
2565
+ try {
2566
+ const manager = precompileSessionManager({
2567
+ account: payer,
2568
+ client: createSigningClient(),
2569
+ decimals: 0,
2570
+ fetch: globalThis.fetch,
2571
+ maxDeposit: '1',
2572
+ webSocket: WebSocket as never,
2573
+ })
2574
+
2575
+ await expect(manager.ws(`ws://localhost:${harness.port}/ws`)).rejects.toThrow(
2576
+ 'received mismatched payment-receipt frame',
2577
+ )
2578
+ } finally {
2579
+ harness.close()
2580
+ }
2581
+ })
2582
+
2583
+ test('rejects close-ready receipts beyond local voucher state', async () => {
2584
+ const harness = await createManagedWsHarness({ maxDeposit: 1n })
2585
+ harness.wsServer.on('connection', (socket: import('ws').WebSocket) => {
2586
+ let openCredential: Credential.Credential<SessionCredentialPayload> | undefined
2587
+
2588
+ socket.on('message', (data) => {
2589
+ const message = TempoWs.parseMessage(data.toString())
2590
+ if (!message) return
2591
+
2592
+ if (message.mpp === 'authorization') {
2593
+ openCredential = Credential.deserialize<SessionCredentialPayload>(message.authorization)
2594
+ const payload = openCredential.payload
2595
+ if (payload.action !== 'open') return
2596
+ socket.send(
2597
+ TempoWs.formatReceiptMessage({
2598
+ method: 'tempo',
2599
+ intent: 'session',
2600
+ status: 'success',
2601
+ timestamp: new Date().toISOString(),
2602
+ reference: payload.channelId,
2603
+ challengeId: openCredential.challenge.id,
2604
+ channelId: payload.channelId,
2605
+ acceptedCumulative: payload.cumulativeAmount,
2606
+ spent: '0',
2607
+ units: 0,
2608
+ }),
2609
+ )
2610
+ return
2611
+ }
2612
+
2613
+ if (message.mpp !== 'payment-close-request' || !openCredential) return
2614
+ const payload = openCredential.payload
2615
+ socket.send(
2616
+ TempoWs.formatCloseReadyMessage({
2617
+ method: 'tempo',
2618
+ intent: 'session',
2619
+ status: 'success',
2620
+ timestamp: new Date().toISOString(),
2621
+ reference: payload.channelId,
2622
+ challengeId: openCredential.challenge.id,
2623
+ channelId: payload.channelId,
2624
+ acceptedCumulative: '1',
2625
+ spent: '9',
2626
+ units: 1,
2627
+ }),
2628
+ )
2629
+ })
2630
+ })
2631
+
2632
+ try {
2633
+ const manager = precompileSessionManager({
2634
+ account: payer,
2635
+ client: createSigningClient(),
2636
+ decimals: 0,
2637
+ fetch: globalThis.fetch,
2638
+ maxDeposit: '1',
2639
+ webSocket: WebSocket as never,
2640
+ })
2641
+
2642
+ await manager.ws(`ws://localhost:${harness.port}/ws`)
2643
+ await expect(manager.close()).rejects.toThrow(
2644
+ 'received payment-close-ready beyond local voucher state',
2645
+ )
2646
+ } finally {
2647
+ harness.close()
2648
+ }
2649
+ })
2650
+
2651
+ test('fallback close after socket death signs for delivered amount, not full voucher', async () => {
2652
+ const harness = await createManagedWsHarness({ maxDeposit: 3n })
2653
+ let serverSocket: import('ws').WebSocket | null = null
2654
+ harness.wsServer.on('connection', (socket: import('ws').WebSocket) => {
2655
+ serverSocket = socket
2656
+ void TempoWs.serve({
2657
+ socket,
2658
+ store: harness.rawStore,
2659
+ url: `${harness.server.url}/ws`,
2660
+ route: harness.route,
2661
+ generate: async function* (stream: TempoWs.SessionController) {
2662
+ await stream.charge()
2663
+ yield 'chunk-1'
2664
+ await stream.charge()
2665
+ yield 'chunk-2'
2666
+ await new Promise((resolve) => setTimeout(resolve, 60_000))
2667
+ },
2668
+ })
2669
+ })
2670
+
2671
+ try {
2672
+ const manager = precompileSessionManager({
2673
+ account: payer,
2674
+ client: createSigningClient(),
2675
+ decimals: 0,
2676
+ fetch: globalThis.fetch,
2677
+ maxDeposit: '3',
2678
+ webSocket: WebSocket as never,
2679
+ })
2680
+
2681
+ const ws = await manager.ws(`ws://localhost:${harness.port}/ws`)
2682
+ const chunks: string[] = []
2683
+
2684
+ await new Promise<void>((resolve) => {
2685
+ ws.addEventListener('message', (event) => {
2686
+ if (typeof event.data !== 'string') return
2687
+ chunks.push(event.data)
2688
+ if (chunks.length === 2) serverSocket?.terminate()
2689
+ })
2690
+ ws.addEventListener('close', () => resolve(), { once: true })
2691
+ })
2692
+
2693
+ expect(chunks).toEqual(['chunk-1', 'chunk-2'])
2694
+
2695
+ const closeReceipt = await manager.close()
2696
+ expect(closeReceipt).toBeDefined()
2697
+ expect(BigInt(closeReceipt!.spent)).toBeLessThanOrEqual(2n)
2698
+ expect(BigInt(closeReceipt!.spent)).toBeGreaterThan(0n)
2699
+ expect(BigInt(closeReceipt!.spent)).toBeLessThan(3n)
2700
+ } finally {
2701
+ harness.close()
2702
+ }
2703
+ })
2704
+
2705
+ test('rejects tx-bearing open receipts replayed during websocket close', async () => {
2706
+ const harness = await createManagedWsHarness({ maxDeposit: 1n })
2707
+ harness.wsServer.on('connection', (socket: import('ws').WebSocket) => {
2708
+ let openCredential: Credential.Credential<SessionCredentialPayload> | undefined
2709
+ let openReceipt: SessionReceipt | undefined
2710
+
2711
+ socket.on('message', (data) => {
2712
+ const message = TempoWs.parseMessage(data.toString())
2713
+ if (!message) return
2714
+
2715
+ if (message.mpp === 'authorization') {
2716
+ const credential = Credential.deserialize<SessionCredentialPayload>(
2717
+ message.authorization,
2718
+ )
2719
+ const payload = credential.payload
2720
+ if (payload.action === 'close') {
2721
+ if (openReceipt) socket.send(TempoWs.formatReceiptMessage(openReceipt))
2722
+ return
2723
+ }
2724
+ if (payload.action !== 'open') return
2725
+
2726
+ openCredential = credential
2727
+ openReceipt = {
2728
+ method: 'tempo',
2729
+ intent: 'session',
2730
+ status: 'success',
2731
+ timestamp: new Date().toISOString(),
2732
+ reference: payload.channelId,
2733
+ challengeId: credential.challenge.id,
2734
+ channelId: payload.channelId,
2735
+ acceptedCumulative: payload.cumulativeAmount,
2736
+ spent: '0',
2737
+ units: 0,
2738
+ txHash: `0x${'12'.repeat(32)}` as Hex,
2739
+ }
2740
+ socket.send(TempoWs.formatReceiptMessage(openReceipt))
2741
+ return
2742
+ }
2743
+
2744
+ if (message.mpp !== 'payment-close-request' || !openCredential) return
2745
+ const payload = openCredential.payload
2746
+ socket.send(
2747
+ TempoWs.formatCloseReadyMessage({
2748
+ method: 'tempo',
2749
+ intent: 'session',
2750
+ status: 'success',
2751
+ timestamp: new Date().toISOString(),
2752
+ reference: payload.channelId,
2753
+ challengeId: openCredential.challenge.id,
2754
+ channelId: payload.channelId,
2755
+ acceptedCumulative: '1',
2756
+ spent: '0',
2757
+ units: 0,
2758
+ }),
2759
+ )
2760
+ })
2761
+ })
2762
+
2763
+ try {
2764
+ const manager = precompileSessionManager({
2765
+ account: payer,
2766
+ client: createSigningClient(),
2767
+ decimals: 0,
2768
+ fetch: globalThis.fetch,
2769
+ maxDeposit: '1',
2770
+ webSocket: WebSocket as never,
2771
+ })
2772
+
2773
+ await manager.ws(`ws://localhost:${harness.port}/ws`)
2774
+ await expect(manager.close()).rejects.toThrow(
2775
+ 'received mismatched payment-close receipt frame',
2776
+ )
2777
+ } finally {
2778
+ harness.close()
2779
+ }
2780
+ })
2781
+ })
2782
+
2783
+ test('does not let a racing lower voucher regress highest accepted precompile voucher', async () => {
2784
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
2785
+ const lowerVoucher = await ClientOps.createVoucherPayload(
2786
+ createSigningClient(),
2787
+ payer,
2788
+ openPayload.descriptor,
2789
+ Types.uint96(200n),
2790
+ chainId,
2791
+ )
2792
+ const higherVoucher = await ClientOps.createVoucherPayload(
2793
+ createSigningClient(),
2794
+ payer,
2795
+ openPayload.descriptor,
2796
+ Types.uint96(500n),
2797
+ chainId,
2798
+ )
2799
+ if (higherVoucher.action !== 'voucher') throw new Error('expected voucher payload')
2800
+
2801
+ const seedStore = channelStore(Store.memory())
2802
+ await persistPrecompileChannel(seedStore, openPayload, {
2803
+ highestVoucherAmount: 100n,
2804
+ highestVoucher: {
2805
+ channelId: openPayload.channelId,
2806
+ cumulativeAmount: 100n,
2807
+ signature: openPayload.signature,
2808
+ },
2809
+ })
2810
+ const stale = (await seedStore.getChannel(openPayload.channelId))!
2811
+ let stored: ChannelStore.State = {
2812
+ ...stale,
2813
+ highestVoucherAmount: 500n,
2814
+ highestVoucher: {
2815
+ channelId: openPayload.channelId,
2816
+ cumulativeAmount: 500n,
2817
+ signature: higherVoucher.signature,
2818
+ },
2819
+ }
2820
+ const racingStore = {
2821
+ async get(_key: string) {
2822
+ return stale as never
2823
+ },
2824
+ async put(_key: string, value: unknown) {
2825
+ stored = value as ChannelStore.State
2826
+ },
2827
+ async delete(_key: string) {},
2828
+ async update<result>(
2829
+ _key: string,
2830
+ fn: (current: unknown | null) => Store.Change<unknown, result>,
2831
+ ): Promise<result> {
2832
+ const change = fn(stored)
2833
+ if (change.op === 'set') stored = change.value as ChannelStore.State
2834
+ return change.result
2835
+ },
2836
+ } as Store.AtomicStore
2837
+ const method = session({
2838
+ account: payer,
2839
+ amount: '1',
2840
+ chainId,
2841
+ channelStateTtl: Number.MAX_SAFE_INTEGER,
2842
+ currency: token,
2843
+ decimals: 0,
2844
+ recipient: payee,
2845
+ store: racingStore,
2846
+ unitType: 'request',
2847
+ getClient: () => createStateClient(payer),
2848
+ })
2849
+
2850
+ const receipt = await method.verify({
2851
+ credential: {
2852
+ challenge: makeChallenge(openPayload.channelId),
2853
+ payload: lowerVoucher,
2854
+ },
2855
+ request: verifyRequest(openPayload.channelId),
2856
+ })
2857
+
2858
+ expect((receipt as SessionReceipt).acceptedCumulative).toBe('500')
2859
+ expect(stored.highestVoucherAmount).toBe(500n)
2860
+ expect(stored.highestVoucher?.signature).toBe(higherVoucher.signature)
2861
+ })
2862
+
2863
+ test('marks pending precompile close before broadcast and restores it when broadcast fails', async () => {
2864
+ const rawStore = Store.memory()
2865
+ const store = channelStore(rawStore)
2866
+ const openPayload = await createOpenPayload()
2867
+ await persistPrecompileChannel(store, openPayload, {
2868
+ payee: payer.address,
2869
+ })
2870
+ let observedPending = false
2871
+ const method = session({
2872
+ account: payer,
2873
+ amount: '1',
2874
+ chainId,
2875
+ currency: token,
2876
+ decimals: 0,
2877
+ recipient: payee,
2878
+ store: rawStore,
2879
+ unitType: 'request',
2880
+ getClient: () =>
2881
+ createClient({
2882
+ account: payer,
2883
+ chain: testChain,
2884
+ transport: custom({
2885
+ async request(args) {
2886
+ if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
2887
+ if (args.method === 'eth_call')
2888
+ return encodeFunctionResult({
2889
+ abi: escrowAbi,
2890
+ functionName: 'getChannelState',
2891
+ result: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 },
2892
+ })
2893
+ if (args.method === 'eth_getTransactionCount') {
2894
+ observedPending =
2895
+ (await store.getChannel(openPayload.channelId))!.closeRequestedAt !== 0n
2896
+ throw new Error('broadcast failed')
2897
+ }
2898
+ if (args.method === 'eth_estimateGas') return '0x5208'
2899
+ if (args.method === 'eth_maxPriorityFeePerGas') return '0x1'
2900
+ if (args.method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' }
2901
+ throw new Error(`unexpected rpc request: ${args.method}`)
2902
+ },
2903
+ }),
2904
+ }),
2905
+ })
2906
+ const payload = await ClientOps.createClosePayload(
2907
+ createSigningClient(),
2908
+ payer,
2909
+ openPayload.descriptor,
2910
+ Types.uint96(100n),
2911
+ chainId,
2912
+ )
2913
+
2914
+ await expect(
2915
+ method.verify({
2916
+ credential: {
2917
+ challenge: makeChallenge(openPayload.channelId),
2918
+ payload,
2919
+ },
2920
+ request: verifyRequest(openPayload.channelId),
2921
+ }),
2922
+ ).rejects.toThrow(/broadcast failed/)
2923
+ expect(observedPending).toBe(true)
2924
+ expect((await store.getChannel(openPayload.channelId))!.closeRequestedAt).toBe(0n)
2925
+ })
2926
+
2927
+ test('precompile settle returns txHash when channel disappears before final write', async () => {
2928
+ const rawStore = Store.memory()
2929
+ const store = channelStore(rawStore)
2930
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
2931
+ await persistPrecompileChannel(store, openPayload, { payee: payer.address })
2932
+ const client = createServerClient([], payer, openPayload.channelId, {
2933
+ receipt: transactionReceipt([settledLog(openPayload.channelId, 100n)]),
2934
+ state: { settled: 100n, deposit: 1_000n, closeRequestedAt: 0 },
2935
+ })
2936
+ let deleted = false
2937
+ const disappearingStore = {
2938
+ async get(key: string) {
2939
+ return rawStore.get(key)
2940
+ },
2941
+ async put(key: string, value: unknown) {
2942
+ return rawStore.put(key, value)
2943
+ },
2944
+ async delete(key: string) {
2945
+ return rawStore.delete(key)
2946
+ },
2947
+ async update<result>(
2948
+ key: string,
2949
+ fn: (current: unknown | null) => Store.Change<unknown, result>,
2950
+ ): Promise<result> {
2951
+ if (!deleted) {
2952
+ deleted = true
2953
+ return rawStore.update(key, fn)
2954
+ }
2955
+ const change = fn(null)
2956
+ return change.result
2957
+ },
2958
+ } as Store.AtomicStore
2959
+
2960
+ const { settle } = await import('./Session.js')
2961
+ await expect(settle(disappearingStore, client, openPayload.channelId)).resolves.toBe(
2962
+ `0x${'aa'.repeat(32)}`,
2963
+ )
2964
+ })
2965
+
2966
+ test('precompile close still returns receipt when channel disappears before final write', async () => {
2967
+ const rawStore = Store.memory()
2968
+ const store = channelStore(rawStore)
2969
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
2970
+ await persistPrecompileChannel(store, openPayload, { payee: payer.address, spent: 100n })
2971
+ const closeSignature = await Voucher.signVoucher(
2972
+ createSigningClient(),
2973
+ payer,
2974
+ { channelId: openPayload.channelId, cumulativeAmount: 100n },
2975
+ tip20ChannelEscrow,
2976
+ chainId,
2977
+ )
2978
+ const method = session({
2979
+ account: payer,
2980
+ amount: '1',
2981
+ chainId,
2982
+ currency: token,
2983
+ decimals: 0,
2984
+ recipient: payee,
2985
+ store: rawStore,
2986
+ unitType: 'request',
2987
+ getClient: () =>
2988
+ createServerClient([], payer, openPayload.channelId, {
2989
+ receipt: transactionReceipt([closedLog(openPayload.channelId, 100n, 900n)]),
2990
+ state: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 },
2991
+ }),
2992
+ })
2993
+ let deleteBeforeFinalWrite = false
2994
+ const originalUpdate = store.updateChannel.bind(store)
2995
+ store.updateChannel = (async (channelId, fn) => {
2996
+ if (deleteBeforeFinalWrite) return fn(null as never) as never
2997
+ const result = await originalUpdate(channelId, fn)
2998
+ deleteBeforeFinalWrite = true
2999
+ return result
3000
+ }) as typeof store.updateChannel
3001
+
3002
+ const receipt = (await method.verify({
3003
+ credential: {
3004
+ challenge: makeChallenge(openPayload.channelId),
3005
+ payload: {
3006
+ action: 'close',
3007
+ channelId: openPayload.channelId,
3008
+ cumulativeAmount: '100',
3009
+ descriptor: openPayload.descriptor,
3010
+ signature: closeSignature,
3011
+ },
3012
+ },
3013
+ request: verifyRequest(openPayload.channelId),
3014
+ })) as SessionReceipt
3015
+
3016
+ expect(receipt.txHash).toBe(`0x${'aa'.repeat(32)}`)
3017
+ expect(receipt.spent).toBe('100')
3018
+ })
3019
+
3020
+ test('rejects close when precompile channel is finalized on-chain', async () => {
3021
+ const rawStore = Store.memory()
3022
+ const store = channelStore(rawStore)
3023
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
3024
+ await persistPrecompileChannel(store, openPayload, { payee: payer.address, spent: 100n })
3025
+ const closeSignature = await Voucher.signVoucher(
3026
+ createSigningClient(),
3027
+ payer,
3028
+ { channelId: openPayload.channelId, cumulativeAmount: 100n },
3029
+ tip20ChannelEscrow,
3030
+ chainId,
3031
+ )
3032
+ const method = session({
3033
+ account: payer,
3034
+ amount: '1',
3035
+ chainId,
3036
+ currency: token,
3037
+ decimals: 0,
3038
+ recipient: payee,
3039
+ store: rawStore,
3040
+ unitType: 'request',
3041
+ getClient: () => createStateClient(payer, { settled: 0n, deposit: 0n, closeRequestedAt: 0 }),
3042
+ })
3043
+
3044
+ await expect(
3045
+ method.verify({
3046
+ credential: {
3047
+ challenge: makeChallenge(openPayload.channelId),
3048
+ payload: {
3049
+ action: 'close',
3050
+ channelId: openPayload.channelId,
3051
+ cumulativeAmount: '100',
3052
+ descriptor: openPayload.descriptor,
3053
+ signature: closeSignature,
3054
+ },
3055
+ },
3056
+ request: verifyRequest(openPayload.channelId),
3057
+ }),
3058
+ ).rejects.toThrow(/channel deposit is zero/)
3059
+ })
3060
+
3061
+ test('pending precompile close blocks concurrent charges', async () => {
3062
+ const { store } = createServer()
3063
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
3064
+ await persistPrecompileChannel(store, openPayload, {
3065
+ closeRequestedAt: 1n,
3066
+ highestVoucherAmount: 500n,
3067
+ spent: 100n,
3068
+ })
3069
+
3070
+ await expect(charge(store, openPayload.channelId, 1n)).rejects.toThrow(/pending close request/)
3071
+ })
3072
+
3073
+ test('rejects server-driven close when no account is available', async () => {
3074
+ const rawStore = Store.memory()
3075
+ const store = channelStore(rawStore)
3076
+ const openPayload = await createOpenPayload()
3077
+ await persistPrecompileChannel(store, openPayload)
3078
+ const method = session({
3079
+ amount: '1',
3080
+ chainId,
3081
+ currency: token,
3082
+ decimals: 0,
3083
+ recipient: payee,
3084
+ store: rawStore,
3085
+ unitType: 'request',
3086
+ getClient: () => createStateClient(null),
3087
+ })
3088
+ const payload = await ClientOps.createClosePayload(
3089
+ createSigningClient(),
3090
+ payer,
3091
+ openPayload.descriptor,
3092
+ Types.uint96(100n),
3093
+ chainId,
3094
+ )
3095
+
3096
+ await expect(
3097
+ method.verify({
3098
+ credential: {
3099
+ challenge: makeChallenge(openPayload.channelId),
3100
+ payload,
3101
+ },
3102
+ request: verifyRequest(openPayload.channelId),
3103
+ }),
3104
+ ).rejects.toThrow(/no account available/)
3105
+ })
3106
+
3107
+ test('accepts server-driven close account override matching the channel payee', async () => {
3108
+ const rawStore = Store.memory()
3109
+ const store = channelStore(rawStore)
3110
+ const openPayload = await createOpenPayload()
3111
+ await persistPrecompileChannel(store, openPayload, {
3112
+ payee: wrongPayer.address,
3113
+ })
3114
+ const method = session({
3115
+ account: wrongPayer,
3116
+ amount: '1',
3117
+ chainId,
3118
+ currency: token,
3119
+ decimals: 0,
3120
+ recipient: payee,
3121
+ store: rawStore,
3122
+ unitType: 'request',
3123
+ getClient: () => createStateClient(payer),
3124
+ })
3125
+ const payload = await ClientOps.createClosePayload(
3126
+ createSigningClient(),
3127
+ payer,
3128
+ openPayload.descriptor,
3129
+ Types.uint96(100n),
3130
+ chainId,
3131
+ )
3132
+
3133
+ await expect(
3134
+ method.verify({
3135
+ credential: {
3136
+ challenge: makeChallenge(openPayload.channelId),
3137
+ payload,
3138
+ },
3139
+ request: verifyRequest(openPayload.channelId),
3140
+ }),
3141
+ ).rejects.toThrow(/eth_sendRawTransaction/)
3142
+ })
3143
+
3144
+ test('uses request-specified fee payer account for server-driven precompile close', async () => {
3145
+ const rawStore = Store.memory()
3146
+ const store = channelStore(rawStore)
3147
+ const openPayload = await createOpenPayload()
3148
+ await persistPrecompileChannel(store, openPayload, {
3149
+ payee: wrongPayer.address,
3150
+ })
3151
+ const method = session({
3152
+ account: wrongPayer,
3153
+ amount: '1',
3154
+ chainId,
3155
+ currency: token,
3156
+ decimals: 0,
3157
+ feeToken: token,
3158
+ recipient: payee,
3159
+ store: rawStore,
3160
+ unitType: 'request',
3161
+ getClient: () => createStateClient(payer),
3162
+ })
3163
+ const payload = await ClientOps.createClosePayload(
3164
+ createSigningClient(),
3165
+ payer,
3166
+ openPayload.descriptor,
3167
+ Types.uint96(100n),
3168
+ chainId,
3169
+ )
3170
+
3171
+ await expect(
3172
+ method.verify({
3173
+ credential: {
3174
+ challenge: makeChallenge(openPayload.channelId),
3175
+ payload,
3176
+ },
3177
+ request: verifyRequestWithFeePayer(openPayload.channelId, payer),
3178
+ }),
3179
+ ).rejects.toThrow(/eth_sendRawTransaction/)
3180
+ })
3181
+
3182
+ test('accepts server-driven close sender matching a nonzero precompile operator', async () => {
3183
+ const rawStore = Store.memory()
3184
+ const store = channelStore(rawStore)
3185
+ const openPayload = await createOpenPayload({
3186
+ operator: wrongPayer.address,
3187
+ })
3188
+ await persistPrecompileChannel(store, openPayload)
3189
+ const method = session({
3190
+ account: wrongPayer,
3191
+ amount: '1',
3192
+ chainId,
3193
+ currency: token,
3194
+ decimals: 0,
3195
+ recipient: payee,
3196
+ operator: wrongPayer.address,
3197
+ store: rawStore,
3198
+ unitType: 'request',
3199
+ getClient: () => createStateClient(payer),
3200
+ })
3201
+ const payload = await ClientOps.createClosePayload(
3202
+ createSigningClient(),
3203
+ payer,
3204
+ openPayload.descriptor,
3205
+ Types.uint96(100n),
3206
+ chainId,
3207
+ )
3208
+
3209
+ await expect(
3210
+ method.verify({
3211
+ credential: {
3212
+ challenge: makeChallenge(openPayload.channelId, { operator: wrongPayer.address }),
3213
+ payload,
3214
+ },
3215
+ request: verifyRequest(openPayload.channelId, { operator: wrongPayer.address }),
3216
+ }),
3217
+ ).rejects.toThrow(/eth_sendRawTransaction/)
3218
+ })
3219
+
3220
+ test('rejects server-driven close when sender is not the channel payee or operator', async () => {
3221
+ const rawStore = Store.memory()
3222
+ const store = channelStore(rawStore)
3223
+ const openPayload = await createOpenPayload()
3224
+ await persistPrecompileChannel(store, openPayload)
3225
+ const method = session({
3226
+ amount: '1',
3227
+ chainId,
3228
+ currency: token,
3229
+ decimals: 0,
3230
+ recipient: payee,
3231
+ store: rawStore,
3232
+ unitType: 'request',
3233
+ getClient: () => createStateClient(wrongPayer),
3234
+ })
3235
+ const payload = await ClientOps.createClosePayload(
3236
+ createSigningClient(),
3237
+ payer,
3238
+ openPayload.descriptor,
3239
+ Types.uint96(100n),
3240
+ chainId,
3241
+ )
3242
+
3243
+ await expect(
3244
+ method.verify({
3245
+ credential: {
3246
+ challenge: makeChallenge(openPayload.channelId),
3247
+ payload,
3248
+ },
3249
+ request: verifyRequest(openPayload.channelId),
3250
+ }),
3251
+ ).rejects.toThrow(/tx sender .* is not the channel payee/)
3252
+ })
3253
+ })