tapimo 0.0.0 → 0.0.2

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 (422) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +119 -0
  3. package/dist/ApiKey.d.ts +65 -0
  4. package/dist/ApiKey.d.ts.map +1 -0
  5. package/dist/ApiKey.js +105 -0
  6. package/dist/ApiKey.js.map +1 -0
  7. package/dist/ApiKeySource.d.ts +166 -0
  8. package/dist/ApiKeySource.d.ts.map +1 -0
  9. package/dist/ApiKeySource.js +234 -0
  10. package/dist/ApiKeySource.js.map +1 -0
  11. package/dist/App.d.ts +19317 -0
  12. package/dist/App.d.ts.map +1 -0
  13. package/dist/App.js +612 -0
  14. package/dist/App.js.map +1 -0
  15. package/dist/Assets.d.ts +44 -0
  16. package/dist/Assets.d.ts.map +1 -0
  17. package/dist/Assets.js +23 -0
  18. package/dist/Assets.js.map +1 -0
  19. package/dist/Auth.d.ts +2 -0
  20. package/dist/Auth.d.ts.map +1 -0
  21. package/dist/Auth.js +2 -0
  22. package/dist/Auth.js.map +1 -0
  23. package/dist/Cli.d.ts +21 -0
  24. package/dist/Cli.d.ts.map +1 -0
  25. package/dist/Cli.js +13 -0
  26. package/dist/Cli.js.map +1 -0
  27. package/dist/Client.d.ts +20 -0
  28. package/dist/Client.d.ts.map +1 -0
  29. package/dist/Client.js +24 -0
  30. package/dist/Client.js.map +1 -0
  31. package/dist/Metrics.d.ts +70 -0
  32. package/dist/Metrics.d.ts.map +1 -0
  33. package/dist/Metrics.js +91 -0
  34. package/dist/Metrics.js.map +1 -0
  35. package/dist/Scope.d.ts +35 -0
  36. package/dist/Scope.d.ts.map +1 -0
  37. package/dist/Scope.js +38 -0
  38. package/dist/Scope.js.map +1 -0
  39. package/dist/Webhooks.d.ts +79 -0
  40. package/dist/Webhooks.d.ts.map +1 -0
  41. package/dist/Webhooks.js +107 -0
  42. package/dist/Webhooks.js.map +1 -0
  43. package/dist/admin/Access.d.ts +41 -0
  44. package/dist/admin/Access.d.ts.map +1 -0
  45. package/dist/admin/Access.js +75 -0
  46. package/dist/admin/Access.js.map +1 -0
  47. package/dist/admin/App.d.ts +1221 -0
  48. package/dist/admin/App.d.ts.map +1 -0
  49. package/dist/admin/App.js +113 -0
  50. package/dist/admin/App.js.map +1 -0
  51. package/dist/admin/apps/api-keys.d.ts +209 -0
  52. package/dist/admin/apps/api-keys.d.ts.map +1 -0
  53. package/dist/admin/apps/api-keys.js +129 -0
  54. package/dist/admin/apps/api-keys.js.map +1 -0
  55. package/dist/admin/apps/scopes.d.ts +46 -0
  56. package/dist/admin/apps/scopes.d.ts.map +1 -0
  57. package/dist/admin/apps/scopes.js +41 -0
  58. package/dist/admin/apps/scopes.js.map +1 -0
  59. package/dist/admin/apps/verified-tokens.d.ts +1206 -0
  60. package/dist/admin/apps/verified-tokens.d.ts.map +1 -0
  61. package/dist/admin/apps/verified-tokens.js +485 -0
  62. package/dist/admin/apps/verified-tokens.js.map +1 -0
  63. package/dist/admin/index.d.ts +3 -0
  64. package/dist/admin/index.d.ts.map +1 -0
  65. package/dist/admin/index.js +3 -0
  66. package/dist/admin/index.js.map +1 -0
  67. package/dist/admin/ui.generated.d.ts +11 -0
  68. package/dist/admin/ui.generated.d.ts.map +1 -0
  69. package/dist/admin/ui.generated.js +43 -0
  70. package/dist/admin/ui.generated.js.map +1 -0
  71. package/dist/apps/activities.d.ts +18725 -0
  72. package/dist/apps/activities.d.ts.map +1 -0
  73. package/dist/apps/activities.js +3138 -0
  74. package/dist/apps/activities.js.map +1 -0
  75. package/dist/apps/addresses.d.ts +3662 -0
  76. package/dist/apps/addresses.d.ts.map +1 -0
  77. package/dist/apps/addresses.js +22 -0
  78. package/dist/apps/addresses.js.map +1 -0
  79. package/dist/apps/balances.d.ts +342 -0
  80. package/dist/apps/balances.d.ts.map +1 -0
  81. package/dist/apps/balances.js +393 -0
  82. package/dist/apps/balances.js.map +1 -0
  83. package/dist/apps/blocks.d.ts +931 -0
  84. package/dist/apps/blocks.d.ts.map +1 -0
  85. package/dist/apps/blocks.js +689 -0
  86. package/dist/apps/blocks.js.map +1 -0
  87. package/dist/apps/coingecko.d.ts +1053 -0
  88. package/dist/apps/coingecko.d.ts.map +1 -0
  89. package/dist/apps/coingecko.js +1015 -0
  90. package/dist/apps/coingecko.js.map +1 -0
  91. package/dist/apps/exchanges.d.ts +3000 -0
  92. package/dist/apps/exchanges.d.ts.map +1 -0
  93. package/dist/apps/exchanges.js +3570 -0
  94. package/dist/apps/exchanges.js.map +1 -0
  95. package/dist/apps/fee-amm.d.ts +640 -0
  96. package/dist/apps/fee-amm.d.ts.map +1 -0
  97. package/dist/apps/fee-amm.js +815 -0
  98. package/dist/apps/fee-amm.js.map +1 -0
  99. package/dist/apps/indexer.d.ts +116 -0
  100. package/dist/apps/indexer.d.ts.map +1 -0
  101. package/dist/apps/indexer.js +270 -0
  102. package/dist/apps/indexer.js.map +1 -0
  103. package/dist/apps/receipts.d.ts +1438 -0
  104. package/dist/apps/receipts.d.ts.map +1 -0
  105. package/dist/apps/receipts.js +874 -0
  106. package/dist/apps/receipts.js.map +1 -0
  107. package/dist/apps/rpc.d.ts +173 -0
  108. package/dist/apps/rpc.d.ts.map +1 -0
  109. package/dist/apps/rpc.js +191 -0
  110. package/dist/apps/rpc.js.map +1 -0
  111. package/dist/apps/tokenlist.d.ts +192 -0
  112. package/dist/apps/tokenlist.d.ts.map +1 -0
  113. package/dist/apps/tokenlist.js +153 -0
  114. package/dist/apps/tokenlist.js.map +1 -0
  115. package/dist/apps/tokens.d.ts +2246 -0
  116. package/dist/apps/tokens.d.ts.map +1 -0
  117. package/dist/apps/tokens.js +1789 -0
  118. package/dist/apps/tokens.js.map +1 -0
  119. package/dist/apps/transactions.d.ts +2327 -0
  120. package/dist/apps/transactions.d.ts.map +1 -0
  121. package/dist/apps/transactions.js +975 -0
  122. package/dist/apps/transactions.js.map +1 -0
  123. package/dist/apps/transfers.d.ts +589 -0
  124. package/dist/apps/transfers.d.ts.map +1 -0
  125. package/dist/apps/transfers.js +769 -0
  126. package/dist/apps/transfers.js.map +1 -0
  127. package/dist/apps/verified-tokens.d.ts +544 -0
  128. package/dist/apps/verified-tokens.d.ts.map +1 -0
  129. package/dist/apps/verified-tokens.js +234 -0
  130. package/dist/apps/verified-tokens.js.map +1 -0
  131. package/dist/apps/webhooks.d.ts +7684 -0
  132. package/dist/apps/webhooks.d.ts.map +1 -0
  133. package/dist/apps/webhooks.js +2112 -0
  134. package/dist/apps/webhooks.js.map +1 -0
  135. package/dist/cloudflare.d.ts +32 -0
  136. package/dist/cloudflare.d.ts.map +1 -0
  137. package/dist/cloudflare.js +45 -0
  138. package/dist/cloudflare.js.map +1 -0
  139. package/dist/index.d.ts +16 -0
  140. package/dist/index.d.ts.map +1 -0
  141. package/dist/index.js +16 -0
  142. package/dist/index.js.map +1 -0
  143. package/dist/internal/Auth.d.ts +298 -0
  144. package/dist/internal/Auth.d.ts.map +1 -0
  145. package/dist/internal/Auth.js +618 -0
  146. package/dist/internal/Auth.js.map +1 -0
  147. package/dist/internal/Cache.d.ts +74 -0
  148. package/dist/internal/Cache.d.ts.map +1 -0
  149. package/dist/internal/Cache.js +215 -0
  150. package/dist/internal/Cache.js.map +1 -0
  151. package/dist/internal/Cursor.d.ts +79 -0
  152. package/dist/internal/Cursor.d.ts.map +1 -0
  153. package/dist/internal/Cursor.js +116 -0
  154. package/dist/internal/Cursor.js.map +1 -0
  155. package/dist/internal/EdgeCache.d.ts +59 -0
  156. package/dist/internal/EdgeCache.d.ts.map +1 -0
  157. package/dist/internal/EdgeCache.js +174 -0
  158. package/dist/internal/EdgeCache.js.map +1 -0
  159. package/dist/internal/Log.d.ts +104 -0
  160. package/dist/internal/Log.d.ts.map +1 -0
  161. package/dist/internal/Log.js +118 -0
  162. package/dist/internal/Log.js.map +1 -0
  163. package/dist/internal/MetricSink.d.ts +65 -0
  164. package/dist/internal/MetricSink.d.ts.map +1 -0
  165. package/dist/internal/MetricSink.js +146 -0
  166. package/dist/internal/MetricSink.js.map +1 -0
  167. package/dist/internal/Mpp.d.ts +71 -0
  168. package/dist/internal/Mpp.d.ts.map +1 -0
  169. package/dist/internal/Mpp.js +105 -0
  170. package/dist/internal/Mpp.js.map +1 -0
  171. package/dist/internal/OpenApi.d.ts +313 -0
  172. package/dist/internal/OpenApi.d.ts.map +1 -0
  173. package/dist/internal/OpenApi.fonts.d.ts +2 -0
  174. package/dist/internal/OpenApi.fonts.d.ts.map +1 -0
  175. package/dist/internal/OpenApi.fonts.js +39 -0
  176. package/dist/internal/OpenApi.fonts.js.map +1 -0
  177. package/dist/internal/OpenApi.js +335 -0
  178. package/dist/internal/OpenApi.js.map +1 -0
  179. package/dist/internal/OpenApi.logo.d.ts +6 -0
  180. package/dist/internal/OpenApi.logo.d.ts.map +1 -0
  181. package/dist/internal/OpenApi.logo.js +19 -0
  182. package/dist/internal/OpenApi.logo.js.map +1 -0
  183. package/dist/internal/Path.d.ts +5 -0
  184. package/dist/internal/Path.d.ts.map +1 -0
  185. package/dist/internal/Path.js +16 -0
  186. package/dist/internal/Path.js.map +1 -0
  187. package/dist/internal/RateLimit.d.ts +60 -0
  188. package/dist/internal/RateLimit.d.ts.map +1 -0
  189. package/dist/internal/RateLimit.js +48 -0
  190. package/dist/internal/RateLimit.js.map +1 -0
  191. package/dist/internal/Response.d.ts +132 -0
  192. package/dist/internal/Response.d.ts.map +1 -0
  193. package/dist/internal/Response.js +72 -0
  194. package/dist/internal/Response.js.map +1 -0
  195. package/dist/internal/Schema.d.ts +257 -0
  196. package/dist/internal/Schema.d.ts.map +1 -0
  197. package/dist/internal/Schema.js +360 -0
  198. package/dist/internal/Schema.js.map +1 -0
  199. package/dist/internal/Store.d.ts +204 -0
  200. package/dist/internal/Store.d.ts.map +1 -0
  201. package/dist/internal/Store.js +291 -0
  202. package/dist/internal/Store.js.map +1 -0
  203. package/dist/internal/Tidx.d.ts +120 -0
  204. package/dist/internal/Tidx.d.ts.map +1 -0
  205. package/dist/internal/Tidx.js +156 -0
  206. package/dist/internal/Tidx.js.map +1 -0
  207. package/dist/internal/Timing.d.ts +22 -0
  208. package/dist/internal/Timing.d.ts.map +1 -0
  209. package/dist/internal/Timing.js +32 -0
  210. package/dist/internal/Timing.js.map +1 -0
  211. package/dist/internal/Ttl.d.ts +25 -0
  212. package/dist/internal/Ttl.d.ts.map +1 -0
  213. package/dist/internal/Ttl.js +49 -0
  214. package/dist/internal/Ttl.js.map +1 -0
  215. package/dist/internal/Value.d.ts +47 -0
  216. package/dist/internal/Value.d.ts.map +1 -0
  217. package/dist/internal/Value.js +122 -0
  218. package/dist/internal/Value.js.map +1 -0
  219. package/dist/internal/VerifiedTokens.d.ts +228 -0
  220. package/dist/internal/VerifiedTokens.d.ts.map +1 -0
  221. package/dist/internal/VerifiedTokens.js +450 -0
  222. package/dist/internal/VerifiedTokens.js.map +1 -0
  223. package/dist/internal/Viem.d.ts +2082 -0
  224. package/dist/internal/Viem.d.ts.map +1 -0
  225. package/dist/internal/Viem.js +63 -0
  226. package/dist/internal/Viem.js.map +1 -0
  227. package/dist/internal/WebhookDestination.d.ts +198 -0
  228. package/dist/internal/WebhookDestination.d.ts.map +1 -0
  229. package/dist/internal/WebhookDestination.js +170 -0
  230. package/dist/internal/WebhookDestination.js.map +1 -0
  231. package/dist/internal/Webhooks.d.ts +433 -0
  232. package/dist/internal/Webhooks.d.ts.map +1 -0
  233. package/dist/internal/Webhooks.js +489 -0
  234. package/dist/internal/Webhooks.js.map +1 -0
  235. package/dist/internal/webhookDestinations/betterStack.d.ts +26 -0
  236. package/dist/internal/webhookDestinations/betterStack.d.ts.map +1 -0
  237. package/dist/internal/webhookDestinations/betterStack.js +76 -0
  238. package/dist/internal/webhookDestinations/betterStack.js.map +1 -0
  239. package/dist/internal/webhookDestinations/slack.d.ts +59 -0
  240. package/dist/internal/webhookDestinations/slack.d.ts.map +1 -0
  241. package/dist/internal/webhookDestinations/slack.js +422 -0
  242. package/dist/internal/webhookDestinations/slack.js.map +1 -0
  243. package/dist/internal/webhookDestinations/url.d.ts +17 -0
  244. package/dist/internal/webhookDestinations/url.d.ts.map +1 -0
  245. package/dist/internal/webhookDestinations/url.js +130 -0
  246. package/dist/internal/webhookDestinations/url.js.map +1 -0
  247. package/dist/openrpc.json +5302 -0
  248. package/package.json +46 -4
  249. package/seeds/31318/icons/0x20c0000000000000000000000000000000000000.svg +1 -0
  250. package/seeds/31318/icons/0x20c0000000000000000000000000000000000001.svg +1 -0
  251. package/seeds/31318/icons/0x20c0000000000000000000000000000000000002.svg +1 -0
  252. package/seeds/31318/icons/0x20c0000000000000000000000000000000000003.svg +1 -0
  253. package/seeds/31318/verified-tokens.json +30 -0
  254. package/seeds/4217/icons/0x20c0000000000000000000000000000000000000.svg +25 -0
  255. package/seeds/4217/icons/0x20c000000000000000000000048c8f36df1c9a4a.svg +1 -0
  256. package/seeds/4217/icons/0x20c0000000000000000000000520792dcccccccc.svg +1 -0
  257. package/seeds/4217/icons/0x20c0000000000000000000000a6da882d075a4c3.svg +4 -0
  258. package/seeds/4217/icons/0x20c000000000000000000000111111111e910f0f.svg +30 -0
  259. package/seeds/4217/icons/0x20c00000000000000000000014f22ca97301eb73.svg +1 -0
  260. package/seeds/4217/icons/0x20c0000000000000000000001621e21f71cf12fb.svg +1 -0
  261. package/seeds/4217/icons/0x20c0000000000000000000002f52d5cc21a3207b.svg +1 -0
  262. package/seeds/4217/icons/0x20c0000000000000000000003158081efd85bfc2.svg +11 -0
  263. package/seeds/4217/icons/0x20c0000000000000000000003554d28269e0f3c2.svg +1 -0
  264. package/seeds/4217/icons/0x20c000000000000000000000383a23bacb546ab9.svg +1 -0
  265. package/seeds/4217/icons/0x20c00000000000000000000042109aef2f8b28e1.png +0 -0
  266. package/seeds/4217/icons/0x20c0000000000000000000005c0bac7cef389a11.svg +1 -0
  267. package/seeds/4217/icons/0x20c0000000000000000000006fd9a167923ba194.png +0 -0
  268. package/seeds/4217/icons/0x20c0000000000000000000007f7ba549dd0251b9.svg +1 -0
  269. package/seeds/4217/icons/0x20c0000000000000000000008191667423f70e67.svg +5 -0
  270. package/seeds/4217/icons/0x20c0000000000000000000008ee4fcff88888888.svg +1 -0
  271. package/seeds/4217/icons/0x20c0000000000000000000009a4a4b17e0dc6651.svg +1 -0
  272. package/seeds/4217/icons/0x20c000000000000000000000ab02d39df30bd17e.svg +1 -0
  273. package/seeds/4217/icons/0x20c000000000000000000000ae247a1130450f09.svg +1 -0
  274. package/seeds/4217/icons/0x20c000000000000000000000aeed2ec36a54d0e5.svg +1 -0
  275. package/seeds/4217/icons/0x20c000000000000000000000b9537d11c60e8b50.svg +1 -0
  276. package/seeds/4217/icons/0x20c000000000000000000000bd95bfb69fbe6ce3.svg +1 -0
  277. package/seeds/4217/icons/0x20c000000000000000000000c412ec89d0c08be5.svg +1 -0
  278. package/seeds/4217/icons/0x20c000000000000000000000f047dd7018e50367.png +0 -0
  279. package/seeds/4217/verified-tokens.json +177 -0
  280. package/seeds/42431/icons/0x20c0000000000000000000000000000000000000.svg +25 -0
  281. package/seeds/42431/icons/0x20c0000000000000000000000000000000000001.svg +1 -0
  282. package/seeds/42431/icons/0x20c0000000000000000000000000000000000002.svg +1 -0
  283. package/seeds/42431/icons/0x20c0000000000000000000000000000000000003.svg +1 -0
  284. package/seeds/42431/icons/0x20c0000000000000000000009e8d7eb59b783726.svg +1 -0
  285. package/seeds/42431/icons/0x20c000000000000000000000d72572838bbee59c.svg +1 -0
  286. package/seeds/42431/verified-tokens.json +44 -0
  287. package/seeds/openrpc/README.md +27 -0
  288. package/seeds/openrpc/execution-apis.json +8989 -0
  289. package/src/ApiKey.test.ts +91 -0
  290. package/src/ApiKey.ts +122 -0
  291. package/src/ApiKeySource.test-d.ts +11 -0
  292. package/src/ApiKeySource.test.ts +295 -0
  293. package/src/ApiKeySource.ts +303 -0
  294. package/src/App.test-d.ts +38 -0
  295. package/src/App.test.ts +65927 -0
  296. package/src/App.ts +855 -0
  297. package/src/Assets.ts +58 -0
  298. package/src/Auth.ts +1 -0
  299. package/src/Cli.test.ts +81 -0
  300. package/src/Cli.ts +38 -0
  301. package/src/Client.test-d.ts +1438 -0
  302. package/src/Client.test.ts +134 -0
  303. package/src/Client.ts +45 -0
  304. package/src/Metrics.ts +154 -0
  305. package/src/Scope.ts +50 -0
  306. package/src/Webhooks.test.ts +49 -0
  307. package/src/Webhooks.ts +172 -0
  308. package/src/admin/Access.test.ts +130 -0
  309. package/src/admin/Access.ts +87 -0
  310. package/src/admin/App.test.ts +225 -0
  311. package/src/admin/App.ts +216 -0
  312. package/src/admin/apps/api-keys.test.ts +156 -0
  313. package/src/admin/apps/api-keys.ts +157 -0
  314. package/src/admin/apps/scopes.test.ts +52 -0
  315. package/src/admin/apps/scopes.ts +47 -0
  316. package/src/admin/apps/verified-tokens.test.ts +415 -0
  317. package/src/admin/apps/verified-tokens.ts +566 -0
  318. package/src/admin/index.ts +2 -0
  319. package/src/admin/ui/plugin.ts +135 -0
  320. package/src/admin/ui/src/components/Checkbox.tsx +22 -0
  321. package/src/admin/ui/src/components/Dialog.tsx +32 -0
  322. package/src/admin/ui/src/components/MobileProfile.tsx +31 -0
  323. package/src/admin/ui/src/components/Page.tsx +53 -0
  324. package/src/admin/ui/src/components/TempoLogo.tsx +37 -0
  325. package/src/admin/ui/src/fonts/Pilat-Demi.woff2 +0 -0
  326. package/src/admin/ui/src/fonts/Pilat-Regular.woff2 +0 -0
  327. package/src/admin/ui/src/lib/api-keys.ts +63 -0
  328. package/src/admin/ui/src/lib/client.ts +15 -0
  329. package/src/admin/ui/src/lib/config.ts +57 -0
  330. package/src/admin/ui/src/lib/query-client.ts +26 -0
  331. package/src/admin/ui/src/lib/scopes.ts +25 -0
  332. package/src/admin/ui/src/lib/verified-tokens.ts +89 -0
  333. package/src/admin/ui/src/lib/wagmi.ts +18 -0
  334. package/src/admin/ui/src/routeTree.gen.ts +104 -0
  335. package/src/admin/ui/src/router.tsx +25 -0
  336. package/src/admin/ui/src/routes/__root.tsx +219 -0
  337. package/src/admin/ui/src/routes/api-keys.tsx +329 -0
  338. package/src/admin/ui/src/routes/index.tsx +7 -0
  339. package/src/admin/ui/src/routes/verified-tokens.tsx +331 -0
  340. package/src/admin/ui/src/styles.css +20 -0
  341. package/src/admin/ui/tsconfig.json +16 -0
  342. package/src/admin/ui/vite.config.ts +29 -0
  343. package/src/admin/ui.generated.ts +60 -0
  344. package/src/apps/activities.test.ts +1020 -0
  345. package/src/apps/activities.ts +3934 -0
  346. package/src/apps/addresses.ts +24 -0
  347. package/src/apps/balances.test.ts +274 -0
  348. package/src/apps/balances.ts +533 -0
  349. package/src/apps/blocks.test.ts +385 -0
  350. package/src/apps/blocks.ts +896 -0
  351. package/src/apps/coingecko.test.ts +218 -0
  352. package/src/apps/coingecko.ts +1383 -0
  353. package/src/apps/exchanges.test.ts +958 -0
  354. package/src/apps/exchanges.ts +4773 -0
  355. package/src/apps/fee-amm.test.ts +321 -0
  356. package/src/apps/fee-amm.ts +1121 -0
  357. package/src/apps/indexer.test.ts +153 -0
  358. package/src/apps/indexer.ts +330 -0
  359. package/src/apps/receipts.test.ts +285 -0
  360. package/src/apps/receipts.ts +1193 -0
  361. package/src/apps/rpc.test.ts +302 -0
  362. package/src/apps/rpc.ts +222 -0
  363. package/src/apps/tokenlist.test.ts +80 -0
  364. package/src/apps/tokenlist.ts +207 -0
  365. package/src/apps/tokens.test.ts +1062 -0
  366. package/src/apps/tokens.ts +2292 -0
  367. package/src/apps/transactions.test.ts +408 -0
  368. package/src/apps/transactions.ts +1363 -0
  369. package/src/apps/transfers.test.ts +371 -0
  370. package/src/apps/transfers.ts +1116 -0
  371. package/src/apps/verified-tokens.test.ts +114 -0
  372. package/src/apps/verified-tokens.ts +295 -0
  373. package/src/apps/webhooks.test.ts +1937 -0
  374. package/src/apps/webhooks.ts +2926 -0
  375. package/src/cloudflare.ts +55 -0
  376. package/src/index.ts +15 -0
  377. package/src/internal/Auth.test.ts +980 -0
  378. package/src/internal/Auth.ts +1012 -0
  379. package/src/internal/Cache.test.ts +240 -0
  380. package/src/internal/Cache.ts +262 -0
  381. package/src/internal/Cursor.test.ts +100 -0
  382. package/src/internal/Cursor.ts +149 -0
  383. package/src/internal/EdgeCache.test.ts +234 -0
  384. package/src/internal/EdgeCache.ts +224 -0
  385. package/src/internal/Errors.ts +14 -0
  386. package/src/internal/Log.test.ts +244 -0
  387. package/src/internal/Log.ts +216 -0
  388. package/src/internal/MetricSink.ts +220 -0
  389. package/src/internal/Mpp.test.ts +64 -0
  390. package/src/internal/Mpp.ts +141 -0
  391. package/src/internal/OpenApi.fonts.ts +38 -0
  392. package/src/internal/OpenApi.logo.ts +18 -0
  393. package/src/internal/OpenApi.ts +488 -0
  394. package/src/internal/Path.test.ts +14 -0
  395. package/src/internal/Path.ts +15 -0
  396. package/src/internal/RateLimit.test.ts +93 -0
  397. package/src/internal/RateLimit.ts +93 -0
  398. package/src/internal/Response.ts +131 -0
  399. package/src/internal/Schema.test.ts +34 -0
  400. package/src/internal/Schema.ts +523 -0
  401. package/src/internal/Store.test.ts +545 -0
  402. package/src/internal/Store.ts +526 -0
  403. package/src/internal/Tidx.local.test.ts +33 -0
  404. package/src/internal/Tidx.test.ts +294 -0
  405. package/src/internal/Tidx.ts +238 -0
  406. package/src/internal/Timing.test.ts +22 -0
  407. package/src/internal/Timing.ts +62 -0
  408. package/src/internal/Ttl.test.ts +18 -0
  409. package/src/internal/Ttl.ts +59 -0
  410. package/src/internal/Value.ts +114 -0
  411. package/src/internal/VerifiedTokens.test.ts +333 -0
  412. package/src/internal/VerifiedTokens.ts +645 -0
  413. package/src/internal/Viem.test.ts +115 -0
  414. package/src/internal/Viem.ts +165 -0
  415. package/src/internal/WebhookDestination.test.ts +648 -0
  416. package/src/internal/WebhookDestination.ts +331 -0
  417. package/src/internal/Webhooks.test.ts +668 -0
  418. package/src/internal/Webhooks.ts +940 -0
  419. package/src/internal/webhookDestinations/betterStack.ts +91 -0
  420. package/src/internal/webhookDestinations/slack.ts +496 -0
  421. package/src/internal/webhookDestinations/url.ts +128 -0
  422. package/src/openrpc.json +5302 -0
@@ -0,0 +1,1937 @@
1
+ import { Store, Webhooks as Core } from 'cadent'
2
+
3
+ import * as Tidx from '../internal/Tidx.js'
4
+ import * as Viem from '../internal/Viem.js'
5
+ import * as TestApp from '../../test/App.js'
6
+ import * as Mppx from '../../test/Mppx.js'
7
+ import * as Runtime from '../../test/runtime.js'
8
+ import * as Tempo from '../../test/Tempo.js'
9
+ import * as Webhooks from './webhooks.js'
10
+
11
+ /** API key with both webhook scopes, owned by `org_test`. */
12
+ const writer = {
13
+ id: 'key_writer',
14
+ orgId: 'org_test',
15
+ scopes: ['webhooks:read', 'webhooks:write'],
16
+ token: 'secret_writer',
17
+ } satisfies TestApp.source.Key
18
+
19
+ /** API key with read-only webhook scope, owned by `org_test`. */
20
+ const reader = {
21
+ id: 'key_reader',
22
+ orgId: 'org_test',
23
+ scopes: ['webhooks:read'],
24
+ token: 'secret_reader',
25
+ } satisfies TestApp.source.Key
26
+
27
+ /** API key in a different org, used to prove owner isolation. */
28
+ const other = {
29
+ id: 'key_other',
30
+ orgId: 'org_other',
31
+ scopes: ['webhooks:read', 'webhooks:write'],
32
+ token: 'secret_other',
33
+ } satisfies TestApp.source.Key
34
+
35
+ /** Creates a webhooks-enabled test client backed by an in-memory state store. */
36
+ function client(keys: readonly TestApp.source.Key[] = [writer, reader, other]) {
37
+ return TestApp.client({
38
+ auth: { source: TestApp.source({ keys }) },
39
+ webhook: { store: Store.memory() },
40
+ })
41
+ }
42
+
43
+ /** RequestInit carrying a bearer token for the given key. */
44
+ function as(key: { token: string }) {
45
+ return { headers: { authorization: `Bearer ${key.token}` } } as const
46
+ }
47
+
48
+ /** Minimal valid creation payload. */
49
+ const validBody = {
50
+ destination: { type: 'url', url: 'https://example.com/hook' },
51
+ eventType: 'token:transfer',
52
+ } as const
53
+
54
+ describe('feature gate', () => {
55
+ test('returns 404 when no state store is configured', async () => {
56
+ // Routes stay mounted for a stable schema, but the resource is disabled.
57
+ // `event-types` requires `data:read`, so seed a key that holds it to reach
58
+ // the feature gate rather than tripping the scope check first.
59
+ const dataReader = {
60
+ id: 'key_data_reader',
61
+ orgId: 'org_test',
62
+ scopes: ['data:read'],
63
+ token: 'secret_data_reader',
64
+ } satisfies TestApp.source.Key
65
+ const disabled = TestApp.client({ auth: { source: TestApp.source({ keys: [dataReader] }) } })
66
+ const response = await disabled.v1.webhooks['event-types'].$get({}, as(dataReader))
67
+
68
+ expect(response.status).toMatchInlineSnapshot(`404`)
69
+ const body = (await response.json()) as { error?: { code?: string } }
70
+ expect(body.error?.code).toMatchInlineSnapshot(`"webhooks_not_enabled"`)
71
+ })
72
+ })
73
+
74
+ describe('access matrix', () => {
75
+ test('rejects anonymous reads with 401 even when MPP is enabled (API-key-only)', async () => {
76
+ // Webhooks opt out of the paid (MPP) lane for now, so an anonymous caller on
77
+ // a `public: false` route (`GET /webhooks`) is rejected outright rather than
78
+ // offered the paid path — even though MPP is enabled app-wide by default.
79
+ const response = await client().v1.webhooks.$get({ query: {} })
80
+
81
+ expect(response.status).toMatchInlineSnapshot(`401`)
82
+ expect(response.headers.has('www-authenticate')).toMatchInlineSnapshot(`false`)
83
+ })
84
+
85
+ test('rejects an invalid key', async () => {
86
+ const response = await client().v1.webhooks.$get(
87
+ { query: {} },
88
+ { headers: { authorization: 'Bearer nope' } },
89
+ )
90
+
91
+ expect(response.status).toMatchInlineSnapshot(`401`)
92
+ })
93
+
94
+ test('rejects a key missing the read scope', async () => {
95
+ const noScope = {
96
+ id: 'key_noscope',
97
+ orgId: 'org_test',
98
+ scopes: ['data:read'],
99
+ token: 'secret_noscope',
100
+ } satisfies TestApp.source.Key
101
+ const response = await client([noScope]).v1.webhooks.$get({ query: {} }, as(noScope))
102
+ const body = (await response.json()) as { error?: { code?: string } }
103
+
104
+ expect(response.status).toMatchInlineSnapshot(`403`)
105
+ expect(body.error?.code).toMatchInlineSnapshot(`"api_key_forbidden"`)
106
+ })
107
+
108
+ test('rejects a read-only key on writes', async () => {
109
+ const response = await client().v1.webhooks.$post({ json: validBody }, as(reader))
110
+ const body = (await response.json()) as { error?: { code?: string } }
111
+
112
+ expect(response.status).toMatchInlineSnapshot(`403`)
113
+ expect(body.error?.code).toMatchInlineSnapshot(`"api_key_forbidden"`)
114
+ })
115
+
116
+ test('lists event types without authentication (public catalog)', async () => {
117
+ // `GET /webhooks/event-types` is intentionally public: anonymous callers are
118
+ // served within the public quota, no key or scope required.
119
+ const response = await client().v1.webhooks['event-types'].$get({})
120
+ const body = await TestApp.json(response, Webhooks.schema.getWebhookEventTypes.Response)
121
+
122
+ expect(response.status).toMatchInlineSnapshot(`200`)
123
+ // The catalog is static public data and must be cacheable (`stable`),
124
+ // overriding the resource-wide `no-store` default for subscriptions —
125
+ // without it every request pays the full auth/rate-limit floor.
126
+ expect(response.headers.get('cache-control')).toMatchInlineSnapshot(
127
+ `"private, max-age=300, stale-while-revalidate=3600"`,
128
+ )
129
+ expect(body.data.map((entry) => entry.type)).toMatchInlineSnapshot(`
130
+ [
131
+ "block:created",
132
+ "log:emitted",
133
+ "token:transfer",
134
+ "transaction:included",
135
+ ]
136
+ `)
137
+ })
138
+
139
+ test('lists event types for a key with the data:read scope', async () => {
140
+ const dataReader = {
141
+ id: 'key_data_reader',
142
+ orgId: 'org_test',
143
+ scopes: ['data:read'],
144
+ token: 'secret_data_reader',
145
+ } satisfies TestApp.source.Key
146
+ const response = await client([dataReader]).v1.webhooks['event-types'].$get({}, as(dataReader))
147
+
148
+ expect(response.status).toMatchInlineSnapshot(`200`)
149
+ })
150
+
151
+ test('rejects a key lacking the data:read scope', async () => {
152
+ const response = await client().v1.webhooks['event-types'].$get({}, as(reader))
153
+ const body = (await response.json()) as { error?: { code?: string } }
154
+
155
+ expect(response.status).toMatchInlineSnapshot(`403`)
156
+ expect(body.error?.code).toMatchInlineSnapshot(`"api_key_forbidden"`)
157
+ })
158
+ })
159
+
160
+ describe('CRUD lifecycle', () => {
161
+ test('creates, reads, lists, updates, and deletes a subscription', async () => {
162
+ const app = client()
163
+
164
+ // Create returns the one-time signing secret.
165
+ const created = await app.v1.webhooks.$post({ json: validBody }, as(writer))
166
+ const createdBody = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
167
+ expect(created.status).toMatchInlineSnapshot(`200`)
168
+ expect(createdBody.id.startsWith('wh_')).toBe(true)
169
+ expect(createdBody.secret.startsWith('whsec_')).toBe(true)
170
+ expect(createdBody.status).toMatchInlineSnapshot(`"active"`)
171
+ expect(createdBody.destination.url).toMatchInlineSnapshot(`"https://example.com/hook"`)
172
+ // API-key-owned subscriptions never expire.
173
+ expect(createdBody.expiresAt).toMatchInlineSnapshot(`undefined`)
174
+ expect(createdBody).not.toHaveProperty('owner')
175
+
176
+ // Reads never expose the secret.
177
+ const read = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get(
178
+ { param: { id: createdBody.id } },
179
+ as(writer),
180
+ )
181
+ const readBody = await TestApp.json(read, Webhooks.schema.getWebhook.Response)
182
+ expect(read.status).toMatchInlineSnapshot(`200`)
183
+ expect(readBody.id).toBe(createdBody.id)
184
+ expect(readBody).not.toHaveProperty('secret')
185
+ expect(read.headers.get('cache-control')).toMatchInlineSnapshot(`"no-store"`)
186
+
187
+ // List shows the subscription.
188
+ const listed = await app.v1.webhooks.$get({ query: {} }, as(writer))
189
+ const listedBody = await TestApp.json(listed, Webhooks.schema.listWebhooks.Response)
190
+ expect(listed.status).toMatchInlineSnapshot(`200`)
191
+ expect(listedBody.data.map((entry) => entry.id)).toContain(createdBody.id)
192
+
193
+ // Patch pauses it.
194
+ const patched = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$patch(
195
+ { json: { status: 'paused' }, param: { id: createdBody.id } },
196
+ as(writer),
197
+ )
198
+ const patchedBody = await TestApp.json(patched, Webhooks.schema.updateWebhook.Response)
199
+ expect(patched.status).toMatchInlineSnapshot(`200`)
200
+ expect(patchedBody.status).toMatchInlineSnapshot(`"paused"`)
201
+
202
+ // Delete removes it, after which reads 404.
203
+ const deleted = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$delete(
204
+ { param: { id: createdBody.id } },
205
+ as(writer),
206
+ )
207
+ const deletedBody = await TestApp.json(deleted, Webhooks.schema.deleteWebhook.Response)
208
+ expect(deleted.status).toMatchInlineSnapshot(`200`)
209
+ expect(deletedBody.id).toBe(createdBody.id)
210
+
211
+ const missing = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get(
212
+ { param: { id: createdBody.id } },
213
+ as(writer),
214
+ )
215
+ expect(missing.status).toMatchInlineSnapshot(`404`)
216
+ })
217
+
218
+ test('embeds the exact subscription total via include=totalCount', async () => {
219
+ const app = client()
220
+ // Two subscriptions under the writer's owner.
221
+ await app.v1.webhooks.$post({ json: validBody }, as(writer))
222
+ await app.v1.webhooks.$post({ json: validBody }, as(writer))
223
+
224
+ const listed = await app.v1.webhooks.$get(
225
+ { query: { include: 'totalCount', limit: '5' } },
226
+ as(writer),
227
+ )
228
+ const body = await TestApp.json(listed, Webhooks.schema.listWebhooks.Response)
229
+ expect(listed.status).toBe(200)
230
+ // The count is the owner total, exact.
231
+ expect(body.data.length).toBe(2)
232
+ expect(body.meta).toEqual({ totalCountCapped: false, totalCount: 2 })
233
+
234
+ // Without the include, `meta` is omitted.
235
+ const bare = await app.v1.webhooks.$get({ query: { limit: '5' } }, as(writer))
236
+ const bareBody = await TestApp.json(bare, Webhooks.schema.listWebhooks.Response)
237
+ expect(bareBody.meta).toBeUndefined()
238
+ })
239
+
240
+ test('a sibling key in the same org manages the org subscription', async () => {
241
+ const app = client()
242
+ const created = await app.v1.webhooks.$post({ json: validBody }, as(writer))
243
+ const { id } = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
244
+
245
+ // `reader` shares `org_test`, so it can read but not mutate (scope-gated).
246
+ const read = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get({ param: { id } }, as(reader))
247
+ expect(read.status).toMatchInlineSnapshot(`200`)
248
+ })
249
+ })
250
+
251
+ describe('owner isolation', () => {
252
+ test('a different org cannot see or mutate another org subscription', async () => {
253
+ const app = client()
254
+ const created = await app.v1.webhooks.$post({ json: validBody }, as(writer))
255
+ const { id } = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
256
+
257
+ const read = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get({ param: { id } }, as(other))
258
+ expect(read.status).toMatchInlineSnapshot(`404`)
259
+
260
+ const removed = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$delete(
261
+ { param: { id } },
262
+ as(other),
263
+ )
264
+ expect(removed.status).toMatchInlineSnapshot(`404`)
265
+
266
+ const listed = await app.v1.webhooks.$get({ query: {} }, as(other))
267
+ const listedBody = await TestApp.json(listed, Webhooks.schema.listWebhooks.Response)
268
+ expect(listedBody.data).toMatchInlineSnapshot(`[]`)
269
+ })
270
+ })
271
+
272
+ describe('subscription limits', () => {
273
+ test('rejects creation past the configured per-owner cap with 403 limit_exceeded', async () => {
274
+ const app = TestApp.client({
275
+ auth: { source: TestApp.source({ keys: [writer, other] }) },
276
+ webhook: { maxPerOwner: 2, store: Store.memory() },
277
+ })
278
+
279
+ // Fill the writer's quota.
280
+ for (let i = 0; i < 2; i++) {
281
+ const ok = await app.v1.webhooks.$post({ json: validBody }, as(writer))
282
+ expect(ok.status).toMatchInlineSnapshot(`200`)
283
+ }
284
+
285
+ // The third create is over the cap.
286
+ const over = await app.v1.webhooks.$post({ json: validBody }, as(writer))
287
+ expect(over.status).toMatchInlineSnapshot(`403`)
288
+ const body = (await over.json()) as { error?: { code?: string } }
289
+ expect(body.error?.code).toMatchInlineSnapshot(`"limit_exceeded"`)
290
+
291
+ // The cap is per-owner: a different org is unaffected.
292
+ const otherOrg = await app.v1.webhooks.$post({ json: validBody }, as(other))
293
+ expect(otherOrg.status).toMatchInlineSnapshot(`200`)
294
+ })
295
+ })
296
+
297
+ describe('delivery log', () => {
298
+ /** A delivery row with a lexically-sortable id, mirroring the core test helper. */
299
+ function row(subscriptionId: string, n: number): Core.Delivery {
300
+ return {
301
+ attempt: n,
302
+ createdAt: new Date(1_700_000_000_000 + n * 1_000).toISOString(),
303
+ envelope: {
304
+ chainId: 4217,
305
+ createdAt: new Date(1_700_000_000_000 + n * 1_000).toISOString(),
306
+ data: { amount: String(n) },
307
+ id: `evt_${n}`,
308
+ subscriptionId,
309
+ type: 'token:transfer',
310
+ },
311
+ eventId: `evt_${n}`,
312
+ id: `whd_${String(1_700_000_000_000 + n * 1_000).padStart(15, '0')}_${'0'.repeat(16)}`,
313
+ requestUrl: 'https://example.com/hook',
314
+ status: 'succeeded',
315
+ subscriptionId,
316
+ }
317
+ }
318
+
319
+ /** Seeds a subscription owned by `writer` plus `count` delivery rows. */
320
+ async function seed(store: ReturnType<typeof Store.memory>, count: number) {
321
+ const app = TestApp.client({
322
+ auth: { source: TestApp.source({ keys: [writer, reader, other] }) },
323
+ webhook: { store },
324
+ })
325
+ const created = await app.v1.webhooks.$post({ json: validBody }, as(writer))
326
+ const { id } = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
327
+ for (let n = 1; n <= count; n++) await Core.recordDelivery(store, row(id, n))
328
+ return { app, id }
329
+ }
330
+
331
+ test('lists a subscription deliveries newest-first', async () => {
332
+ const store = Store.memory()
333
+ const { app, id } = await seed(store, 3)
334
+
335
+ const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
336
+ { param: { id }, query: {} },
337
+ as(writer),
338
+ )
339
+ expect(response.status).toMatchInlineSnapshot(`200`)
340
+ const body = await TestApp.json(response, Webhooks.schema.listWebhookDeliveries.Response)
341
+ expect(body.data.map((d) => d.eventId)).toMatchInlineSnapshot(`
342
+ [
343
+ "evt_3",
344
+ "evt_2",
345
+ "evt_1",
346
+ ]
347
+ `)
348
+ expect(body.nextCursor).toMatchInlineSnapshot(`null`)
349
+ })
350
+
351
+ test('embeds the exact delivery total via include=totalCount', async () => {
352
+ const store = Store.memory()
353
+ const { app, id } = await seed(store, 8)
354
+
355
+ const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
356
+ { param: { id }, query: { include: 'totalCount', limit: '5' } },
357
+ as(writer),
358
+ )
359
+ const body = await TestApp.json(response, Webhooks.schema.listWebhookDeliveries.Response)
360
+ expect(response.status).toBe(200)
361
+ // The count is the full delivery total, independent of the page `limit`.
362
+ expect(body.data.length).toBe(5)
363
+ expect(body.meta).toEqual({ totalCountCapped: false, totalCount: 8 })
364
+
365
+ // Without the include, `meta` is omitted.
366
+ const bare = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
367
+ { param: { id }, query: {} },
368
+ as(writer),
369
+ )
370
+ const bareBody = await TestApp.json(bare, Webhooks.schema.listWebhookDeliveries.Response)
371
+ expect(bareBody.meta).toBeUndefined()
372
+ })
373
+
374
+ test('paginates with cursor and limit', async () => {
375
+ const store = Store.memory()
376
+ const { app, id } = await seed(store, 6)
377
+
378
+ const first = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
379
+ { param: { id }, query: { limit: '5' } },
380
+ as(writer),
381
+ )
382
+ const firstBody = await TestApp.json(first, Webhooks.schema.listWebhookDeliveries.Response)
383
+ expect(firstBody.data.map((d) => d.eventId)).toEqual([
384
+ 'evt_6',
385
+ 'evt_5',
386
+ 'evt_4',
387
+ 'evt_3',
388
+ 'evt_2',
389
+ ])
390
+
391
+ const second = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
392
+ { param: { id }, query: { cursor: firstBody.nextCursor!, limit: '5' } },
393
+ as(writer),
394
+ )
395
+ const secondBody = await TestApp.json(second, Webhooks.schema.listWebhookDeliveries.Response)
396
+ expect(secondBody.data.map((d) => d.eventId)).toEqual(['evt_1'])
397
+ })
398
+
399
+ test('serves a positional page via `page` (deterministic in-memory slice)', async () => {
400
+ const store = Store.memory()
401
+ const { app, id } = await seed(store, 6)
402
+
403
+ const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
404
+ { param: { id }, query: { limit: '5', page: '2' } },
405
+ as(writer),
406
+ )
407
+ const body = await TestApp.json(response, Webhooks.schema.listWebhookDeliveries.Response)
408
+ expect(response.status).toMatchInlineSnapshot(`200`)
409
+ // Newest-first feed of evt_6..evt_1; page 2 at limit 5 holds the last row.
410
+ expect(body.data.map((d) => d.eventId)).toEqual(['evt_1'])
411
+ })
412
+
413
+ test('rejects `page` combined with `cursor`', async () => {
414
+ const store = Store.memory()
415
+ const { app, id } = await seed(store, 6)
416
+
417
+ const first = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
418
+ { param: { id }, query: { limit: '5' } },
419
+ as(writer),
420
+ )
421
+ const firstBody = await TestApp.json(first, Webhooks.schema.listWebhookDeliveries.Response)
422
+
423
+ const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
424
+ { param: { id }, query: { cursor: firstBody.nextCursor!, page: '2' } },
425
+ as(writer),
426
+ )
427
+ expect(response.status).toMatchInlineSnapshot(`400`)
428
+ })
429
+
430
+ test('a read-only key can list deliveries', async () => {
431
+ const store = Store.memory()
432
+ const { app, id } = await seed(store, 1)
433
+
434
+ const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
435
+ { param: { id }, query: {} },
436
+ as(reader),
437
+ )
438
+ expect(response.status).toMatchInlineSnapshot(`200`)
439
+ })
440
+
441
+ test('a different org cannot read another org deliveries', async () => {
442
+ const store = Store.memory()
443
+ const { app, id } = await seed(store, 1)
444
+
445
+ const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
446
+ { param: { id }, query: {} },
447
+ as(other),
448
+ )
449
+ expect(response.status).toMatchInlineSnapshot(`404`)
450
+ })
451
+
452
+ test('404s for an unknown subscription', async () => {
453
+ const app = client()
454
+ const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
455
+ { param: { id: 'wh_missing' }, query: {} },
456
+ as(writer),
457
+ )
458
+ expect(response.status).toMatchInlineSnapshot(`404`)
459
+ })
460
+ })
461
+
462
+ describe('SSRF protection', () => {
463
+ test('rejects a non-https callback URL', async () => {
464
+ const response = await client().v1.webhooks.$post(
465
+ { json: { ...validBody, destination: { type: 'url', url: 'http://example.com/hook' } } },
466
+ as(writer),
467
+ )
468
+ const body = (await response.json()) as { error?: { code?: string } }
469
+
470
+ expect(response.status).toMatchInlineSnapshot(`400`)
471
+ expect(body.error?.code).toMatchInlineSnapshot(`"url_invalid"`)
472
+ })
473
+
474
+ test('rejects the cloud metadata host', async () => {
475
+ const response = await client().v1.webhooks.$post(
476
+ {
477
+ json: {
478
+ ...validBody,
479
+ destination: { type: 'url', url: 'https://169.254.169.254/latest/meta-data' },
480
+ },
481
+ },
482
+ as(writer),
483
+ )
484
+ const body = (await response.json()) as { error?: { code?: string } }
485
+
486
+ expect(response.status).toMatchInlineSnapshot(`400`)
487
+ expect(body.error?.code).toMatchInlineSnapshot(`"url_invalid"`)
488
+ })
489
+ })
490
+
491
+ describe('subscription context', () => {
492
+ test('round-trips context through create and read', async () => {
493
+ const app = TestApp.client({
494
+ auth: { source: TestApp.source({ keys: [writer] }) },
495
+ webhook: { store: Store.memory() },
496
+ })
497
+ const context = {
498
+ description: 'Notify #ops when a large USDC transfer settles.',
499
+ metadata: { env: 'prod', team: 'payments' },
500
+ title: 'Prod USDC large transfers',
501
+ }
502
+ const created = await app.v1.webhooks.$post(
503
+ {
504
+ json: {
505
+ context,
506
+ destination: { type: 'slack', url: 'https://hooks.slack.com/services/T000/B000/xxxx' },
507
+ eventType: 'token:transfer',
508
+ },
509
+ },
510
+ as(writer),
511
+ )
512
+ expect(created.status).toBe(200)
513
+ const body = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
514
+ expect(body.context).toEqual(context)
515
+
516
+ const read = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get(
517
+ { param: { id: body.id } },
518
+ as(writer),
519
+ )
520
+ const readBody = await TestApp.json(read, Webhooks.schema.getWebhook.Response)
521
+ expect(readBody.context).toEqual(context)
522
+ })
523
+
524
+ test('clears context with an explicit null patch', async () => {
525
+ const app = TestApp.client({
526
+ auth: { source: TestApp.source({ keys: [writer] }) },
527
+ webhook: { store: Store.memory() },
528
+ })
529
+ const created = await app.v1.webhooks.$post(
530
+ {
531
+ json: {
532
+ context: { title: 'Temporary' },
533
+ destination: { type: 'slack', url: 'https://hooks.slack.com/services/T000/B000/xxxx' },
534
+ eventType: 'token:transfer',
535
+ },
536
+ },
537
+ as(writer),
538
+ )
539
+ const { id } = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
540
+
541
+ const patched = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$patch(
542
+ { json: { context: null }, param: { id } },
543
+ as(writer),
544
+ )
545
+ const body = await TestApp.json(patched, Webhooks.schema.updateWebhook.Response)
546
+ expect(body.context).toBeUndefined()
547
+ })
548
+
549
+ test('rejects metadata that exceeds the entry limit', async () => {
550
+ const app = TestApp.client({
551
+ auth: { source: TestApp.source({ keys: [writer] }) },
552
+ webhook: { store: Store.memory() },
553
+ })
554
+ const metadata = Object.fromEntries(Array.from({ length: 21 }, (_, i) => [`key${i}`, 'value']))
555
+ const created = await app.v1.webhooks.$post(
556
+ {
557
+ json: {
558
+ context: { metadata },
559
+ destination: { type: 'slack', url: 'https://hooks.slack.com/services/T000/B000/xxxx' },
560
+ eventType: 'token:transfer',
561
+ },
562
+ },
563
+ as(writer),
564
+ )
565
+ expect(created.status).toBe(400)
566
+ })
567
+ })
568
+
569
+ describe('slack destination', () => {
570
+ test('creates a subscription with a slack incoming-webhook URL', async () => {
571
+ const app = TestApp.client({
572
+ auth: { source: TestApp.source({ keys: [writer] }) },
573
+ webhook: { store: Store.memory() },
574
+ })
575
+ const created = await app.v1.webhooks.$post(
576
+ {
577
+ json: {
578
+ destination: {
579
+ type: 'slack',
580
+ url: 'https://hooks.slack.com/services/T000/B000/xxxx',
581
+ },
582
+ eventType: 'token:transfer',
583
+ },
584
+ },
585
+ as(writer),
586
+ )
587
+ expect(created.status).toMatchInlineSnapshot(`200`)
588
+ const body = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
589
+ expect(body.destination).toMatchInlineSnapshot(`
590
+ {
591
+ "type": "slack",
592
+ "url": "https://hooks.slack.com/services/T000/B000/xxxx",
593
+ }
594
+ `)
595
+ })
596
+
597
+ test('rejects a slack destination whose host is not hooks.slack.com', async () => {
598
+ const app = TestApp.client({
599
+ auth: { source: TestApp.source({ keys: [writer] }) },
600
+ webhook: { store: Store.memory() },
601
+ })
602
+ const response = await app.v1.webhooks.$post(
603
+ {
604
+ json: {
605
+ destination: { type: 'slack', url: 'https://evil.example.com/hook' },
606
+ eventType: 'token:transfer',
607
+ },
608
+ },
609
+ as(writer),
610
+ )
611
+ const body = (await response.json()) as { error?: { code?: string } }
612
+
613
+ expect(response.status).toMatchInlineSnapshot(`400`)
614
+ expect(body.error?.code).toMatchInlineSnapshot(`"url_invalid"`)
615
+ })
616
+ })
617
+
618
+ describe('betterstack destination', () => {
619
+ test('creates a subscription with a Better Stack source', async () => {
620
+ const app = TestApp.client({
621
+ auth: { source: TestApp.source({ keys: [writer] }) },
622
+ webhook: { store: Store.memory() },
623
+ })
624
+ const created = await app.v1.webhooks.$post(
625
+ {
626
+ json: {
627
+ destination: {
628
+ token: 'src_token',
629
+ type: 'betterstack',
630
+ url: 'https://s1234567.eu-nbg-2.betterstackdata.com',
631
+ },
632
+ eventType: 'token:transfer',
633
+ },
634
+ },
635
+ as(writer),
636
+ )
637
+ expect(created.status).toMatchInlineSnapshot(`200`)
638
+ const body = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
639
+ expect(body.destination).toMatchInlineSnapshot(`
640
+ {
641
+ "token": "src_token",
642
+ "type": "betterstack",
643
+ "url": "https://s1234567.eu-nbg-2.betterstackdata.com",
644
+ }
645
+ `)
646
+ })
647
+
648
+ test('rejects a Better Stack destination whose host is not betterstackdata.com', async () => {
649
+ const app = TestApp.client({
650
+ auth: { source: TestApp.source({ keys: [writer] }) },
651
+ webhook: { store: Store.memory() },
652
+ })
653
+ const response = await app.v1.webhooks.$post(
654
+ {
655
+ json: {
656
+ destination: {
657
+ token: 'src_token',
658
+ type: 'betterstack',
659
+ url: 'https://evil.example.com/ingest',
660
+ },
661
+ eventType: 'token:transfer',
662
+ },
663
+ },
664
+ as(writer),
665
+ )
666
+ const body = (await response.json()) as { error?: { code?: string } }
667
+
668
+ expect(response.status).toMatchInlineSnapshot(`400`)
669
+ expect(body.error?.code).toMatchInlineSnapshot(`"url_invalid"`)
670
+ })
671
+ })
672
+
673
+ describe('ping', () => {
674
+ afterEach(() => {
675
+ vi.restoreAllMocks()
676
+ })
677
+
678
+ /** Seeds a webhooks-enabled client with a subscription owned by `writer`. */
679
+ async function seed() {
680
+ const app = TestApp.client({
681
+ auth: { source: TestApp.source({ keys: [writer, reader, other] }) },
682
+ webhook: { store: Store.memory() },
683
+ })
684
+ const created = await app.v1.webhooks.$post({ json: validBody }, as(writer))
685
+ const { id } = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
686
+ return { app, id }
687
+ }
688
+
689
+ test('delivers a synthetic ping and returns the result', async () => {
690
+ const { app, id } = await seed()
691
+
692
+ const calls: { signature: string | null }[] = []
693
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
694
+ calls.push({ signature: new Headers(init?.headers).get('tempo-signature') })
695
+ return new Response(null, { status: 200 })
696
+ })
697
+
698
+ const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].ping.$post(
699
+ { param: { id } },
700
+ as(writer),
701
+ )
702
+ expect(response.status).toMatchInlineSnapshot(`200`)
703
+ const body = await TestApp.json(response, Webhooks.schema.pingWebhook.Response)
704
+ expect(body.delivered).toMatchInlineSnapshot(`true`)
705
+ expect(body.responseStatus).toMatchInlineSnapshot(`200`)
706
+ expect(body.eventId).toMatch(/^evt_[0-9a-f]{64}$/)
707
+
708
+ // A single signed request reached the endpoint.
709
+ expect(calls).toHaveLength(1)
710
+ expect(calls[0]?.signature).toBeTruthy()
711
+
712
+ // The ping is observable in the delivery log under the same event id.
713
+ const deliveries = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
714
+ { param: { id }, query: {} },
715
+ as(writer),
716
+ )
717
+ const log = await TestApp.json(deliveries, Webhooks.schema.listWebhookDeliveries.Response)
718
+ expect(log.data).toHaveLength(1)
719
+ expect(log.data[0]?.eventId).toBe(body.eventId)
720
+ })
721
+
722
+ test('reports a failed delivery without disabling the subscription', async () => {
723
+ const { app, id } = await seed()
724
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 500 }))
725
+
726
+ const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].ping.$post(
727
+ { param: { id } },
728
+ as(writer),
729
+ )
730
+ const body = await TestApp.json(response, Webhooks.schema.pingWebhook.Response)
731
+ expect(body.delivered).toMatchInlineSnapshot(`false`)
732
+ expect(body.error).toMatchInlineSnapshot(`"non-2xx response (500)"`)
733
+
734
+ // A failed test ping must leave a healthy subscription active.
735
+ const read = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get({ param: { id } }, as(writer))
736
+ const subscription = await TestApp.json(read, Webhooks.schema.getWebhook.Response)
737
+ expect(subscription.status).toMatchInlineSnapshot(`"active"`)
738
+ expect(subscription.failureCount).toMatchInlineSnapshot(`0`)
739
+ })
740
+
741
+ test('a read-only key cannot ping', async () => {
742
+ const { app, id } = await seed()
743
+ const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].ping.$post(
744
+ { param: { id } },
745
+ as(reader),
746
+ )
747
+ expect(response.status).toMatchInlineSnapshot(`403`)
748
+ })
749
+
750
+ test('a different org cannot ping another org subscription', async () => {
751
+ const { app, id } = await seed()
752
+ const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].ping.$post(
753
+ { param: { id } },
754
+ as(other),
755
+ )
756
+ expect(response.status).toMatchInlineSnapshot(`404`)
757
+ })
758
+
759
+ test('404s for an unknown subscription', async () => {
760
+ const app = client()
761
+ const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].ping.$post(
762
+ { param: { id: 'wh_missing' } },
763
+ as(writer),
764
+ )
765
+ expect(response.status).toMatchInlineSnapshot(`404`)
766
+ })
767
+ })
768
+
769
+ describe('retry delivery', () => {
770
+ afterEach(() => {
771
+ vi.restoreAllMocks()
772
+ })
773
+
774
+ const deliveryId = 'whd_001700000000000_0000000000000000'
775
+
776
+ /** Seeds a webhooks client with a subscription plus one logged delivery to replay. */
777
+ async function seed() {
778
+ const store = Store.memory()
779
+ const app = TestApp.client({
780
+ auth: { source: TestApp.source({ keys: [writer, reader, other] }) },
781
+ webhook: { store },
782
+ })
783
+ const created = await app.v1.webhooks.$post({ json: validBody }, as(writer))
784
+ const { id } = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
785
+ await Core.recordDelivery(store, {
786
+ attempt: 1,
787
+ createdAt: new Date(1_700_000_000_000).toISOString(),
788
+ envelope: {
789
+ chainId: 4217,
790
+ createdAt: new Date(1_700_000_000_000).toISOString(),
791
+ data: { amount: '1' },
792
+ id: 'evt_seed',
793
+ subscriptionId: id,
794
+ type: 'token:transfer',
795
+ },
796
+ eventId: 'evt_seed',
797
+ id: deliveryId,
798
+ requestUrl: 'https://example.com/hook',
799
+ status: 'failed',
800
+ subscriptionId: id,
801
+ })
802
+ return { app, id, store }
803
+ }
804
+
805
+ test('replays the stored envelope and records a new attempt', async () => {
806
+ const { app, id, store } = await seed()
807
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 }))
808
+
809
+ const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries[
810
+ ':deliveryId'
811
+ ].retry.$post({ param: { deliveryId, id } }, as(writer))
812
+ expect(response.status).toMatchInlineSnapshot(`200`)
813
+ const body = await TestApp.json(response, Webhooks.schema.retryWebhookDelivery.Response)
814
+ expect(body.delivered).toMatchInlineSnapshot(`true`)
815
+ expect(body.eventId).toMatchInlineSnapshot(`"evt_seed"`)
816
+
817
+ // A new attempt was appended alongside the seeded row.
818
+ expect(await Core.listDeliveries(store, id)).toHaveLength(2)
819
+ })
820
+
821
+ test('a failed replay reports the error', async () => {
822
+ const { app, id } = await seed()
823
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 500 }))
824
+
825
+ const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries[
826
+ ':deliveryId'
827
+ ].retry.$post({ param: { deliveryId, id } }, as(writer))
828
+ const body = await TestApp.json(response, Webhooks.schema.retryWebhookDelivery.Response)
829
+ expect(body.delivered).toMatchInlineSnapshot(`false`)
830
+ expect(body.error).toMatchInlineSnapshot(`"non-2xx response (500)"`)
831
+ })
832
+
833
+ test('a read-only key cannot retry', async () => {
834
+ const { app, id } = await seed()
835
+ const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries[
836
+ ':deliveryId'
837
+ ].retry.$post({ param: { deliveryId, id } }, as(reader))
838
+ expect(response.status).toMatchInlineSnapshot(`403`)
839
+ })
840
+
841
+ test('a different org cannot retry another org delivery', async () => {
842
+ const { app, id } = await seed()
843
+ const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries[
844
+ ':deliveryId'
845
+ ].retry.$post({ param: { deliveryId, id } }, as(other))
846
+ expect(response.status).toMatchInlineSnapshot(`404`)
847
+ })
848
+
849
+ test('404s with delivery_not_found for an unknown delivery', async () => {
850
+ const { app, id } = await seed()
851
+ const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries[
852
+ ':deliveryId'
853
+ ].retry.$post({ param: { deliveryId: 'whd_missing', id } }, as(writer))
854
+ expect(response.status).toMatchInlineSnapshot(`404`)
855
+ const body = (await response.json()) as { error?: { code?: string } }
856
+ expect(body.error?.code).toMatchInlineSnapshot(`"delivery_not_found"`)
857
+ })
858
+
859
+ test('404s for an unknown subscription', async () => {
860
+ const app = client()
861
+ const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries[
862
+ ':deliveryId'
863
+ ].retry.$post({ param: { deliveryId: 'whd_x', id: 'wh_missing' } }, as(writer))
864
+ expect(response.status).toMatchInlineSnapshot(`404`)
865
+ })
866
+ })
867
+
868
+ describe('OpenAPI visibility', () => {
869
+ test('hides webhook routes when the feature is disabled', async () => {
870
+ const app = TestApp.create({ auth: { source: TestApp.source({ keys: [writer] }) } })
871
+ const body = (await (await app.request('/openapi.json')).json()) as { paths: object }
872
+
873
+ // OpenAPI paths include App.create's mounted `/v1` prefix; the webhook
874
+ // sub-app's local `/webhooks` paths are not emitted prefix-free.
875
+ expect(Object.keys(body.paths).some((path) => path.startsWith('/v1/webhooks'))).toBe(false)
876
+ })
877
+
878
+ test('documents webhook routes when the feature is enabled', async () => {
879
+ const app = TestApp.create({
880
+ auth: { source: TestApp.source({ keys: [writer] }) },
881
+ webhook: { store: Store.memory() },
882
+ })
883
+ const body = (await (await app.request('/openapi.json')).json()) as { paths: object }
884
+ const paths = Object.keys(body.paths)
885
+ .filter((path) => path.startsWith('/v1/webhooks'))
886
+ .sort()
887
+
888
+ expect(paths).toMatchInlineSnapshot(`
889
+ [
890
+ "/v1/webhooks",
891
+ "/v1/webhooks/event-types",
892
+ "/v1/webhooks/{id}",
893
+ "/v1/webhooks/{id}/deliveries",
894
+ "/v1/webhooks/{id}/deliveries/{deliveryId}/retry",
895
+ "/v1/webhooks/{id}/ping",
896
+ ]
897
+ `)
898
+ })
899
+
900
+ test('omits the outbound `webhooks` section when the feature is disabled', async () => {
901
+ const app = TestApp.create({ auth: { source: TestApp.source({ keys: [writer] }) } })
902
+ const body = (await (await app.request('/openapi.json')).json()) as { webhooks?: object }
903
+
904
+ expect(body.webhooks).toMatchInlineSnapshot(`undefined`)
905
+ })
906
+
907
+ test('documents the outbound delivery envelope under OAS `webhooks` when enabled', async () => {
908
+ const app = TestApp.create({
909
+ auth: { source: TestApp.source({ keys: [writer] }) },
910
+ webhook: { store: Store.memory() },
911
+ })
912
+ const body = (await (await app.request('/openapi.json')).json()) as {
913
+ webhooks?: {
914
+ event?: {
915
+ post?: {
916
+ parameters?: { name: string }[]
917
+ requestBody?: {
918
+ content?: { 'application/json'?: { schema?: { oneOf?: { title?: string }[] } } }
919
+ }
920
+ }
921
+ }
922
+ }
923
+ }
924
+
925
+ expect(body.webhooks?.event?.post?.parameters?.map((p) => p.name)).toMatchInlineSnapshot(`
926
+ [
927
+ "tempo-signature",
928
+ "tempo-event-id",
929
+ "tempo-event-type",
930
+ ]
931
+ `)
932
+ // Each envelope variant carries a `title` so Scalar renders a labelled
933
+ // variant selector rather than collapsing the union to its first member.
934
+ const variants =
935
+ body.webhooks?.event?.post?.requestBody?.content?.['application/json']?.schema?.oneOf
936
+ expect(variants?.map((v) => v.title)).toMatchInlineSnapshot(`
937
+ [
938
+ "Transfer event",
939
+ "Included transaction event",
940
+ "Log event",
941
+ "Block event",
942
+ "Ping (test delivery)",
943
+ ]
944
+ `)
945
+ })
946
+
947
+ test('documents per-event-type filters on the create body', async () => {
948
+ const app = TestApp.create({
949
+ auth: { source: TestApp.source({ keys: [writer] }) },
950
+ webhook: { store: Store.memory() },
951
+ })
952
+ const body = (await (await app.request('/openapi.json')).json()) as {
953
+ paths: {
954
+ '/v1/webhooks': {
955
+ post: {
956
+ requestBody: {
957
+ content: {
958
+ 'application/json': {
959
+ schema: {
960
+ oneOf?: {
961
+ title?: string
962
+ properties?: { filters?: { properties?: Record<string, unknown> } }
963
+ }[]
964
+ }
965
+ }
966
+ }
967
+ }
968
+ }
969
+ }
970
+ }
971
+ }
972
+ const variants =
973
+ body.paths['/v1/webhooks'].post.requestBody.content['application/json'].schema.oneOf
974
+
975
+ // Each event type renders as a titled variant exposing only its own filters.
976
+ expect(
977
+ variants?.map((v) => ({
978
+ filters: Object.keys(v.properties?.filters?.properties ?? {}),
979
+ title: v.title,
980
+ })),
981
+ ).toMatchInlineSnapshot(`
982
+ [
983
+ {
984
+ "filters": [
985
+ "address",
986
+ "recipient",
987
+ "sender",
988
+ "token",
989
+ ],
990
+ "title": "Transfer subscription",
991
+ },
992
+ {
993
+ "filters": [
994
+ "hash",
995
+ "address",
996
+ "from",
997
+ "to",
998
+ "includeCalls",
999
+ "includeReceipt",
1000
+ "value",
1001
+ "input",
1002
+ "calls",
1003
+ "callCount",
1004
+ "txType",
1005
+ "feeToken",
1006
+ "feePayer",
1007
+ "gasLimit",
1008
+ "gasUsed",
1009
+ "maxFeePerGas",
1010
+ "maxPriorityFeePerGas",
1011
+ "nonce",
1012
+ "nonceKey",
1013
+ "validBefore",
1014
+ "validAfter",
1015
+ "blockNumber",
1016
+ "timestamp",
1017
+ "status",
1018
+ ],
1019
+ "title": "Included-transaction subscription",
1020
+ },
1021
+ {
1022
+ "filters": [
1023
+ "address",
1024
+ "signature",
1025
+ "topic0",
1026
+ "topic1",
1027
+ "topic2",
1028
+ "topic3",
1029
+ "args",
1030
+ "blockNumber",
1031
+ ],
1032
+ "title": "Log subscription",
1033
+ },
1034
+ {
1035
+ "filters": [
1036
+ "number",
1037
+ "miner",
1038
+ "proposer",
1039
+ "gasUsed",
1040
+ "gasLimit",
1041
+ "timestamp",
1042
+ ],
1043
+ "title": "Block subscription",
1044
+ },
1045
+ ]
1046
+ `)
1047
+ })
1048
+ })
1049
+
1050
+ describe('envelope schema', () => {
1051
+ /** A `transfer` subscription used to build representative envelopes. */
1052
+ async function transferSubscription() {
1053
+ const store = Store.memory()
1054
+ return Core.buildEnvelope({
1055
+ blockNumber: 100,
1056
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
1057
+ data: {
1058
+ address: '0x20c0000000000000000000008f5425160ebe5525',
1059
+ amount: '1000',
1060
+ blockNumber: 100,
1061
+ recipient: '0x2222222222222222222222222222222222222222',
1062
+ sender: '0x1111111111111111111111111111111111111111',
1063
+ timestamp: '2026-01-01T00:00:00.000Z',
1064
+ transactionHash: '0x33333333333333333333333333333333333333333333333333333333333333aa',
1065
+ },
1066
+ logIndex: 0,
1067
+ subscription: await Core.createSubscription(store, {
1068
+ chainId: 42431,
1069
+ eventType: 'token:transfer',
1070
+ owner: { orgId: 'org_test', type: 'api_key' },
1071
+ destination: { type: 'url', url: 'https://hooks.example.com/x' },
1072
+ }),
1073
+ })
1074
+ }
1075
+
1076
+ test('round-trips a delivered transfer envelope', async () => {
1077
+ const parsed = Webhooks.schema.Envelope.safeParse(await transferSubscription())
1078
+
1079
+ expect(parsed.success).toMatchInlineSnapshot(`true`)
1080
+ })
1081
+
1082
+ test('round-trips a ping envelope', async () => {
1083
+ const store = Store.memory()
1084
+ const subscription = await Core.createSubscription(store, {
1085
+ chainId: 42431,
1086
+ eventType: 'token:transfer',
1087
+ owner: { orgId: 'org_test', type: 'api_key' },
1088
+ destination: { type: 'url', url: 'https://hooks.example.com/x' },
1089
+ })
1090
+ const parsed = Webhooks.schema.Envelope.safeParse(Core.buildPingEnvelope(subscription))
1091
+
1092
+ expect(parsed.success).toMatchInlineSnapshot(`true`)
1093
+ expect(parsed.success && parsed.data.type).toMatchInlineSnapshot(`"ping"`)
1094
+ })
1095
+
1096
+ test('rejects an unknown envelope type', () => {
1097
+ const parsed = Webhooks.schema.Envelope.safeParse({
1098
+ chainId: 42431,
1099
+ createdAt: '2026-01-01T00:00:00.000Z',
1100
+ data: {},
1101
+ id: 'evt_x',
1102
+ subscriptionId: 'wh_x',
1103
+ type: 'unknown',
1104
+ })
1105
+
1106
+ expect(parsed.success).toMatchInlineSnapshot(`false`)
1107
+ })
1108
+ })
1109
+
1110
+ describe('MPP access (disabled for now — API-key-only)', () => {
1111
+ test('rejects an MPP payer with 401, offering no payment challenge', async () => {
1112
+ const app = TestApp.create({
1113
+ auth: { source: TestApp.source({ keys: [] }) },
1114
+ mpp: {
1115
+ charge: {
1116
+ chainId: Tempo.chain.id,
1117
+ currency: Tempo.currency,
1118
+ decimals: 6,
1119
+ getClient: () => Tempo.client,
1120
+ recipient: Tempo.accounts[2].address,
1121
+ },
1122
+ secretKey: 'secret_test_key_0123456789abcdef',
1123
+ },
1124
+ webhook: { store: Store.memory() },
1125
+ })
1126
+
1127
+ // The paid lane is disabled on webhook routes, so no `402` challenge is
1128
+ // offered: the MPP client's request is rejected outright (`401`) and never
1129
+ // gets a chance to pay, so no payment receipt is issued.
1130
+ const response = await Mppx.createClient(app).fetch('http://tempo-api.test/v1/webhooks', {
1131
+ body: JSON.stringify(validBody),
1132
+ headers: { 'content-type': 'application/json' },
1133
+ method: 'POST',
1134
+ })
1135
+
1136
+ expect(response.status).toMatchInlineSnapshot(`401`)
1137
+ expect(response.headers.has('payment-receipt')).toMatchInlineSnapshot(`false`)
1138
+ })
1139
+ })
1140
+
1141
+ // The poll tests pin a FIXED immutable historical window on testnet chain 42431
1142
+ // (real TIDX + `rpc.testnet.tempo.xyz`). Under `localnet`, the test RPC client is
1143
+ // redirected to the disposable local chain, which lacks that history, so the
1144
+ // whole block is testnet-only.
1145
+ describe.skipIf(Runtime.get().mode === 'localnet')('poll', () => {
1146
+ /** Decodes the opaque `(block, index)` keyset cursor the poller stores. */
1147
+ function decodeCursor(token: string | null): [number, number] | null {
1148
+ if (!token) return null
1149
+ const json = atob(token.replaceAll('-', '+').replaceAll('_', '/'))
1150
+ return JSON.parse(json) as [number, number]
1151
+ }
1152
+
1153
+ /** Encodes a `(block, index)` keyset cursor in the poller's opaque format. */
1154
+ function encodeCursor(values: [number, number]): string {
1155
+ return btoa(JSON.stringify(values)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
1156
+ }
1157
+
1158
+ /** End-of-block index sentinel (matches the poller's own cursor sentinel). */
1159
+ const endOfBlockIndex = 2_147_483_647
1160
+
1161
+ // The poller scans real TIDX, so the tests pin a FIXED historical block window
1162
+ // on chain 42431. Chain history is immutable, so a narrow `sender` filter over
1163
+ // this window yields a deterministic, snapshot-stable row set while exercising
1164
+ // the real keyset/`toBlock` SQL and decode path end to end — no fabricated rows.
1165
+ const realTidx = Tidx.getClient({
1166
+ chainId: 42431,
1167
+ tidx: { baseUrl: 'https://tidx.tempo.xyz', basicAuth: process.env.VITE_TIDX_BASIC_AUTH },
1168
+ })
1169
+ // The monitoring scanners (`log:emitted`/`block:created`/`transaction:included`)
1170
+ // read the same immutable historical window over real RPC (chain 42431 →
1171
+ // `rpc.testnet.tempo.xyz`), so their results are snapshot-stable too.
1172
+ const client = Viem.getClient({ chainId: 42431, rpc: TestApp.rpc })
1173
+ // Confirmed scan window `[windowFrom, windowTo]`: anchor the cursor just below
1174
+ // `windowFrom`, and pass `headBlockNumber = windowTo + confirmations` so the
1175
+ // poller's `safeBlock` lands exactly on `windowTo`.
1176
+ const windowFrom = 1_000_000
1177
+ const windowTo = 1_000_002
1178
+ const windowCursor = encodeCursor([windowFrom - 1, endOfBlockIndex])
1179
+ const windowHead = windowTo + 2
1180
+ // A sender whose only transfers in `[windowFrom, windowTo]` are exactly two
1181
+ // rows, both in block 1,000,002 (`log_idx` 0 then 1): a TIP-20 transfer
1182
+ // followed by its fee payment. Filtering on it makes the scan deterministic.
1183
+ const windowSender = '0xe7687128b0a808c2831ff94d4f7b2fb35c65af38'
1184
+
1185
+ async function createTransferSubscription(store: ReturnType<typeof Store.memory>) {
1186
+ return Core.createSubscription(store, {
1187
+ chainId: 42431,
1188
+ eventType: 'token:transfer',
1189
+ filters: { sender: windowSender },
1190
+ owner: { orgId: 'org_test', type: 'api_key' },
1191
+ destination: { type: 'url', url: 'https://hooks.example.com/x' },
1192
+ })
1193
+ }
1194
+
1195
+ test('dispatches a signed envelope per row and advances the cursor', async () => {
1196
+ const store = Store.memory()
1197
+ const subscription = await createTransferSubscription(store)
1198
+ // Anchor the subscription just below the pinned window so the poller scans
1199
+ // it this tick (a brand-new subscription anchors at head and skips its first
1200
+ // tick).
1201
+ await Core.setCursor(store, subscription.id, windowCursor)
1202
+
1203
+ const dispatched: Webhooks.poll.Dispatchable[] = []
1204
+ const result = await Webhooks.poll({
1205
+ chainId: 42431,
1206
+ dispatch: async (input) => {
1207
+ dispatched.push(input)
1208
+ return { ok: true }
1209
+ },
1210
+ eventTypes: ['token:transfer'],
1211
+ headBlockNumber: windowHead,
1212
+ client,
1213
+ store,
1214
+ tidx: realTidx,
1215
+ })
1216
+
1217
+ expect(result).toMatchInlineSnapshot(`
1218
+ {
1219
+ "chainId": 42431,
1220
+ "dispatched": 2,
1221
+ "failed": 0,
1222
+ "safeBlock": 1000002,
1223
+ "scanned": 2,
1224
+ "subscriptions": 1,
1225
+ }
1226
+ `)
1227
+ expect(dispatched).toHaveLength(2)
1228
+ // The envelope carries the read-endpoint transfer row verbatim — here the
1229
+ // first of the two pinned-window transfers from `windowSender`.
1230
+ expect(dispatched[0]?.envelope.data).toMatchInlineSnapshot(`
1231
+ {
1232
+ "address": "0x20c00000000000000000000071de0cd31ab0d105",
1233
+ "amount": "10000",
1234
+ "blockNumber": 1000002,
1235
+ "logIndex": 0,
1236
+ "recipient": "0x9e39034aae71fb89f66061a2602eb6efec271754",
1237
+ "sender": "0xe7687128b0a808c2831ff94d4f7b2fb35c65af38",
1238
+ "timestamp": "2026-01-14T18:38:03.000Z",
1239
+ "token": {
1240
+ "decimals": 6,
1241
+ "symbol": "QKSUSD",
1242
+ },
1243
+ "transactionHash": "0x3d24a706cc2f6f4c96620bef1f61ddb23040ff77c22c8db42918c7c424bbf9d3",
1244
+ }
1245
+ `)
1246
+
1247
+ // Cursor advanced to the last dispatched row `(1000002, 1)`.
1248
+ expect(decodeCursor(await Core.getCursor(store, subscription.id))).toMatchInlineSnapshot(`
1249
+ [
1250
+ 1000002,
1251
+ 1,
1252
+ ]
1253
+ `)
1254
+ })
1255
+
1256
+ test('pauses the cursor at the last success when dispatch fails', async () => {
1257
+ const store = Store.memory()
1258
+ const subscription = await createTransferSubscription(store)
1259
+ await Core.setCursor(store, subscription.id, windowCursor)
1260
+
1261
+ let calls = 0
1262
+ const result = await Webhooks.poll({
1263
+ chainId: 42431,
1264
+ // Succeed on the first row, fail on the second.
1265
+ dispatch: async () => (++calls === 1 ? { ok: true } : { error: 'boom', ok: false }),
1266
+ eventTypes: ['token:transfer'],
1267
+ headBlockNumber: windowHead,
1268
+ client,
1269
+ store,
1270
+ tidx: realTidx,
1271
+ })
1272
+
1273
+ expect(result.dispatched).toMatchInlineSnapshot(`1`)
1274
+ expect(result.failed).toMatchInlineSnapshot(`1`)
1275
+
1276
+ // Cursor stops at the first (successful) row `(1000002, 0)`, so the failed
1277
+ // one re-runs on the next tick (at-least-once).
1278
+ expect(decodeCursor(await Core.getCursor(store, subscription.id))).toMatchInlineSnapshot(`
1279
+ [
1280
+ 1000002,
1281
+ 0,
1282
+ ]
1283
+ `)
1284
+ })
1285
+
1286
+ test('does not advance the cursor when the first row fails', async () => {
1287
+ const store = Store.memory()
1288
+ const subscription = await createTransferSubscription(store)
1289
+ await Core.setCursor(store, subscription.id, windowCursor)
1290
+
1291
+ const result = await Webhooks.poll({
1292
+ chainId: 42431,
1293
+ dispatch: async () => ({ error: 'unreachable', ok: false }),
1294
+ eventTypes: ['token:transfer'],
1295
+ headBlockNumber: windowHead,
1296
+ client,
1297
+ store,
1298
+ tidx: realTidx,
1299
+ })
1300
+
1301
+ expect(result.dispatched).toMatchInlineSnapshot(`0`)
1302
+ expect(result.failed).toMatchInlineSnapshot(`1`)
1303
+ // Nothing accepted → cursor untouched at the window anchor, so the row
1304
+ // retries next tick.
1305
+ expect(decodeCursor(await Core.getCursor(store, subscription.id))).toMatchInlineSnapshot(`
1306
+ [
1307
+ 999999,
1308
+ 2147483647,
1309
+ ]
1310
+ `)
1311
+ })
1312
+
1313
+ test('anchors a first-seen subscription at the confirmed head without backfilling', async () => {
1314
+ const store = Store.memory()
1315
+ const subscription = await createTransferSubscription(store)
1316
+
1317
+ const dispatched: Webhooks.poll.Dispatchable[] = []
1318
+ const result = await Webhooks.poll({
1319
+ chainId: 42431,
1320
+ dispatch: async (input) => {
1321
+ dispatched.push(input)
1322
+ return { ok: true }
1323
+ },
1324
+ eventTypes: ['token:transfer'],
1325
+ headBlockNumber: windowHead,
1326
+ client,
1327
+ store,
1328
+ // Rows exist in history, but a brand-new subscription must not replay them.
1329
+ tidx: realTidx,
1330
+ })
1331
+
1332
+ // Nothing scanned or dispatched on the first tick; the cursor is anchored at
1333
+ // the head of the confirmed window (`windowHead - confirmations` = 1000002).
1334
+ expect(result.scanned).toMatchInlineSnapshot(`0`)
1335
+ expect(dispatched).toHaveLength(0)
1336
+ expect(decodeCursor(await Core.getCursor(store, subscription.id))).toMatchInlineSnapshot(`
1337
+ [
1338
+ 1000002,
1339
+ 2147483647,
1340
+ ]
1341
+ `)
1342
+ })
1343
+
1344
+ test('advances to the end of the confirmed window on an empty scan', async () => {
1345
+ const store = Store.memory()
1346
+ const subscription = await createTransferSubscription(store)
1347
+ // Anchor just past the window's only matching block (1000002) and confirm up
1348
+ // to block 1000004 — a range `windowSender` has no transfers in, so the scan
1349
+ // is genuinely empty.
1350
+ await Core.setCursor(store, subscription.id, encodeCursor([windowTo, endOfBlockIndex]))
1351
+
1352
+ const result = await Webhooks.poll({
1353
+ chainId: 42431,
1354
+ dispatch: async () => ({ ok: true }),
1355
+ eventTypes: ['token:transfer'],
1356
+ headBlockNumber: windowTo + 4,
1357
+ client,
1358
+ store,
1359
+ tidx: realTidx,
1360
+ })
1361
+
1362
+ expect(result.scanned).toMatchInlineSnapshot(`0`)
1363
+
1364
+ // No rows, clean scan: cursor jumps past the end of the confirmed window
1365
+ // (`headBlockNumber - confirmations` = block 1000004) so it is not rescanned.
1366
+ expect(decodeCursor(await Core.getCursor(store, subscription.id))).toMatchInlineSnapshot(`
1367
+ [
1368
+ 1000004,
1369
+ 2147483647,
1370
+ ]
1371
+ `)
1372
+ })
1373
+
1374
+ // The two pinned-window rows are `Transfer(from, to, value)` logs. Their raw
1375
+ // topic0 (the `Transfer` selector) and the `from` address padded to a bytes32
1376
+ // topic let the raw-log path be filtered as deterministically as the decoded
1377
+ // path.
1378
+ const transferSignature =
1379
+ 'event Transfer(address indexed from, address indexed to, uint256 value)'
1380
+ const transferSelector = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
1381
+ const windowSenderTopic = `0x${'0'.repeat(24)}${windowSender.slice(2)}`
1382
+
1383
+ test('log:emitted decodes args from a signature-matched event view', async () => {
1384
+ const store = Store.memory()
1385
+ const subscription = await Core.createSubscription(store, {
1386
+ chainId: 42431,
1387
+ eventType: 'log:emitted',
1388
+ filters: { signature: transferSignature, args: { from: windowSender } },
1389
+ owner: { orgId: 'org_test', type: 'api_key' },
1390
+ destination: { type: 'url', url: 'https://hooks.example.com/x' },
1391
+ })
1392
+ await Core.setCursor(store, subscription.id, windowCursor)
1393
+
1394
+ const dispatched: Webhooks.poll.Dispatchable[] = []
1395
+ const result = await Webhooks.poll({
1396
+ chainId: 42431,
1397
+ dispatch: async (input) => {
1398
+ dispatched.push(input)
1399
+ return { ok: true }
1400
+ },
1401
+ eventTypes: ['log:emitted'],
1402
+ headBlockNumber: windowHead,
1403
+ client,
1404
+ store,
1405
+ tidx: realTidx,
1406
+ })
1407
+
1408
+ expect(result.scanned).toMatchInlineSnapshot(`2`)
1409
+ expect(dispatched[0]?.envelope.data).toMatchInlineSnapshot(`
1410
+ {
1411
+ "address": "0x20c00000000000000000000071de0cd31ab0d105",
1412
+ "args": {
1413
+ "from": "0xE7687128b0A808C2831ff94d4F7B2fb35C65aF38",
1414
+ "to": "0x9E39034aAE71fb89f66061a2602eb6eFeC271754",
1415
+ "value": "10000",
1416
+ },
1417
+ "blockNumber": 1000002,
1418
+ "data": "0x0000000000000000000000000000000000000000000000000000000000002710",
1419
+ "event": {
1420
+ "name": "Transfer",
1421
+ "signature": "event Transfer(address indexed from, address indexed to, uint256 value)",
1422
+ "topic0": "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
1423
+ },
1424
+ "logIndex": 0,
1425
+ "timestamp": "2026-01-14T18:38:03.000Z",
1426
+ "topics": [
1427
+ "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
1428
+ "0x000000000000000000000000e7687128b0a808c2831ff94d4f7b2fb35c65af38",
1429
+ "0x0000000000000000000000009e39034aae71fb89f66061a2602eb6efec271754",
1430
+ ],
1431
+ "transactionHash": "0x3d24a706cc2f6f4c96620bef1f61ddb23040ff77c22c8db42918c7c424bbf9d3",
1432
+ "transactionIndex": 0,
1433
+ }
1434
+ `)
1435
+ })
1436
+
1437
+ test('log:emitted scans raw logs by topic when no signature is given', async () => {
1438
+ const store = Store.memory()
1439
+ const subscription = await Core.createSubscription(store, {
1440
+ chainId: 42431,
1441
+ eventType: 'log:emitted',
1442
+ filters: { topic0: transferSelector, topic1: windowSenderTopic },
1443
+ owner: { orgId: 'org_test', type: 'api_key' },
1444
+ destination: { type: 'url', url: 'https://hooks.example.com/x' },
1445
+ })
1446
+ await Core.setCursor(store, subscription.id, windowCursor)
1447
+
1448
+ const dispatched: Webhooks.poll.Dispatchable[] = []
1449
+ const result = await Webhooks.poll({
1450
+ chainId: 42431,
1451
+ dispatch: async (input) => {
1452
+ dispatched.push(input)
1453
+ return { ok: true }
1454
+ },
1455
+ eventTypes: ['log:emitted'],
1456
+ headBlockNumber: windowHead,
1457
+ client,
1458
+ store,
1459
+ tidx: realTidx,
1460
+ })
1461
+
1462
+ expect(result.scanned).toMatchInlineSnapshot(`2`)
1463
+ expect(dispatched[0]?.envelope.data).toMatchInlineSnapshot(`
1464
+ {
1465
+ "address": "0x20c00000000000000000000071de0cd31ab0d105",
1466
+ "blockNumber": 1000002,
1467
+ "data": "0x0000000000000000000000000000000000000000000000000000000000002710",
1468
+ "logIndex": 0,
1469
+ "timestamp": "2026-01-14T18:38:03.000Z",
1470
+ "topics": [
1471
+ "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
1472
+ "0x000000000000000000000000e7687128b0a808c2831ff94d4f7b2fb35c65af38",
1473
+ "0x0000000000000000000000009e39034aae71fb89f66061a2602eb6efec271754",
1474
+ ],
1475
+ "transactionHash": "0x3d24a706cc2f6f4c96620bef1f61ddb23040ff77c22c8db42918c7c424bbf9d3",
1476
+ "transactionIndex": 0,
1477
+ }
1478
+ `)
1479
+ })
1480
+
1481
+ test('block:created scans new blocks and resolves transaction counts', async () => {
1482
+ const store = Store.memory()
1483
+ const subscription = await Core.createSubscription(store, {
1484
+ chainId: 42431,
1485
+ eventType: 'block:created',
1486
+ // Pin to a single block so the heartbeat feed is deterministic.
1487
+ filters: { number: { eq: `0x${windowTo.toString(16)}` } },
1488
+ owner: { orgId: 'org_test', type: 'api_key' },
1489
+ destination: { type: 'url', url: 'https://hooks.example.com/x' },
1490
+ })
1491
+ await Core.setCursor(store, subscription.id, windowCursor)
1492
+
1493
+ const dispatched: Webhooks.poll.Dispatchable[] = []
1494
+ const result = await Webhooks.poll({
1495
+ chainId: 42431,
1496
+ dispatch: async (input) => {
1497
+ dispatched.push(input)
1498
+ return { ok: true }
1499
+ },
1500
+ eventTypes: ['block:created'],
1501
+ headBlockNumber: windowHead,
1502
+ client,
1503
+ store,
1504
+ tidx: realTidx,
1505
+ })
1506
+
1507
+ expect(result.scanned).toMatchInlineSnapshot(`1`)
1508
+ expect(dispatched[0]?.envelope.data).toMatchInlineSnapshot(`
1509
+ {
1510
+ "gasLimit": 500000000,
1511
+ "gasUsed": 4340281,
1512
+ "hash": "0x3fe7d9e595f3d8215ec840c6ef55ac0bead58933e96fc2ead0f2aaf79715f45d",
1513
+ "miner": "0x0000000000000000000000000000000000000000",
1514
+ "number": 1000002,
1515
+ "parentHash": "0xa406dff9c51cc2abbef3a811f0499e99a568bf781019f129ab45dca1df0e43e5",
1516
+ "timestamp": "2026-01-14T18:38:03.000Z",
1517
+ "transactionCount": 52,
1518
+ }
1519
+ `)
1520
+ // The window scanner advances to the end of the fully-scanned block so the
1521
+ // next tick resumes at the following block.
1522
+ expect(decodeCursor(await Core.getCursor(store, subscription.id))).toMatchInlineSnapshot(`
1523
+ [
1524
+ 1000002,
1525
+ 2147483647,
1526
+ ]
1527
+ `)
1528
+ })
1529
+
1530
+ test('transaction:included filters on txs columns and inner calls', async () => {
1531
+ const store = Store.memory()
1532
+ const subscription = await Core.createSubscription(store, {
1533
+ chainId: 42431,
1534
+ eventType: 'transaction:included',
1535
+ // `from` + an `input` selector (ERC20 `transfer` = 0xa9059cbb) pins the
1536
+ // scan to the deterministic window tx.
1537
+ filters: { from: windowSender, input: { selector: '0xa9059cbb' } },
1538
+ owner: { orgId: 'org_test', type: 'api_key' },
1539
+ destination: { type: 'url', url: 'https://hooks.example.com/x' },
1540
+ })
1541
+ await Core.setCursor(store, subscription.id, windowCursor)
1542
+
1543
+ const dispatched: Webhooks.poll.Dispatchable[] = []
1544
+ const result = await Webhooks.poll({
1545
+ chainId: 42431,
1546
+ dispatch: async (input) => {
1547
+ dispatched.push(input)
1548
+ return { ok: true }
1549
+ },
1550
+ eventTypes: ['transaction:included'],
1551
+ headBlockNumber: windowHead,
1552
+ client,
1553
+ store,
1554
+ tidx: realTidx,
1555
+ })
1556
+
1557
+ expect(result.scanned).toMatchInlineSnapshot(`1`)
1558
+ const tx = dispatched[0]?.envelope.data as { hash: string; sender: string } | undefined
1559
+ expect(tx?.hash).toMatchInlineSnapshot(
1560
+ `"0x3d24a706cc2f6f4c96620bef1f61ddb23040ff77c22c8db42918c7c424bbf9d3"`,
1561
+ )
1562
+ expect(tx?.sender).toBe(windowSender)
1563
+ })
1564
+
1565
+ test('transaction:included routes a status filter through receipts', async () => {
1566
+ const store = Store.memory()
1567
+ const subscription = await Core.createSubscription(store, {
1568
+ chainId: 42431,
1569
+ eventType: 'transaction:included',
1570
+ filters: { from: windowSender, status: 'success' },
1571
+ owner: { orgId: 'org_test', type: 'api_key' },
1572
+ destination: { type: 'url', url: 'https://hooks.example.com/x' },
1573
+ })
1574
+ await Core.setCursor(store, subscription.id, windowCursor)
1575
+
1576
+ const result = await Webhooks.poll({
1577
+ chainId: 42431,
1578
+ dispatch: async () => ({ ok: true }),
1579
+ eventTypes: ['transaction:included'],
1580
+ headBlockNumber: windowHead,
1581
+ client,
1582
+ store,
1583
+ tidx: realTidx,
1584
+ })
1585
+
1586
+ // The window tx succeeded, so the status=success subquery keeps it.
1587
+ expect(result.scanned).toMatchInlineSnapshot(`1`)
1588
+ })
1589
+
1590
+ test('transaction:included embeds the receipt when includeReceipt is set', async () => {
1591
+ const store = Store.memory()
1592
+ const subscription = await Core.createSubscription(store, {
1593
+ chainId: 42431,
1594
+ eventType: 'transaction:included',
1595
+ // No receipt-touching filter — `includeReceipt` alone must trigger the
1596
+ // fetch and embed it in the delivered payload.
1597
+ filters: { from: windowSender, input: { selector: '0xa9059cbb' }, includeReceipt: true },
1598
+ owner: { orgId: 'org_test', type: 'api_key' },
1599
+ destination: { type: 'url', url: 'https://hooks.example.com/x' },
1600
+ })
1601
+ await Core.setCursor(store, subscription.id, windowCursor)
1602
+
1603
+ const dispatched: Webhooks.poll.Dispatchable[] = []
1604
+ const result = await Webhooks.poll({
1605
+ chainId: 42431,
1606
+ dispatch: async (input) => {
1607
+ dispatched.push(input)
1608
+ return { ok: true }
1609
+ },
1610
+ eventTypes: ['transaction:included'],
1611
+ headBlockNumber: windowHead,
1612
+ client,
1613
+ store,
1614
+ tidx: realTidx,
1615
+ })
1616
+
1617
+ expect(result.scanned).toMatchInlineSnapshot(`1`)
1618
+ const data = dispatched[0]?.envelope.data as
1619
+ | { hash: string; meta?: { receipt?: { status: string; transactionHash: string } } }
1620
+ | undefined
1621
+ // The receipt is embedded under `meta.receipt`, matching the read endpoint's
1622
+ // `include=receipt` shape, and refers to the same transaction.
1623
+ expect(data?.meta?.receipt?.status).toMatchInlineSnapshot(`"success"`)
1624
+ expect(data?.meta?.receipt?.transactionHash).toBe(data?.hash)
1625
+ })
1626
+
1627
+ // End-to-end through the *default* transport: `poll` with no injected
1628
+ // `dispatch` falls back to `deliverAndRecord`, which signs and POSTs via
1629
+ // `globalThis.fetch` and records the outcome. These stub the global fetch so a
1630
+ // seeded TIDX row reaches a captured local sink with no network.
1631
+ describe('default transport (deliverAndRecord)', () => {
1632
+ afterEach(() => {
1633
+ vi.restoreAllMocks()
1634
+ })
1635
+
1636
+ test('delivers a verifiable signed envelope and records success', async () => {
1637
+ const store = Store.memory()
1638
+ const subscription = await createTransferSubscription(store)
1639
+ await Core.setCursor(store, subscription.id, windowCursor)
1640
+
1641
+ // Real TIDX queries also go through `globalThis.fetch`, so the mock passes
1642
+ // non-webhook requests through to the real network and only captures the
1643
+ // delivery POST to the subscription URL.
1644
+ const realFetch = globalThis.fetch.bind(globalThis)
1645
+ const calls: { body: string; headers: Headers; url: string }[] = []
1646
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
1647
+ const url =
1648
+ typeof input === 'string' ? input : input instanceof Request ? input.url : input.href
1649
+ if (!url.startsWith(subscription.destination.url)) return realFetch(input, init)
1650
+ calls.push({
1651
+ body: typeof init?.body === 'string' ? init.body : '',
1652
+ headers: new Headers(init?.headers),
1653
+ url,
1654
+ })
1655
+ return new Response(null, { status: 200 })
1656
+ })
1657
+
1658
+ const result = await Webhooks.poll({
1659
+ // One row per tick keeps the delivery assertions focused on a single
1660
+ // signed envelope.
1661
+ batchSize: 1,
1662
+ chainId: 42431,
1663
+ eventTypes: ['token:transfer'],
1664
+ headBlockNumber: windowHead,
1665
+ client,
1666
+ store,
1667
+ tidx: realTidx,
1668
+ })
1669
+
1670
+ expect(result.dispatched).toBe(1)
1671
+ expect(calls).toHaveLength(1)
1672
+ expect(calls[0]?.url).toBe(subscription.destination.url)
1673
+
1674
+ // The captured body verifies against the subscription's signing secret.
1675
+ expect(
1676
+ Core.verify({
1677
+ body: calls[0]?.body ?? '',
1678
+ secret: subscription.secret,
1679
+ signature: calls[0]?.headers.get('tempo-signature') ?? '',
1680
+ }),
1681
+ ).toBe(true)
1682
+
1683
+ // The delivered envelope carries the matched transfer row and its type.
1684
+ const envelope = JSON.parse(calls[0]?.body ?? '{}') as Core.Envelope
1685
+ expect(envelope.type).toBe('token:transfer')
1686
+ expect(calls[0]?.headers.get('tempo-event-type')).toBe('token:transfer')
1687
+ expect((envelope.data as { blockNumber: number }).blockNumber).toBe(windowTo)
1688
+
1689
+ // Success resets the failure counter, stamps lastDeliveryAt, stays active.
1690
+ const after = await Core.getSubscription(store, subscription.owner, subscription.id)
1691
+ expect(after?.status).toBe('active')
1692
+ expect(after?.failureCount).toBe(0)
1693
+ expect(after?.lastDeliveryAt).toBeDefined()
1694
+ })
1695
+
1696
+ test('pauses on a failed delivery and re-delivers on the next tick', async () => {
1697
+ const store = Store.memory()
1698
+ const subscription = await createTransferSubscription(store)
1699
+ await Core.setCursor(store, subscription.id, windowCursor)
1700
+
1701
+ const realFetch = globalThis.fetch.bind(globalThis)
1702
+ let attempts = 0
1703
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
1704
+ const url =
1705
+ typeof input === 'string' ? input : input instanceof Request ? input.url : input.href
1706
+ if (!url.startsWith(subscription.destination.url)) return realFetch(input, init)
1707
+ attempts++
1708
+ return new Response(null, { status: attempts === 1 ? 500 : 200 })
1709
+ })
1710
+
1711
+ const tick = () =>
1712
+ Webhooks.poll({
1713
+ batchSize: 1,
1714
+ chainId: 42431,
1715
+ eventTypes: ['token:transfer'],
1716
+ headBlockNumber: windowHead,
1717
+ client,
1718
+ store,
1719
+ tidx: realTidx,
1720
+ })
1721
+
1722
+ const first = await tick()
1723
+ expect(first.failed).toBe(1)
1724
+ expect(first.dispatched).toBe(0)
1725
+ // Cursor paused at the prior position so the row re-delivers next tick.
1726
+ expect(decodeCursor(await Core.getCursor(store, subscription.id))).toEqual([
1727
+ windowFrom - 1,
1728
+ endOfBlockIndex,
1729
+ ])
1730
+
1731
+ const second = await tick()
1732
+ expect(second.dispatched).toBe(1)
1733
+ expect(attempts).toBe(2)
1734
+ // Now advanced past the redelivered row (the first row in block 1000002).
1735
+ expect(decodeCursor(await Core.getCursor(store, subscription.id))).toEqual([windowTo, 0])
1736
+ })
1737
+
1738
+ test('auto-disables a subscription after sustained failures', async () => {
1739
+ const store = Store.memory()
1740
+ const subscription = await createTransferSubscription(store)
1741
+ await Core.setCursor(store, subscription.id, windowCursor)
1742
+
1743
+ // Real TIDX queries pass through; only the webhook delivery fails (500).
1744
+ const realFetch = globalThis.fetch.bind(globalThis)
1745
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
1746
+ const url =
1747
+ typeof input === 'string' ? input : input instanceof Request ? input.url : input.href
1748
+ if (!url.startsWith(subscription.destination.url)) return realFetch(input, init)
1749
+ return new Response(null, { status: 500 })
1750
+ })
1751
+
1752
+ const tick = () =>
1753
+ Webhooks.poll({
1754
+ batchSize: 1,
1755
+ chainId: 42431,
1756
+ eventTypes: ['token:transfer'],
1757
+ headBlockNumber: windowHead,
1758
+ client,
1759
+ store,
1760
+ tidx: realTidx,
1761
+ })
1762
+
1763
+ // Each tick delivers once and fails; after `maxFailures` ticks the
1764
+ // subscription auto-disables and drops out of the poller's active index.
1765
+ for (let i = 0; i < Core.maxFailures; i++) await tick()
1766
+
1767
+ const disabled = await Core.getSubscription(store, subscription.owner, subscription.id)
1768
+ expect(disabled?.status).toBe('disabled')
1769
+ expect(disabled?.failureCount).toBe(Core.maxFailures)
1770
+
1771
+ // A subsequent tick visits no active subscriptions.
1772
+ const after = await tick()
1773
+ expect(after.subscriptions).toBe(0)
1774
+ })
1775
+ })
1776
+
1777
+ // Full loop through the public HTTP surface: create over the route, let the
1778
+ // poller detect a seeded TIDX row and deliver via the default transport, then
1779
+ // read the recorded outcome back through the deliveries route. Unlike the
1780
+ // `poll` tests above (which seed subscriptions via the core helper), this
1781
+ // proves the route-issued secret and id line up with what is delivered/logged.
1782
+ describe('end-to-end (routes + poller)', () => {
1783
+ test('create → poll → signed delivery → deliveries route records success', async () => {
1784
+ const store = Store.memory()
1785
+ const app = TestApp.client({
1786
+ auth: { source: TestApp.source({ keys: [writer] }) },
1787
+ webhook: { store },
1788
+ })
1789
+
1790
+ // 1. Create through the route; capture the one-time signing secret + id.
1791
+ // The `windowSender` filter pins the scan to the deterministic window rows.
1792
+ const created = await app.v1.webhooks.$post(
1793
+ { json: { ...validBody, chainId: 42431, filters: { sender: windowSender } } },
1794
+ as(writer),
1795
+ )
1796
+ expect(created.status).toBe(200)
1797
+ const { id, secret } = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
1798
+
1799
+ // Anchor the cursor just below the window so the poller scans this tick (a
1800
+ // brand-new subscription anchors at the confirmed head and skips its first
1801
+ // tick).
1802
+ await Core.setCursor(store, id, windowCursor)
1803
+
1804
+ // 2. A local delivery sink that captures the request and acks 200, injected
1805
+ // via `deliverAndRecord`'s `fetch` option (the SSRF guard blocks loopback,
1806
+ // so a real local server can't be a subscriber URL) — no global mock.
1807
+ const calls: { body: string; headers: Headers; url: string }[] = []
1808
+ const fetch = (async (input, init) => {
1809
+ calls.push({
1810
+ body: typeof init?.body === 'string' ? init.body : '',
1811
+ headers: new Headers(init?.headers),
1812
+ url: typeof input === 'string' ? input : '',
1813
+ })
1814
+ return new Response(null, { status: 200 })
1815
+ }) satisfies typeof globalThis.fetch
1816
+
1817
+ // 3. Poll a real TIDX transfer row; dispatch through the real
1818
+ // sign+deliver+record core with the injected transport. `batchSize: 1`
1819
+ // delivers exactly one envelope from the window.
1820
+ const result = await Webhooks.poll({
1821
+ batchSize: 1,
1822
+ chainId: 42431,
1823
+ dispatch: ({ envelope, subscription }) =>
1824
+ Core.deliverAndRecord(store, subscription, envelope, { fetch }),
1825
+ eventTypes: ['token:transfer'],
1826
+ headBlockNumber: windowHead,
1827
+ client,
1828
+ store,
1829
+ tidx: realTidx,
1830
+ })
1831
+ expect(result.dispatched).toBe(1)
1832
+
1833
+ // The subscriber received exactly one POST to its URL, signed verifiably
1834
+ // with the secret the route returned.
1835
+ expect(calls).toHaveLength(1)
1836
+ expect(calls[0]?.url).toBe(validBody.destination.url)
1837
+ expect(
1838
+ Core.verify({
1839
+ body: calls[0]?.body ?? '',
1840
+ secret,
1841
+ signature: calls[0]?.headers.get('tempo-signature') ?? '',
1842
+ }),
1843
+ ).toBe(true)
1844
+ const envelope = JSON.parse(calls[0]?.body ?? '{}') as Core.Envelope
1845
+ expect(envelope.type).toBe('token:transfer')
1846
+
1847
+ // 4. The deliveries route shows the recorded success for this event.
1848
+ const deliveries = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
1849
+ { param: { id }, query: {} },
1850
+ as(writer),
1851
+ )
1852
+ const log = await TestApp.json(deliveries, Webhooks.schema.listWebhookDeliveries.Response)
1853
+ expect(log.data).toHaveLength(1)
1854
+ expect(log.data[0]?.status).toBe('succeeded')
1855
+ expect(log.data[0]?.eventId).toBe(envelope.id)
1856
+ })
1857
+ })
1858
+ })
1859
+
1860
+ describe('matchesOperator', () => {
1861
+ const addr = '0x20c0000000000000000000008f5425160ebe5525'
1862
+ const addr2 = '0x1111111111111111111111111111111111111111'
1863
+
1864
+ test('bare value matches case-insensitively (string kind)', () => {
1865
+ expect(Webhooks.matchesOperator(addr.toUpperCase(), addr)).toBe(true)
1866
+ expect(Webhooks.matchesOperator(addr2, addr)).toBe(false)
1867
+ })
1868
+
1869
+ test('a missing value never matches (fail-closed)', () => {
1870
+ expect(Webhooks.matchesOperator(undefined, addr)).toBe(false)
1871
+ })
1872
+
1873
+ test('{ eq } matches equality', () => {
1874
+ expect(Webhooks.matchesOperator(addr, { eq: addr })).toBe(true)
1875
+ })
1876
+
1877
+ test('{ in } matches membership', () => {
1878
+ expect(Webhooks.matchesOperator(addr, { in: [addr2, addr] })).toBe(true)
1879
+ expect(Webhooks.matchesOperator(addr, { in: [addr2] })).toBe(false)
1880
+ })
1881
+
1882
+ test('{ not } matches inequality', () => {
1883
+ expect(Webhooks.matchesOperator(addr, { not: addr2 })).toBe(true)
1884
+ expect(Webhooks.matchesOperator(addr, { not: addr })).toBe(false)
1885
+ })
1886
+
1887
+ test('compare bare value matches numeric equality', () => {
1888
+ expect(Webhooks.matchesOperator('0x1a', '0x1a', 'number')).toBe(true)
1889
+ expect(Webhooks.matchesOperator('0x1b', '0x1a', 'number')).toBe(false)
1890
+ })
1891
+
1892
+ test('compare range AND-combines bounds', () => {
1893
+ const op = { gte: '0x1', lte: '0x100' }
1894
+ expect(Webhooks.matchesOperator('0x80', op, 'number')).toBe(true)
1895
+ expect(Webhooks.matchesOperator('0x200', op, 'number')).toBe(false)
1896
+ })
1897
+
1898
+ test('bytes selector matches the first four bytes', () => {
1899
+ expect(Webhooks.matchesOperator('0xa9059cbbdeadbeef', { selector: '0xa9059cbb' })).toBe(true)
1900
+ expect(Webhooks.matchesOperator('0x12345678deadbeef', { selector: '0xa9059cbb' })).toBe(false)
1901
+ })
1902
+
1903
+ test('bytes startsWith matches a hex prefix', () => {
1904
+ expect(Webhooks.matchesOperator('0xa9059cbbdeadbeef', { startsWith: '0xa9059cbb' })).toBe(true)
1905
+ expect(Webhooks.matchesOperator('0xdead', { startsWith: '0xa9059cbb' })).toBe(false)
1906
+ })
1907
+ })
1908
+
1909
+ describe('parseFilters', () => {
1910
+ test('returns validated filters for a known event type', () => {
1911
+ expect(
1912
+ Webhooks.parseFilters('token:transfer', {
1913
+ sender: '0x20c0000000000000000000008f5425160ebe5525',
1914
+ }),
1915
+ ).toMatchInlineSnapshot(`
1916
+ {
1917
+ "sender": "0x20c0000000000000000000008f5425160ebe5525",
1918
+ }
1919
+ `)
1920
+ })
1921
+
1922
+ test('treats undefined filters as the empty (match-all) set', () => {
1923
+ expect(Webhooks.parseFilters('token:transfer', undefined)).toMatchInlineSnapshot(`{}`)
1924
+ })
1925
+
1926
+ test('fails closed on an invalid filter shape', () => {
1927
+ let error: unknown
1928
+ try {
1929
+ Webhooks.parseFilters('token:transfer', { sender: 'not-an-address' })
1930
+ } catch (cause) {
1931
+ error = cause
1932
+ }
1933
+ expect(error).toBeInstanceOf(Core.InvalidFilterError)
1934
+ expect((error as Core.InvalidFilterError).eventType).toBe('token:transfer')
1935
+ expect((error as Core.InvalidFilterError).details.length).toBeGreaterThan(0)
1936
+ })
1937
+ })