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,2926 @@
1
+ import { type Context, Hono } from 'hono'
2
+ import { type AbiEvent, parseAbiItem } from 'viem'
3
+ import * as z from 'zod/mini'
4
+
5
+ import type * as App from '../App.js'
6
+ import * as Auth from '../internal/Auth.js'
7
+ import * as Cache from '../internal/Cache.js'
8
+ import * as Cursor from '../internal/Cursor.js'
9
+ import * as OpenApi from '../internal/OpenApi.js'
10
+ import * as Response from '../internal/Response.js'
11
+ import * as Schema from '../internal/Schema.js'
12
+ import * as Store from '../internal/Store.js'
13
+ import type * as Tidx from '../internal/Tidx.js'
14
+ import * as Ttl from '../internal/Ttl.js'
15
+ import * as Value from '../internal/Value.js'
16
+ import type * as Viem from '../internal/Viem.js'
17
+ import * as Webhooks from '../internal/Webhooks.js'
18
+ import * as Receipts from './receipts.js'
19
+ import * as Transactions from './transactions.js'
20
+ import * as Transfers from './transfers.js'
21
+
22
+ /** Default cap on active subscriptions per owner, overridable via `webhook.maxPerOwner`. */
23
+ const defaultMaxPerOwner = 100
24
+
25
+ /**
26
+ * Lifetime of an MPP-payer-owned subscription. A payer pays once, so the
27
+ * subscription is TTL-bounded (renewable by another paid create) to bound the
28
+ * paid-once / deliver-forever mismatch. API-key-owned subscriptions have no TTL.
29
+ */
30
+ const mppTtlMs = 30 * 24 * 60 * 60 * 1_000
31
+
32
+ /**
33
+ * The subscribable webhook event types, in alphabetical order. The source of
34
+ * truth for the event-type *set*: the {@link EventType} union and the route zod
35
+ * enum ({@link schema.EventType}) both derive from this.
36
+ */
37
+ export const eventTypes = [
38
+ 'block:created',
39
+ 'log:emitted',
40
+ 'token:transfer',
41
+ 'transaction:included',
42
+ ] as const
43
+
44
+ /** Event types a subscription can listen to. Derived from {@link eventTypes}. */
45
+ export type EventType = (typeof eventTypes)[number]
46
+
47
+ /** Subscribable event types surfaced by `GET /webhooks/event-types`. */
48
+ const eventTypeDescriptions = [
49
+ {
50
+ description:
51
+ 'A new block was added to the chain. Filter by producer, gas usage, or block number, or omit filters for a per-block heartbeat.',
52
+ type: 'block:created',
53
+ },
54
+ {
55
+ description:
56
+ 'Any contract event log, filtered by emitting address, event signature/topics, or decoded arguments.',
57
+ type: 'log:emitted',
58
+ },
59
+ {
60
+ description: 'TIP-20 token transfer event, such as a stablecoin payment sent or received.',
61
+ type: 'token:transfer',
62
+ },
63
+ {
64
+ description:
65
+ 'A transaction included in a block, filterable on every transaction field (sender, recipient, value, calldata, inner account-abstraction calls, fees, nonce) and its execution status.',
66
+ type: 'transaction:included',
67
+ },
68
+ ] as const satisfies readonly { description: string; type: EventType }[]
69
+
70
+ /** Zod schemas owned by the webhooks resource. */
71
+ export namespace schema {
72
+ /** Subscribable event type. */
73
+ export const EventType = z.enum(eventTypes).check(z.describe('Event type you can subscribe to.'))
74
+
75
+ /** Subscription lifecycle status. */
76
+ export const Status = z
77
+ .enum(['active', 'disabled', 'paused'])
78
+ .check(
79
+ z.describe('Current state of the webhook subscription.'),
80
+ z.meta({ examples: ['active'] }),
81
+ )
82
+
83
+ const Filters = z.record(z.string(), z.unknown())
84
+ const chainIdResponse = z
85
+ .number()
86
+ .check(
87
+ z.int(),
88
+ z.positive(),
89
+ z.describe('Tempo chain ID for this subscription.'),
90
+ z.meta({ examples: [4217] }),
91
+ )
92
+ const Url = z
93
+ .url()
94
+ .check(
95
+ z.describe('Your HTTPS endpoint where Tempo sends signed event POSTs.'),
96
+ z.meta({ examples: ['https://example.com/webhooks'] }),
97
+ )
98
+
99
+ const SlackUrl = z
100
+ .url()
101
+ .check(
102
+ z.describe('Your Slack incoming-webhook URL (`https://hooks.slack.com/services/…`).'),
103
+ z.meta({ examples: ['https://hooks.slack.com/services/T000/B000/XXXXXXXX'] }),
104
+ )
105
+
106
+ const BetterstackUrl = z
107
+ .url()
108
+ .check(
109
+ z.describe(
110
+ 'Your Better Stack source ingest host URL (`https://<host>.betterstackdata.com`).',
111
+ ),
112
+ z.meta({ examples: ['https://s1234567.eu-nbg-2.betterstackdata.com'] }),
113
+ )
114
+
115
+ const BetterstackToken = z
116
+ .string()
117
+ .check(
118
+ z.minLength(1),
119
+ z.describe('Your Better Stack source token (sent as `Authorization: Bearer …`).'),
120
+ z.meta({ examples: ['FczKcxEhjEDE58dBX7XaeX1q'] }),
121
+ )
122
+
123
+ /**
124
+ * Where matched events are delivered: a `url` destination (a signed event POST
125
+ * to your HTTPS endpoint), a `slack` destination (a formatted message posted to
126
+ * your Slack incoming-webhook URL), or a `betterstack` destination (a structured
127
+ * log event POSTed to a Better Stack source with a bearer source token).
128
+ */
129
+ const Destination = z
130
+ .discriminatedUnion('type', [
131
+ z.object({
132
+ type: z.literal('url'),
133
+ url: Url,
134
+ }),
135
+ z.object({
136
+ type: z.literal('slack'),
137
+ url: SlackUrl,
138
+ }),
139
+ z.object({
140
+ token: BetterstackToken,
141
+ type: z.literal('betterstack'),
142
+ url: BetterstackUrl,
143
+ }),
144
+ ])
145
+ .check(
146
+ z.describe(
147
+ 'Delivery destination: an HTTPS URL, a Slack incoming-webhook URL, or a Better Stack source.',
148
+ ),
149
+ )
150
+
151
+ /**
152
+ * Equality / membership operator for a filterable field. A consumer can pass a
153
+ * bare value (shorthand for exact equality, the common case) or an operator
154
+ * object: `{ eq }` exact, `{ in: [...] }` OR-list, `{ not }` negation. Used for
155
+ * addresses, hashes, byte strings, enums, and raw topics. `in` lists are capped
156
+ * at 64 members so a single filter can't fan out into an unbounded SQL `IN`.
157
+ *
158
+ * @see {@link matchesOperator} for how it is evaluated.
159
+ */
160
+ export const eq = <const item extends z.ZodMiniType>(item: item) =>
161
+ z.union([
162
+ item,
163
+ z.strictObject({ eq: item }),
164
+ z.strictObject({ in: z.array(item).check(z.minLength(1), z.maxLength(64)) }),
165
+ z.strictObject({ not: item }),
166
+ ])
167
+
168
+ /**
169
+ * Ordered-comparison operator for a numeric field (`value`, gas/fee fields,
170
+ * `nonce`, `blockNumber`, timestamps). A bare value is exact equality; the
171
+ * object form AND-combines any of `eq`/`gt`/`gte`/`lt`/`lte` to express ranges.
172
+ * Values are `0x`-hex quantities (the wire format), compiled to decimal in SQL.
173
+ */
174
+ export const compare = z.union([
175
+ Schema.Quantity,
176
+ z
177
+ .strictObject({
178
+ eq: z.optional(Schema.Quantity),
179
+ gt: z.optional(Schema.Quantity),
180
+ gte: z.optional(Schema.Quantity),
181
+ lt: z.optional(Schema.Quantity),
182
+ lte: z.optional(Schema.Quantity),
183
+ })
184
+ .check(z.refine((c) => Object.keys(c).length > 0, { error: 'Comparison cannot be empty.' })),
185
+ ])
186
+
187
+ /**
188
+ * Byte/calldata-matching operator for hex fields (tx `input`, `calls[].input`).
189
+ * `{ selector }` matches the first 4 bytes (the function selector, the cheap
190
+ * common case), `{ startsWith }` a hex prefix, `{ eq }` exact calldata.
191
+ */
192
+ export const bytes = z.union([
193
+ z.strictObject({ selector: Schema.Hex }),
194
+ z.strictObject({ startsWith: Schema.Hex }),
195
+ z.strictObject({ eq: Schema.Hex }),
196
+ ])
197
+
198
+ /**
199
+ * `transfer` subscription filters. The predicate fields are shared verbatim
200
+ * with `GET /transfers` via `Transfers.schema.Predicates`, so the two can't
201
+ * drift. All are optional and AND-combined; omit every field to subscribe to
202
+ * the full stream.
203
+ */
204
+ export const TransferFilters = Transfers.schema.Predicates.check(
205
+ z.describe('Filters that narrow which TIP-20 transfer events trigger this webhook.'),
206
+ z.meta({ title: 'Transfer filters' }),
207
+ )
208
+
209
+ /**
210
+ * `transaction:included` subscription filters: operators over every column the
211
+ * `txs` envelope exposes, plus inner account-abstraction `calls`, the
212
+ * top-level `input` calldata, and the receipt-sourced execution `status`. All
213
+ * optional and AND-combined; omit every field for the full stream.
214
+ *
215
+ * `address` matches either side (`from` OR `to`); when `includeCalls` is set
216
+ * (default), `to`/`address` also match an inner call target (`calls[].to`).
217
+ * Set `includeReceipt` to embed the receipt in the delivered payload.
218
+ */
219
+ export const TxFilters = z
220
+ .strictObject({
221
+ hash: z.optional(
222
+ eq(Schema.Hash).check(z.describe('Match this exact transaction hash (or any of several).')),
223
+ ),
224
+ address: z.optional(
225
+ eq(Schema.Address).check(
226
+ z.describe('Match transactions where this address is the sender or recipient.'),
227
+ ),
228
+ ),
229
+ from: z.optional(
230
+ eq(Schema.Address).check(z.describe('Match transactions whose sender (`from`) is this.')),
231
+ ),
232
+ to: z.optional(
233
+ eq(Schema.Address).check(
234
+ z.describe('Match transactions sent to this address (root `to`; see `includeCalls`).'),
235
+ ),
236
+ ),
237
+ includeCalls: z.optional(
238
+ z
239
+ .boolean()
240
+ .check(
241
+ z.describe(
242
+ 'When true (default), `to`/`address` also match an inner account-abstraction call target (`calls[].to`).',
243
+ ),
244
+ z.meta({ examples: [true] }),
245
+ ),
246
+ ),
247
+ includeReceipt: z.optional(
248
+ z
249
+ .boolean()
250
+ .check(
251
+ z.describe(
252
+ 'When true, embed the transaction receipt (execution `status`, `gasUsed`, `logs`, …) in the delivered payload under `meta.receipt`. Default false (the receipt is an extra fetch). Mirrors the read endpoint\u2019s `include=receipt`.',
253
+ ),
254
+ z.meta({ examples: [true] }),
255
+ ),
256
+ ),
257
+ value: z.optional(compare.check(z.describe('Match the transaction value (wei) bounds.'))),
258
+ input: z.optional(
259
+ bytes.check(
260
+ z.describe('Match top-level calldata by 4-byte `selector`, hex `startsWith`, or exact.'),
261
+ ),
262
+ ),
263
+ calls: z.optional(
264
+ z
265
+ .strictObject({
266
+ to: eq(Schema.Address),
267
+ })
268
+ .check(
269
+ z.describe(
270
+ 'Match if ANY inner account-abstraction call targets this address (`calls[].to`).',
271
+ ),
272
+ ),
273
+ ),
274
+ callCount: z.optional(
275
+ compare.check(z.describe('Match the number of inner calls (`call_count`).')),
276
+ ),
277
+ txType: z.optional(
278
+ eq(Schema.Hex).check(z.describe('Match the transaction type (e.g. `0x76` for Tempo AA).')),
279
+ ),
280
+ feeToken: z.optional(
281
+ eq(Schema.TokenAddress).check(z.describe('Match the fee token address.')),
282
+ ),
283
+ feePayer: z.optional(eq(Schema.Address).check(z.describe('Match the fee payer address.'))),
284
+ gasLimit: z.optional(compare.check(z.describe('Match the gas limit bounds.'))),
285
+ gasUsed: z.optional(compare.check(z.describe('Match the gas used bounds.'))),
286
+ maxFeePerGas: z.optional(compare.check(z.describe('Match the max fee per gas bounds.'))),
287
+ maxPriorityFeePerGas: z.optional(
288
+ compare.check(z.describe('Match the max priority fee per gas bounds.')),
289
+ ),
290
+ nonce: z.optional(compare.check(z.describe('Match the nonce bounds.'))),
291
+ nonceKey: z.optional(eq(Schema.Hex).check(z.describe('Match the nonce key (Tempo AA).'))),
292
+ validBefore: z.optional(
293
+ compare.check(z.describe('Match the `validBefore` bounds (unix s).')),
294
+ ),
295
+ validAfter: z.optional(compare.check(z.describe('Match the `validAfter` bounds (unix s).'))),
296
+ blockNumber: z.optional(compare.check(z.describe('Match the block-number bounds.'))),
297
+ timestamp: z.optional(
298
+ compare.check(z.describe('Match the block timestamp bounds (unix s).')),
299
+ ),
300
+ status: z.optional(
301
+ z
302
+ .enum(['success', 'reverted'])
303
+ .check(
304
+ z.describe('Match the receipt execution status (routes through `receipts`).'),
305
+ z.meta({ examples: ['success'] }),
306
+ ),
307
+ ),
308
+ })
309
+ .check(
310
+ z.describe('Filters that narrow which included transactions trigger this webhook.'),
311
+ z.meta({ title: 'Included-transaction filters' }),
312
+ )
313
+
314
+ /** A 32-byte log topic (lowercased hex). */
315
+ const topic = Schema.Hash
316
+
317
+ /**
318
+ * `log:emitted` subscription filters. Raw `topic0..3` map straight to the
319
+ * `logs` table; an optional human-readable ABI event `signature` is sugar that
320
+ * pins `topic0` and lets `args` match decoded (indexed **or** non-indexed)
321
+ * parameters by name. At least one predicate is required (no chain-wide
322
+ * firehose), and an addressless filter must still be selective — an event
323
+ * anchor (`signature`/`topic0`) plus at least one indexed topic or argument.
324
+ */
325
+ export const LogFilters = z
326
+ .strictObject({
327
+ address: z.optional(
328
+ eq(Schema.Address).check(
329
+ z.describe('Only match logs emitted by this contract address (or any of several).'),
330
+ z.meta({ examples: ['0x20c0000000000000000000008f5425160ebe5525'] }),
331
+ ),
332
+ ),
333
+ signature: z.optional(
334
+ z.string().check(
335
+ z.describe(
336
+ 'Human-readable ABI event signature used to derive `topic0` and decode `args`, e.g. `event Transfer(address indexed from, address indexed to, uint256 value)`.',
337
+ ),
338
+ z.meta({
339
+ examples: ['event Transfer(address indexed from, address indexed to, uint256 value)'],
340
+ }),
341
+ ),
342
+ ),
343
+ topic0: z.optional(
344
+ eq(topic).check(
345
+ z.describe('Event signature hash (topic 0). Mutually exclusive with `signature`.'),
346
+ z.meta({
347
+ examples: ['0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'],
348
+ }),
349
+ ),
350
+ ),
351
+ topic1: z.optional(
352
+ eq(topic).check(
353
+ z.describe('Raw indexed argument 1 (topic 1).'),
354
+ z.meta({
355
+ examples: ['0x000000000000000000000000e7687128b0a808c2831ff94d4f7b2fb35c65af38'],
356
+ }),
357
+ ),
358
+ ),
359
+ topic2: z.optional(
360
+ eq(topic).check(
361
+ z.describe('Raw indexed argument 2 (topic 2).'),
362
+ z.meta({
363
+ examples: ['0x0000000000000000000000009e39034aae71fb89f66061a2602eb6efec271754'],
364
+ }),
365
+ ),
366
+ ),
367
+ topic3: z.optional(
368
+ eq(topic).check(
369
+ z.describe('Raw indexed argument 3 (topic 3).'),
370
+ z.meta({
371
+ examples: ['0x0000000000000000000000000000000000000000000000000000000000000000'],
372
+ }),
373
+ ),
374
+ ),
375
+ args: z.optional(
376
+ z
377
+ .record(z.string(), z.unknown())
378
+ .check(
379
+ z.describe(
380
+ 'Match decoded event arguments by name (indexed or non-indexed). Requires `signature`. Each value is an exact value or an operator object.',
381
+ ),
382
+ z.meta({ examples: [{ to: '0x9e39034aae71fb89f66061a2602eb6efec271754' }] }),
383
+ ),
384
+ ),
385
+ blockNumber: z.optional(
386
+ compare.check(z.describe('Only match logs within these block-number bounds.')),
387
+ ),
388
+ })
389
+ .check(
390
+ z.describe('Filters that narrow which contract event logs trigger this webhook.'),
391
+ z.meta({ title: 'Log filters' }),
392
+ z.refine((f) => Object.keys(f).length > 0, {
393
+ error: 'At least one log filter is required.',
394
+ }),
395
+ z.refine((f) => !(f.signature && f.topic0), {
396
+ error: 'Pass `signature` or `topic0`, not both.',
397
+ }),
398
+ z.refine((f) => !f.args || f.signature, {
399
+ error: '`args` requires `signature` to decode the event.',
400
+ }),
401
+ z.refine(
402
+ (f) =>
403
+ Boolean(f.address) ||
404
+ (Boolean(f.signature || f.topic0) && Boolean(f.topic1 || f.topic2 || f.topic3 || f.args)),
405
+ {
406
+ error:
407
+ 'Addressless log filters must include an event signature or topic0 plus at least one indexed topic or argument.',
408
+ },
409
+ ),
410
+ )
411
+
412
+ /**
413
+ * `block:created` subscription filters. All optional and AND-combined; unlike
414
+ * `log:emitted`, an empty filter is allowed — one row per block is a bounded,
415
+ * non-abusive heartbeat feed. Block-level metadata only (no transaction
416
+ * predicates; use `transaction:included` for those).
417
+ */
418
+ export const BlockFilters = z
419
+ .strictObject({
420
+ number: z.optional(
421
+ compare.check(z.describe('Only match blocks within these block-number bounds.')),
422
+ ),
423
+ miner: z.optional(
424
+ eq(Schema.Address).check(
425
+ z.describe('Only match blocks produced by this address (the miner/producer).'),
426
+ z.meta({ examples: ['0x0000000000000000000000000000000000000000'] }),
427
+ ),
428
+ ),
429
+ proposer: z.optional(
430
+ eq(Schema.Address).check(
431
+ z.describe('Only match blocks proposed by this consensus proposer (when available).'),
432
+ ),
433
+ ),
434
+ gasUsed: z.optional(
435
+ compare.check(z.describe('Only match blocks whose total gas used is within these bounds.')),
436
+ ),
437
+ gasLimit: z.optional(
438
+ compare.check(z.describe('Only match blocks whose gas limit is within these bounds.')),
439
+ ),
440
+ timestamp: z.optional(
441
+ compare.check(
442
+ z.describe('Only match blocks whose unix timestamp (seconds) is within these bounds.'),
443
+ ),
444
+ ),
445
+ })
446
+ .check(
447
+ z.describe('Filters that narrow which new blocks trigger this webhook.'),
448
+ z.meta({ title: 'Block filters' }),
449
+ )
450
+
451
+ /**
452
+ * Optional human context describing what a subscription is for. Surfaced by
453
+ * destinations (Slack header + description, Better Stack log fields) and echoed
454
+ * on every delivered event envelope.
455
+ */
456
+ export const Context = z
457
+ .object({
458
+ description: z.optional(
459
+ z
460
+ .string()
461
+ .check(
462
+ z.maxLength(500),
463
+ z.describe('Longer description of what this subscription is for.'),
464
+ z.meta({ examples: ['Notify #ops when a large USDC transfer settles on mainnet.'] }),
465
+ ),
466
+ ),
467
+ metadata: z.optional(
468
+ z
469
+ .record(
470
+ z.string().check(z.minLength(1), z.maxLength(64)),
471
+ z.string().check(z.maxLength(500)),
472
+ )
473
+ .check(
474
+ z.refine((m) => Object.keys(m).length <= 20, {
475
+ error: 'At most 20 metadata entries are allowed.',
476
+ }),
477
+ z.describe(
478
+ 'Arbitrary key/value labels echoed on every delivered event and rendered by destinations (Slack fields, Better Stack log fields). Keys are 1–64 chars, values ≤500 chars, ≤20 entries.',
479
+ ),
480
+ z.meta({ examples: [{ env: 'prod', team: 'payments' }] }),
481
+ ),
482
+ ),
483
+ title: z.optional(
484
+ z
485
+ .string()
486
+ .check(
487
+ z.maxLength(120),
488
+ z.describe('Short label for this subscription.'),
489
+ z.meta({ examples: ['Prod USDC large transfers'] }),
490
+ ),
491
+ ),
492
+ })
493
+ .check(z.describe('Human context describing what this webhook subscription is for.'))
494
+
495
+ /**
496
+ * Public subscription representation. Never includes the signing `secret`
497
+ * (returned once by `POST /webhooks`) or the internal owner.
498
+ */
499
+ export const Subscription = z
500
+ .object({
501
+ chainId: chainIdResponse,
502
+ context: z.optional(Context),
503
+ createdAt: z.iso
504
+ .datetime()
505
+ .check(
506
+ z.describe('When the subscription was created, as an ISO 8601 timestamp.'),
507
+ z.meta({ examples: ['2024-01-01T00:00:00Z'] }),
508
+ ),
509
+ destination: Destination,
510
+ eventType: EventType.check(z.meta({ examples: ['token:transfer'] })),
511
+ expiresAt: z
512
+ .optional(z.iso.datetime())
513
+ .check(
514
+ z.describe(
515
+ 'When the subscription expires, as an ISO 8601 timestamp. Only MPP-paid subscriptions expire.',
516
+ ),
517
+ z.meta({ examples: ['2024-01-01T00:00:00Z'] }),
518
+ ),
519
+ failureCount: z
520
+ .number()
521
+ .check(
522
+ z.int(),
523
+ z.nonnegative(),
524
+ z.describe('Number of delivery failures in a row for this subscription.'),
525
+ z.meta({ examples: [0] }),
526
+ ),
527
+ filters: Filters.check(
528
+ z.describe('Filters applied to this event type.'),
529
+ z.meta({ examples: [{ address: '0x20c0000000000000000000008f5425160ebe5525' }] }),
530
+ ),
531
+ id: z
532
+ .string()
533
+ .check(
534
+ z.describe('Webhook subscription ID (`wh_…`).'),
535
+ z.meta({ examples: ['wh_abc123'] }),
536
+ ),
537
+ lastDeliveryAt: z
538
+ .optional(z.iso.datetime())
539
+ .check(
540
+ z.describe('When Tempo last delivered an event successfully, as an ISO 8601 timestamp.'),
541
+ z.meta({ examples: ['2024-01-01T00:00:00Z'] }),
542
+ ),
543
+ status: Status.check(z.meta({ examples: ['active'] })),
544
+ updatedAt: z.iso
545
+ .datetime()
546
+ .check(
547
+ z.describe('When the subscription was last changed, as an ISO 8601 timestamp.'),
548
+ z.meta({ examples: ['2024-01-01T00:00:00Z'] }),
549
+ ),
550
+ })
551
+ .check(
552
+ z.describe(
553
+ 'A webhook subscription that tells Tempo which onchain events to send to your destination.',
554
+ ),
555
+ )
556
+
557
+ /** Outcome of a single delivery attempt. */
558
+ export const DeliveryStatus = z
559
+ .enum(['failed', 'pending', 'succeeded'])
560
+ .check(
561
+ z.describe('Outcome of a webhook delivery attempt.'),
562
+ z.meta({ examples: ['succeeded'] }),
563
+ )
564
+
565
+ /** A logged delivery attempt for a subscription. */
566
+ export const Delivery = z
567
+ .object({
568
+ attempt: z
569
+ .number()
570
+ .check(
571
+ z.int(),
572
+ z.positive(),
573
+ z.describe('Retry attempt number for this delivery, starting at 1.'),
574
+ z.meta({ examples: [1] }),
575
+ ),
576
+ createdAt: z.iso
577
+ .datetime()
578
+ .check(
579
+ z.describe('When this delivery attempt was created, as an ISO 8601 timestamp.'),
580
+ z.meta({ examples: ['2024-01-01T00:00:00Z'] }),
581
+ ),
582
+ error: z
583
+ .optional(z.string())
584
+ .check(
585
+ z.describe('Why delivery failed, present when `status` is `failed`.'),
586
+ z.meta({ examples: ['Connection timed out'] }),
587
+ ),
588
+ eventId: z
589
+ .string()
590
+ .check(
591
+ z.describe('Stable event ID (`evt_…`) you can use to dedupe webhook deliveries.'),
592
+ z.meta({ examples: ['evt_abc123'] }),
593
+ ),
594
+ id: z
595
+ .string()
596
+ .check(z.describe('Webhook delivery ID (`whd_…`).'), z.meta({ examples: ['whd_abc123'] })),
597
+ requestUrl: z
598
+ .string()
599
+ .check(
600
+ z.describe('Your callback URL that Tempo attempted to deliver to.'),
601
+ z.meta({ examples: ['https://example.com/webhooks'] }),
602
+ ),
603
+ responseMs: z
604
+ .optional(z.number().check(z.int(), z.nonnegative()))
605
+ .check(
606
+ z.describe(
607
+ 'How long the delivery attempt took in milliseconds, when Tempo made a request.',
608
+ ),
609
+ z.meta({ examples: [12] }),
610
+ ),
611
+ responseStatus: z
612
+ .optional(z.number().check(z.int()))
613
+ .check(
614
+ z.describe('HTTP status your endpoint returned, when Tempo received a response.'),
615
+ z.meta({ examples: [200] }),
616
+ ),
617
+ status: DeliveryStatus.check(z.meta({ examples: ['succeeded'] })),
618
+ subscriptionId: z
619
+ .string()
620
+ .check(
621
+ z.describe('Subscription ID (`wh_…`) this delivery belongs to.'),
622
+ z.meta({ examples: ['wh_abc123'] }),
623
+ ),
624
+ })
625
+ .check(z.describe('One attempt by Tempo to deliver an event to your webhook URL.'))
626
+
627
+ /**
628
+ * A `transfer` event payload: the decoded TIP-20 `Transfer` row. Mirrors a
629
+ * `GET /transfers` row but without the `sourceToken` metadata enrichment the
630
+ * read endpoint adds (webhook delivery does not perform per-event token
631
+ * lookups).
632
+ */
633
+ export const TransferEvent = z
634
+ .object({
635
+ address: Schema.TokenAddress.check(
636
+ z.describe('TIP-20 token contract address.'),
637
+ z.meta({ examples: ['0x20c0000000000000000000008f5425160ebe5525'] }),
638
+ ),
639
+ amount: z
640
+ .string()
641
+ .check(
642
+ z.describe('Amount transferred, as a decimal integer string in the token base unit.'),
643
+ z.meta({ examples: ['10000'] }),
644
+ ),
645
+ blockNumber: z
646
+ .number()
647
+ .check(
648
+ z.int(),
649
+ z.nonnegative(),
650
+ z.describe('Block number where this transfer was included.'),
651
+ z.meta({ examples: [1000002] }),
652
+ ),
653
+ recipient: Schema.Address.check(
654
+ z.describe('Address that received the transfer.'),
655
+ z.meta({ examples: ['0x9e39034aae71fb89f66061a2602eb6efec271754'] }),
656
+ ),
657
+ sender: Schema.Address.check(
658
+ z.describe('Address that sent the transfer.'),
659
+ z.meta({ examples: ['0xe7687128b0a808c2831ff94d4f7b2fb35c65af38'] }),
660
+ ),
661
+ timestamp: z.iso
662
+ .datetime()
663
+ .check(
664
+ z.describe('Block timestamp as an ISO 8601 string.'),
665
+ z.meta({ examples: ['2026-01-14T18:38:03.000Z'] }),
666
+ ),
667
+ token: z.optional(
668
+ z
669
+ .object({
670
+ decimals: z
671
+ .number()
672
+ .check(
673
+ z.int(),
674
+ z.nonnegative(),
675
+ z.describe('Number of decimals the token uses.'),
676
+ z.meta({ examples: [6] }),
677
+ ),
678
+ symbol: z
679
+ .string()
680
+ .check(z.describe('Token symbol (e.g. `USDC`).'), z.meta({ examples: ['USDC'] })),
681
+ })
682
+ .check(
683
+ z.describe(
684
+ 'Best-effort token metadata (symbol/decimals) resolved by the poller so `amount` can render as a human amount. Omitted when the lookup is unavailable.',
685
+ ),
686
+ ),
687
+ ),
688
+ transactionHash: Schema.Hash.check(
689
+ z.describe('Transaction hash that contains this transfer.'),
690
+ z.meta({
691
+ examples: ['0x3d24a706cc2f6f4c96620bef1f61ddb23040ff77c22c8db42918c7c424bbf9d3'],
692
+ }),
693
+ ),
694
+ })
695
+ .check(
696
+ z.describe('Payload for a `token:transfer` event, decoded from a TIP-20 `Transfer` log.'),
697
+ )
698
+
699
+ /**
700
+ * A `transaction` event payload: identical to the `GET /transactions/:hash`
701
+ * response (the poller reuses the same humanized shape).
702
+ */
703
+ export const TransactionEvent = Transactions.schema.getTransaction.Response
704
+
705
+ /**
706
+ * A `log:emitted` event payload: the raw contract event log. When a
707
+ * `signature` filter was supplied and decoding succeeds, the best-effort
708
+ * `event`/`args` fields carry the decoded event name and arguments (large
709
+ * integers as decimal strings).
710
+ */
711
+ export const LogEvent = z
712
+ .object({
713
+ address: Schema.Address.check(
714
+ z.describe('Contract that emitted the log.'),
715
+ z.meta({ examples: ['0x20c00000000000000000000071de0cd31ab0d105'] }),
716
+ ),
717
+ args: z.optional(
718
+ z
719
+ .record(z.string(), z.unknown())
720
+ .check(
721
+ z.describe(
722
+ 'Decoded event arguments, present only when a `signature` filter was supplied and decoding succeeded.',
723
+ ),
724
+ z.meta({ examples: [{ from: '0xe768…', to: '0x9e39…', value: '10000' }] }),
725
+ ),
726
+ ),
727
+ blockNumber: z
728
+ .number()
729
+ .check(
730
+ z.int(),
731
+ z.nonnegative(),
732
+ z.describe('Block number the log was included in.'),
733
+ z.meta({ examples: [1000002] }),
734
+ ),
735
+ data: Schema.Hex.check(
736
+ z.describe('Unindexed log data (ABI-encoded).'),
737
+ z.meta({
738
+ examples: ['0x0000000000000000000000000000000000000000000000000000000000002710'],
739
+ }),
740
+ ),
741
+ event: z.optional(
742
+ z
743
+ .object({
744
+ name: z
745
+ .string()
746
+ .check(z.describe('Decoded event name.'), z.meta({ examples: ['Transfer'] })),
747
+ signature: z.string().check(
748
+ z.describe('Human-readable event signature used to decode the log.'),
749
+ z.meta({
750
+ examples: [
751
+ 'event Transfer(address indexed from, address indexed to, uint256 value)',
752
+ ],
753
+ }),
754
+ ),
755
+ topic0: Schema.Hash.check(
756
+ z.describe('Event signature hash (topic 0).'),
757
+ z.meta({
758
+ examples: ['0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'],
759
+ }),
760
+ ),
761
+ })
762
+ .check(
763
+ z.describe(
764
+ 'Decoded event identity, present only when a `signature` filter was supplied.',
765
+ ),
766
+ ),
767
+ ),
768
+ logIndex: z
769
+ .number()
770
+ .check(
771
+ z.int(),
772
+ z.nonnegative(),
773
+ z.describe('Index of the log within its block.'),
774
+ z.meta({ examples: [0] }),
775
+ ),
776
+ timestamp: z.iso
777
+ .datetime()
778
+ .check(
779
+ z.describe('When the block was produced, as an ISO 8601 timestamp.'),
780
+ z.meta({ examples: ['2026-01-14T18:38:03.000Z'] }),
781
+ ),
782
+ topics: z.array(Schema.Hash).check(
783
+ z.describe('Log topics, starting with the event signature hash (topic 0).'),
784
+ z.meta({
785
+ examples: [
786
+ [
787
+ '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef',
788
+ '0x000000000000000000000000e7687128b0a808c2831ff94d4f7b2fb35c65af38',
789
+ ],
790
+ ],
791
+ }),
792
+ ),
793
+ transactionHash: Schema.Hash.check(
794
+ z.describe('Hash of the transaction that emitted the log.'),
795
+ z.meta({
796
+ examples: ['0x3d24a706cc2f6f4c96620bef1f61ddb23040ff77c22c8db42918c7c424bbf9d3'],
797
+ }),
798
+ ),
799
+ transactionIndex: z
800
+ .number()
801
+ .check(
802
+ z.int(),
803
+ z.nonnegative(),
804
+ z.describe('Index of the transaction within its block.'),
805
+ z.meta({ examples: [0] }),
806
+ ),
807
+ })
808
+ .check(z.describe('Payload for a `log:emitted` event (a raw contract event log).'))
809
+
810
+ /** Payload for a `block:created` event (a TIDX-derived block header). */
811
+ export const BlockEvent = z
812
+ .object({
813
+ number: z
814
+ .number()
815
+ .check(
816
+ z.int(),
817
+ z.nonnegative(),
818
+ z.describe('Block height, starting from genesis block 0.'),
819
+ z.meta({ examples: [1000002] }),
820
+ ),
821
+ hash: Schema.Hash.check(
822
+ z.describe('Hash that identifies this block.'),
823
+ z.meta({
824
+ examples: ['0x3fe7d9e595f3d8215ec840c6ef55ac0bead58933e96fc2ead0f2aaf79715f45d'],
825
+ }),
826
+ ),
827
+ parentHash: Schema.Hash.check(
828
+ z.describe('Hash of the previous block in the chain.'),
829
+ z.meta({
830
+ examples: ['0xa406dff9c51cc2abbef3a811f0499e99a568bf781019f129ab45dca1df0e43e5'],
831
+ }),
832
+ ),
833
+ miner: Schema.Address.check(
834
+ z.describe('Address of the block producer (miner/proposer).'),
835
+ z.meta({ examples: ['0x0000000000000000000000000000000000000000'] }),
836
+ ),
837
+ proposer: z.optional(
838
+ Schema.Address.check(
839
+ z.describe('Consensus proposer of the block, when the chain records one.'),
840
+ z.meta({ examples: ['0x0000000000000000000000000000000000000000'] }),
841
+ ),
842
+ ),
843
+ gasUsed: z
844
+ .number()
845
+ .check(
846
+ z.int(),
847
+ z.nonnegative(),
848
+ z.describe('Total gas used by every transaction in this block.'),
849
+ z.meta({ examples: [4340281] }),
850
+ ),
851
+ gasLimit: z
852
+ .number()
853
+ .check(
854
+ z.int(),
855
+ z.nonnegative(),
856
+ z.describe('Maximum gas available for all transactions in this block.'),
857
+ z.meta({ examples: [500000000] }),
858
+ ),
859
+ transactionCount: z
860
+ .number()
861
+ .check(
862
+ z.int(),
863
+ z.nonnegative(),
864
+ z.describe('Number of transactions included in this block.'),
865
+ z.meta({ examples: [3] }),
866
+ ),
867
+ timestamp: z.iso
868
+ .datetime()
869
+ .check(
870
+ z.describe('When the block was produced, as an ISO 8601 timestamp.'),
871
+ z.meta({ examples: ['2026-01-14T18:38:03.000Z'] }),
872
+ ),
873
+ })
874
+ .check(z.describe('Payload for a `block:created` event (a new block header).'))
875
+
876
+ /** A `ping` (synthetic test-delivery) payload, sent by `POST /webhooks/:id/ping`. */
877
+ export const PingEvent = z
878
+ .object({
879
+ ping: z.literal(true).check(z.describe('Marks this payload as a synthetic ping test.')),
880
+ })
881
+ .check(z.describe('Payload for a webhook ping test delivery.'))
882
+
883
+ /** Fields common to every delivery envelope. */
884
+ const envelopeBase = {
885
+ chainId: Schema.ChainId,
886
+ context: z.optional(Context),
887
+ createdAt: z.iso
888
+ .datetime()
889
+ .check(
890
+ z.describe('When Tempo built this delivery envelope, as an ISO 8601 timestamp.'),
891
+ z.meta({ examples: ['2026-01-14T18:38:03.000Z'] }),
892
+ ),
893
+ id: z
894
+ .string()
895
+ .check(
896
+ z.describe('Stable event ID (`evt_…`) for this delivery. Store it and ignore duplicates.'),
897
+ z.meta({ examples: ['evt_abc123'] }),
898
+ ),
899
+ subscriptionId: z
900
+ .string()
901
+ .check(
902
+ z.describe('Subscription ID (`wh_…`) that produced this delivery.'),
903
+ z.meta({ examples: ['wh_abc123'] }),
904
+ ),
905
+ }
906
+
907
+ /**
908
+ * The signed JSON body POSTed to subscriber URLs, discriminated by `type`. The
909
+ * `tempo-event-type` header mirrors `type`. Event variants carry the matched
910
+ * event row; `ping` is a synthetic test delivery.
911
+ *
912
+ * Each variant carries a `title` so doc renderers (Scalar) show a labelled
913
+ * variant selector — without per-variant titles, inline `oneOf` members render
914
+ * as a single (first) collapsed schema.
915
+ */
916
+ export const Envelope = z
917
+ .discriminatedUnion('type', [
918
+ z
919
+ .object({ ...envelopeBase, data: TransferEvent, type: z.literal('token:transfer') })
920
+ .check(z.meta({ title: 'Transfer event' })),
921
+ z
922
+ .object({
923
+ ...envelopeBase,
924
+ data: TransactionEvent,
925
+ type: z.literal('transaction:included'),
926
+ })
927
+ .check(z.meta({ title: 'Included transaction event' })),
928
+ z
929
+ .object({ ...envelopeBase, data: LogEvent, type: z.literal('log:emitted') })
930
+ .check(z.meta({ title: 'Log event' })),
931
+ z
932
+ .object({ ...envelopeBase, data: BlockEvent, type: z.literal('block:created') })
933
+ .check(z.meta({ title: 'Block event' })),
934
+ z
935
+ .object({ ...envelopeBase, data: PingEvent, type: z.literal('ping') })
936
+ .check(z.meta({ title: 'Ping (test delivery)' })),
937
+ ])
938
+ .check(z.describe('The signed JSON payload Tempo POSTs to your webhook URL.'))
939
+
940
+ /** Schemas for the getWebhookEventTypes operation. */
941
+ export namespace getWebhookEventTypes {
942
+ export const Response = z
943
+ .object({
944
+ data: z
945
+ .array(
946
+ z.object({
947
+ description: z
948
+ .string()
949
+ .check(
950
+ z.describe('Plain-English description of the event type.'),
951
+ z.meta({ examples: ['TIP-20 token transfer event.'] }),
952
+ ),
953
+ type: EventType.check(z.meta({ examples: ['token:transfer'] })),
954
+ }),
955
+ )
956
+ .check(z.describe('Webhook event types available for subscription.')),
957
+ })
958
+ .check(z.describe('List of webhook event types Tempo can send.'))
959
+ }
960
+
961
+ /** Schemas for the createWebhook operation. */
962
+ export namespace createWebhook {
963
+ /**
964
+ * Discriminated on `eventType` so each variant carries its own typed
965
+ * `filters` object — this is what lets the docs render the valid filter
966
+ * fields per event type instead of an opaque map. Variant `title`s drive the
967
+ * doc renderer's selector (see {@link schema.Envelope}).
968
+ */
969
+ export const Body = z
970
+ .discriminatedUnion('eventType', [
971
+ z
972
+ .object({
973
+ chainId: z.optional(Schema.ChainId).check(z.meta({ examples: [4217] })),
974
+ context: z.optional(Context),
975
+ destination: Destination,
976
+ eventType: z
977
+ .literal('token:transfer')
978
+ .check(
979
+ z.describe('Event type you want Tempo to send to your webhook URL.'),
980
+ z.meta({ examples: ['token:transfer'] }),
981
+ ),
982
+ filters: z.optional(TransferFilters),
983
+ })
984
+ .check(z.meta({ title: 'Transfer subscription' })),
985
+ z
986
+ .object({
987
+ chainId: z.optional(Schema.ChainId).check(z.meta({ examples: [4217] })),
988
+ context: z.optional(Context),
989
+ destination: Destination,
990
+ eventType: z
991
+ .literal('transaction:included')
992
+ .check(
993
+ z.describe('Event type you want Tempo to send to your webhook URL.'),
994
+ z.meta({ examples: ['transaction:included'] }),
995
+ ),
996
+ filters: z.optional(TxFilters),
997
+ })
998
+ .check(z.meta({ title: 'Included-transaction subscription' })),
999
+ z
1000
+ .object({
1001
+ chainId: z.optional(Schema.ChainId).check(z.meta({ examples: [4217] })),
1002
+ context: z.optional(Context),
1003
+ destination: Destination,
1004
+ eventType: z
1005
+ .literal('log:emitted')
1006
+ .check(
1007
+ z.describe('Event type you want Tempo to send to your webhook URL.'),
1008
+ z.meta({ examples: ['log:emitted'] }),
1009
+ ),
1010
+ filters: LogFilters,
1011
+ })
1012
+ .check(z.meta({ title: 'Log subscription' })),
1013
+ z
1014
+ .object({
1015
+ chainId: z.optional(Schema.ChainId).check(z.meta({ examples: [4217] })),
1016
+ context: z.optional(Context),
1017
+ destination: Destination,
1018
+ eventType: z
1019
+ .literal('block:created')
1020
+ .check(
1021
+ z.describe('Event type you want Tempo to send to your webhook URL.'),
1022
+ z.meta({ examples: ['block:created'] }),
1023
+ ),
1024
+ filters: z.optional(BlockFilters),
1025
+ })
1026
+ .check(z.meta({ title: 'Block subscription' })),
1027
+ ])
1028
+ .check(z.describe('Details for creating a webhook subscription.'))
1029
+
1030
+ export const Response = z
1031
+ .extend(Subscription, {
1032
+ secret: z
1033
+ .string()
1034
+ .check(
1035
+ z.describe(
1036
+ 'HMAC signing secret (`whsec_…`) used to verify Tempo webhook signatures. It is shown once when you create the webhook, so store it now.',
1037
+ ),
1038
+ z.meta({ examples: ['whsec_abc123'] }),
1039
+ ),
1040
+ })
1041
+ .check(z.describe('The new webhook subscription, including the one-time signing secret.'))
1042
+ }
1043
+
1044
+ /** Schemas for the listWebhooks operation. */
1045
+ export namespace listWebhooks {
1046
+ export const Query = z
1047
+ .object({
1048
+ cursor: Schema.Cursor,
1049
+ include: Schema.totalCountInclude,
1050
+ limit: Schema.Limit,
1051
+ page: Schema.Page,
1052
+ })
1053
+ .check(
1054
+ ...Schema.pageChecks(),
1055
+ z.describe('Query parameters for listing your webhook subscriptions.'),
1056
+ )
1057
+
1058
+ export const Response = z
1059
+ .object({
1060
+ data: z.array(Subscription).check(z.describe('Webhook subscriptions on this page.')),
1061
+ meta: z
1062
+ .optional(Schema.CountMeta)
1063
+ .check(z.describe('Extra response metadata requested with `include`.')),
1064
+ nextCursor: Schema.NextCursor,
1065
+ })
1066
+ .check(z.describe('A page of your webhook subscriptions.'))
1067
+ }
1068
+
1069
+ /** Schemas for the getWebhook operation. */
1070
+ export namespace getWebhook {
1071
+ export const Params = z
1072
+ .object({
1073
+ id: z
1074
+ .string()
1075
+ .check(
1076
+ z.describe('Webhook subscription ID (`wh_…`).'),
1077
+ z.meta({ examples: ['wh_001718668800000_V1StGXR8Z5jdHi6B2myTq'] }),
1078
+ ),
1079
+ })
1080
+ .check(z.describe('Path parameters for a webhook subscription request.'))
1081
+
1082
+ export const Response = Subscription
1083
+ }
1084
+
1085
+ /** Schemas for the listWebhookDeliveries operation. */
1086
+ export namespace listWebhookDeliveries {
1087
+ export const Params = getWebhook.Params
1088
+
1089
+ export const Query = z
1090
+ .object({
1091
+ cursor: Schema.Cursor,
1092
+ include: Schema.totalCountInclude,
1093
+ limit: Schema.Limit,
1094
+ page: Schema.Page,
1095
+ })
1096
+ .check(
1097
+ ...Schema.pageChecks(),
1098
+ z.describe('Query parameters for listing webhook delivery attempts.'),
1099
+ )
1100
+
1101
+ export const Response = z
1102
+ .object({
1103
+ data: z.array(Delivery).check(z.describe('Webhook delivery attempts on this page.')),
1104
+ meta: z
1105
+ .optional(Schema.CountMeta)
1106
+ .check(z.describe('Extra response metadata requested with `include`.')),
1107
+ nextCursor: Schema.NextCursor,
1108
+ })
1109
+ .check(z.describe('A page of webhook delivery attempts.'))
1110
+ }
1111
+
1112
+ /** Schemas for the pingWebhook operation. */
1113
+ export namespace pingWebhook {
1114
+ export const Params = getWebhook.Params
1115
+
1116
+ export const Response = z
1117
+ .object({
1118
+ delivered: z
1119
+ .boolean()
1120
+ .check(
1121
+ z.describe('Whether your endpoint returned a 2xx response.'),
1122
+ z.meta({ examples: [true] }),
1123
+ ),
1124
+ error: z
1125
+ .optional(z.string())
1126
+ .check(
1127
+ z.describe('Why the delivery failed, present when `delivered` is `false`.'),
1128
+ z.meta({ examples: ['Connection timed out'] }),
1129
+ ),
1130
+ eventId: z
1131
+ .string()
1132
+ .check(
1133
+ z.describe('Synthetic event ID (`evt_…`) Tempo sent for this ping.'),
1134
+ z.meta({ examples: ['evt_abc123'] }),
1135
+ ),
1136
+ responseMs: z
1137
+ .optional(z.number().check(z.int(), z.nonnegative()))
1138
+ .check(
1139
+ z.describe('Round-trip delivery time in milliseconds, when the request completed.'),
1140
+ z.meta({ examples: [12] }),
1141
+ ),
1142
+ responseStatus: z
1143
+ .optional(z.number().check(z.int()))
1144
+ .check(
1145
+ z.describe('HTTP status your endpoint returned, when Tempo received a response.'),
1146
+ z.meta({ examples: [200] }),
1147
+ ),
1148
+ })
1149
+ .check(z.describe('Result of sending a synthetic ping event to your webhook URL.'))
1150
+ }
1151
+
1152
+ /** Schemas for the retryWebhookDelivery operation. */
1153
+ export namespace retryWebhookDelivery {
1154
+ export const Params = z
1155
+ .object({
1156
+ deliveryId: z
1157
+ .string()
1158
+ .check(
1159
+ z.describe('Delivery ID (`whd_…`) you want Tempo to replay.'),
1160
+ z.meta({ examples: ['whd_001718668800000_8oQ2pVmZ4kKxWnLcRfTbN'] }),
1161
+ ),
1162
+ id: z
1163
+ .string()
1164
+ .check(
1165
+ z.describe('Webhook subscription ID (`wh_…`).'),
1166
+ z.meta({ examples: ['wh_001718668800000_V1StGXR8Z5jdHi6B2myTq'] }),
1167
+ ),
1168
+ })
1169
+ .check(z.describe('Path parameters for retrying a webhook delivery.'))
1170
+
1171
+ export const Response = pingWebhook.Response
1172
+ }
1173
+
1174
+ /** Schemas for the updateWebhook operation. */
1175
+ export namespace updateWebhook {
1176
+ export const Params = getWebhook.Params
1177
+
1178
+ export const Body = z
1179
+ .object({
1180
+ context: z
1181
+ .optional(z.nullable(Context))
1182
+ .check(z.describe('New human context, or `null` to clear it.')),
1183
+ destination: z
1184
+ .optional(Destination)
1185
+ .check(z.describe('New delivery destination for future events (URL or Slack channel).')),
1186
+ filters: z
1187
+ .optional(Filters)
1188
+ .check(
1189
+ z.describe('New filters to apply to future webhook events.'),
1190
+ z.meta({ examples: [{ address: '0x20c0000000000000000000008f5425160ebe5525' }] }),
1191
+ ),
1192
+ status: z
1193
+ .optional(Status)
1194
+ .check(z.describe('New lifecycle status, such as pausing or resuming delivery.')),
1195
+ })
1196
+ .check(z.describe('Fields you want to update on the webhook subscription.'))
1197
+
1198
+ export const Response = Subscription
1199
+ }
1200
+
1201
+ /** Schemas for the deleteWebhook operation. */
1202
+ export namespace deleteWebhook {
1203
+ export const Params = getWebhook.Params
1204
+
1205
+ export const Response = z
1206
+ .object({
1207
+ id: z
1208
+ .string()
1209
+ .check(
1210
+ z.describe('ID of the webhook subscription that was deleted.'),
1211
+ z.meta({ examples: ['wh_abc123'] }),
1212
+ ),
1213
+ })
1214
+ .check(z.describe('Confirmation that the webhook subscription was deleted.'))
1215
+ }
1216
+ }
1217
+
1218
+ /**
1219
+ * Creates the webhooks resource. The subscription surface is non-public and
1220
+ * API-key-only for now (MPP paid access is disabled) and never edge-cached; only
1221
+ * `GET /webhooks/event-types` is public catalog data. Routes are always mounted
1222
+ * but return `404` when no state `webhook.store` is configured, and are hidden
1223
+ * from the OpenAPI document in that case.
1224
+ */
1225
+ export function webhooks(options: webhooks.Options = {}) {
1226
+ const hidden = !options.enabled
1227
+ return (
1228
+ new Hono<App.Environment>()
1229
+ .use('*', async (c, next) => {
1230
+ // Gate the whole resource on a configured state store. Without it the
1231
+ // outbound-HTTP surface is disabled (read-only deployment).
1232
+ if (!c.get('webhook')) return notEnabled(c) as never
1233
+
1234
+ await next()
1235
+
1236
+ // Subscriptions are principal-scoped, mutating, and stateful: never cache.
1237
+ if (!c.res.headers.has('Cache-Control')) c.res.headers.set('Cache-Control', 'no-store')
1238
+ return
1239
+ })
1240
+ .get(
1241
+ '/event-types',
1242
+ Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
1243
+ OpenApi.describeRoute({
1244
+ description: 'See which onchain event types Tempo can POST to your webhook URL.',
1245
+ hide: hidden,
1246
+ operationId: 'getWebhookEventTypes',
1247
+ responses: OpenApi.responses({
1248
+ success: {
1249
+ description: 'Webhook event types available for subscription.',
1250
+ schema: schema.getWebhookEventTypes.Response,
1251
+ },
1252
+ }),
1253
+ summary: 'List webhook event types',
1254
+ tags: ['Webhooks'],
1255
+ }),
1256
+ Cache.response<App.Environment>({
1257
+ cacheControl: Cache.policies.stable,
1258
+ name: 'tempo-api:webhooks:v1',
1259
+ key: (c) => {
1260
+ const url = new URL(c.req.url)
1261
+ url.search = ''
1262
+ return url.toString()
1263
+ },
1264
+ }),
1265
+ (c) => {
1266
+ if (Auth.narrowAccess) return Auth.paidAccessError(c)
1267
+ if (!c.get('webhook')) return notEnabled(c)
1268
+ return c.json(
1269
+ Response.validated(schema.getWebhookEventTypes.Response, {
1270
+ data: eventTypeDescriptions.map((eventType) => ({ ...eventType })),
1271
+ }),
1272
+ 200,
1273
+ )
1274
+ },
1275
+ )
1276
+ .post(
1277
+ '/',
1278
+ Auth.policy({ apiKey: { scopes: ['webhooks:write'] } }),
1279
+ OpenApi.validate('json', schema.createWebhook.Body, {
1280
+ code: 'body_invalid',
1281
+ message: 'Check the request body and try again.',
1282
+ }),
1283
+ OpenApi.describeRoute({
1284
+ description:
1285
+ 'Create a webhook subscription so Tempo can POST signed onchain events to your URL. The signing secret is shown only once, and each call creates a separate subscription.',
1286
+ hide: hidden,
1287
+ operationId: 'createWebhook',
1288
+ responses: OpenApi.responses({
1289
+ errors: { 403: 'You have reached the webhook subscription limit.' },
1290
+ success: {
1291
+ description:
1292
+ 'The created webhook subscription, including the one-time signing secret.',
1293
+ schema: schema.createWebhook.Response,
1294
+ },
1295
+ }),
1296
+ summary: 'Create webhook',
1297
+ tags: ['Webhooks'],
1298
+ }),
1299
+ async (c) => {
1300
+ if (Auth.narrowAccess) return Auth.accessError(c)
1301
+ if (!c.get('webhook')) return notEnabled(c)
1302
+ if (OpenApi.narrowValidation)
1303
+ return OpenApi.validationError(c, {
1304
+ code: 'body_invalid',
1305
+ message: 'Check the request body and try again.',
1306
+ })
1307
+
1308
+ const webhook = c.get('webhook')!
1309
+ const store = webhook.store
1310
+ const owner = ownerFor(c)
1311
+ if (!owner) return unauthorized(c)
1312
+
1313
+ const body = c.req.valid('json')
1314
+ const chainId = body.chainId ?? c.get('chainId')
1315
+
1316
+ try {
1317
+ const subscription = await Webhooks.createSubscription(
1318
+ store,
1319
+ {
1320
+ chainId,
1321
+ ...(body.context === undefined ? {} : { context: body.context }),
1322
+ destination: body.destination,
1323
+ eventType: body.eventType,
1324
+ filters: body.filters,
1325
+ owner,
1326
+ ...(owner.type === 'mpp' ? { ttl: mppTtlMs } : {}),
1327
+ },
1328
+ { maxPerOwner: webhook.maxPerOwner ?? defaultMaxPerOwner },
1329
+ )
1330
+ return c.json(
1331
+ Response.validated(schema.createWebhook.Response, toCreated(subscription)),
1332
+ 200,
1333
+ )
1334
+ } catch (cause) {
1335
+ return mutationError(c, cause)
1336
+ }
1337
+ },
1338
+ )
1339
+ .get(
1340
+ '/',
1341
+ Auth.policy({ apiKey: { scopes: ['webhooks:read'] } }),
1342
+ OpenApi.validate('query', schema.listWebhooks.Query, {
1343
+ code: 'query_invalid',
1344
+ message: 'Check the query parameters and try again.',
1345
+ }),
1346
+ OpenApi.describeRoute({
1347
+ description: 'List your webhook subscriptions.',
1348
+ hide: hidden,
1349
+ operationId: 'listWebhooks',
1350
+ responses: OpenApi.responses({
1351
+ success: {
1352
+ description: 'A page of webhook subscriptions.',
1353
+ schema: schema.listWebhooks.Response,
1354
+ },
1355
+ }),
1356
+ summary: 'List webhooks',
1357
+ tags: ['Webhooks'],
1358
+ }),
1359
+ async (c) => {
1360
+ if (Auth.narrowAccess) return Auth.accessError(c)
1361
+ if (!c.get('webhook')) return notEnabled(c)
1362
+ if (OpenApi.narrowValidation)
1363
+ return OpenApi.validationError(c, {
1364
+ code: 'query_invalid',
1365
+ message: 'Check the query parameters and try again.',
1366
+ })
1367
+
1368
+ const store = c.get('webhook')!.store
1369
+ const owner = ownerFor(c)
1370
+ if (!owner) return unauthorized(c)
1371
+
1372
+ const { cursor, include, limit, page } = c.req.valid('query')
1373
+ try {
1374
+ // `totalCount` is opt-in and exact (the per-owner set is bounded);
1375
+ // run it concurrently with the page list. Best-effort: a failure
1376
+ // omits `meta` rather than failing the page.
1377
+ const countPromise = include.includes('totalCount')
1378
+ ? Webhooks.countSubscriptions(store, owner).catch(() => undefined)
1379
+ : undefined
1380
+
1381
+ // Fetch one extra to detect a further page without a second round-trip.
1382
+ // `page` translates to a positional slice of `(page - 1) * limit` rows.
1383
+ const rows = await Webhooks.listSubscriptions(store, owner, {
1384
+ cursor,
1385
+ limit: limit + 1,
1386
+ offset: page !== undefined && page > 1 ? (page - 1) * limit : undefined,
1387
+ })
1388
+ const hasMore = rows.length > limit
1389
+ const data = (hasMore ? rows.slice(0, limit) : rows).map(toPublic)
1390
+ const nextCursor = hasMore ? (data[data.length - 1]?.id ?? null) : null
1391
+
1392
+ const totalCount = countPromise ? await countPromise : undefined
1393
+
1394
+ return c.json(
1395
+ Response.validated(schema.listWebhooks.Response, {
1396
+ data,
1397
+ ...(totalCount !== undefined
1398
+ ? { meta: { totalCountCapped: false, totalCount } }
1399
+ : {}),
1400
+ nextCursor,
1401
+ }),
1402
+ 200,
1403
+ )
1404
+ } catch (cause) {
1405
+ return Response.upstream(c, cause)
1406
+ }
1407
+ },
1408
+ )
1409
+ // The `:id` param is constrained to the `wh_…` id shape so it can't also
1410
+ // match the sibling static route `GET /webhooks/event-types`. Without the
1411
+ // constraint, this API-key-only route's policy would be collected for the
1412
+ // event-types request and override its (intentionally public) access.
1413
+ .get(
1414
+ '/:id{wh_[A-Za-z0-9_-]+}',
1415
+ Auth.policy({ apiKey: { scopes: ['webhooks:read'] } }),
1416
+ OpenApi.validate('param', schema.getWebhook.Params, {
1417
+ code: 'param_invalid',
1418
+ message: 'Check the path parameters and try again.',
1419
+ }),
1420
+ OpenApi.describeRoute({
1421
+ description: 'Get one webhook subscription by its ID.',
1422
+ hide: hidden,
1423
+ operationId: 'getWebhook',
1424
+ responses: OpenApi.responses({
1425
+ errors: {
1426
+ 404: {
1427
+ description: 'No webhook subscription was found for that ID.',
1428
+ codes: ['webhook_not_found'],
1429
+ },
1430
+ },
1431
+ success: {
1432
+ description: 'The requested webhook subscription.',
1433
+ schema: schema.getWebhook.Response,
1434
+ },
1435
+ }),
1436
+ summary: 'Get webhook',
1437
+ tags: ['Webhooks'],
1438
+ }),
1439
+ async (c) => {
1440
+ if (Auth.narrowAccess) return Auth.accessError(c)
1441
+ if (!c.get('webhook')) return notEnabled(c)
1442
+ if (OpenApi.narrowValidation)
1443
+ return OpenApi.validationError(c, {
1444
+ code: 'param_invalid',
1445
+ message: 'Check the path parameters and try again.',
1446
+ })
1447
+
1448
+ const store = c.get('webhook')!.store
1449
+ const owner = ownerFor(c)
1450
+ if (!owner) return unauthorized(c)
1451
+
1452
+ const { id } = c.req.valid('param')
1453
+ try {
1454
+ const subscription = await Webhooks.getSubscription(store, owner, id)
1455
+ if (!subscription) return notFound(c)
1456
+ return c.json(
1457
+ Response.validated(schema.getWebhook.Response, toPublic(subscription)),
1458
+ 200,
1459
+ )
1460
+ } catch (cause) {
1461
+ return Response.upstream(c, cause)
1462
+ }
1463
+ },
1464
+ )
1465
+ .get(
1466
+ '/:id{wh_[A-Za-z0-9_-]+}/deliveries',
1467
+ Auth.policy({ apiKey: { scopes: ['webhooks:read'] } }),
1468
+ OpenApi.validate('param', schema.listWebhookDeliveries.Params, {
1469
+ code: 'param_invalid',
1470
+ message: 'Check the path parameters and try again.',
1471
+ }),
1472
+ OpenApi.validate('query', schema.listWebhookDeliveries.Query, {
1473
+ code: 'query_invalid',
1474
+ message: 'Check the query parameters and try again.',
1475
+ }),
1476
+ OpenApi.describeRoute({
1477
+ description: 'List delivery attempts for a webhook subscription.',
1478
+ hide: hidden,
1479
+ operationId: 'listWebhookDeliveries',
1480
+ responses: OpenApi.responses({
1481
+ errors: {
1482
+ 404: {
1483
+ description: 'No webhook subscription was found for that ID.',
1484
+ codes: ['webhook_not_found'],
1485
+ },
1486
+ },
1487
+ success: {
1488
+ description: 'A newest-first page of webhook delivery attempts.',
1489
+ schema: schema.listWebhookDeliveries.Response,
1490
+ },
1491
+ }),
1492
+ summary: 'List webhook deliveries',
1493
+ tags: ['Webhooks'],
1494
+ }),
1495
+ async (c) => {
1496
+ if (Auth.narrowAccess) return Auth.accessError(c)
1497
+ if (!c.get('webhook')) return notEnabled(c)
1498
+ if (OpenApi.narrowValidation)
1499
+ return OpenApi.validationError(c, {
1500
+ code: 'param_invalid',
1501
+ message: 'Check the path parameters and try again.',
1502
+ })
1503
+ if (OpenApi.narrowValidation)
1504
+ return OpenApi.validationError(c, {
1505
+ code: 'query_invalid',
1506
+ message: 'Check the query parameters and try again.',
1507
+ })
1508
+
1509
+ const store = c.get('webhook')!.store
1510
+ const owner = ownerFor(c)
1511
+ if (!owner) return unauthorized(c)
1512
+
1513
+ const { id } = c.req.valid('param')
1514
+ const { cursor, include, limit, page } = c.req.valid('query')
1515
+ try {
1516
+ // Owner-scope first: resolving the subscription under the caller's owner
1517
+ // key 404s ids they don't own (indistinguishable from "doesn't exist").
1518
+ const subscription = await Webhooks.getSubscription(store, owner, id)
1519
+ if (!subscription) return notFound(c)
1520
+
1521
+ // `totalCount` is opt-in and exact (the delivery log is TTL-bounded);
1522
+ // run it concurrently with the page list. Best-effort: a failure
1523
+ // omits `meta` rather than failing the page.
1524
+ const countPromise = include.includes('totalCount')
1525
+ ? Webhooks.countDeliveries(store, id).catch(() => undefined)
1526
+ : undefined
1527
+
1528
+ // Fetch one extra to detect a further page without a second round-trip.
1529
+ // `page` translates to a positional slice of `(page - 1) * limit` rows.
1530
+ const rows = await Webhooks.listDeliveries(store, id, {
1531
+ cursor,
1532
+ limit: limit + 1,
1533
+ offset: page !== undefined && page > 1 ? (page - 1) * limit : undefined,
1534
+ })
1535
+ const hasMore = rows.length > limit
1536
+ const data = hasMore ? rows.slice(0, limit) : rows
1537
+ const nextCursor = hasMore ? (data[data.length - 1]?.id ?? null) : null
1538
+
1539
+ const totalCount = countPromise ? await countPromise : undefined
1540
+
1541
+ return c.json(
1542
+ Response.validated(schema.listWebhookDeliveries.Response, {
1543
+ data,
1544
+ ...(totalCount !== undefined
1545
+ ? { meta: { totalCountCapped: false, totalCount } }
1546
+ : {}),
1547
+ nextCursor,
1548
+ }),
1549
+ 200,
1550
+ )
1551
+ } catch (cause) {
1552
+ return Response.upstream(c, cause)
1553
+ }
1554
+ },
1555
+ )
1556
+ .post(
1557
+ '/:id{wh_[A-Za-z0-9_-]+}/ping',
1558
+ Auth.policy({ apiKey: { scopes: ['webhooks:write'] } }),
1559
+ OpenApi.validate('param', schema.pingWebhook.Params, {
1560
+ code: 'param_invalid',
1561
+ message: 'Check the path parameters and try again.',
1562
+ }),
1563
+ OpenApi.describeRoute({
1564
+ description:
1565
+ 'Send a signed `ping` event to your webhook URL to test delivery and signature verification.',
1566
+ hide: hidden,
1567
+ operationId: 'pingWebhook',
1568
+ responses: OpenApi.responses({
1569
+ errors: {
1570
+ 404: {
1571
+ description: 'No webhook subscription was found for that ID.',
1572
+ codes: ['webhook_not_found'],
1573
+ },
1574
+ },
1575
+ success: {
1576
+ description: 'Result of the synthetic ping delivery.',
1577
+ schema: schema.pingWebhook.Response,
1578
+ },
1579
+ }),
1580
+ summary: 'Ping webhook',
1581
+ tags: ['Webhooks'],
1582
+ }),
1583
+ async (c) => {
1584
+ if (Auth.narrowAccess) return Auth.accessError(c)
1585
+ if (!c.get('webhook')) return notEnabled(c)
1586
+ if (OpenApi.narrowValidation)
1587
+ return OpenApi.validationError(c, {
1588
+ code: 'param_invalid',
1589
+ message: 'Check the path parameters and try again.',
1590
+ })
1591
+
1592
+ const store = c.get('webhook')!.store
1593
+ const owner = ownerFor(c)
1594
+ if (!owner) return unauthorized(c)
1595
+
1596
+ const { id } = c.req.valid('param')
1597
+ try {
1598
+ // Owner-scope first so unowned/unknown ids are indistinguishable (404).
1599
+ const subscription = await Webhooks.getSubscription(store, owner, id)
1600
+ if (!subscription) return notFound(c)
1601
+
1602
+ // Inline delivery (not the queue) so the caller gets an immediate
1603
+ // result. `ping` never mutates the subscription's failure/lifecycle
1604
+ // state — a test must not disable a healthy endpoint.
1605
+ const { envelope, result } = await Webhooks.ping(store, subscription)
1606
+ return c.json(
1607
+ Response.validated(schema.pingWebhook.Response, {
1608
+ delivered: result.ok,
1609
+ eventId: envelope.id,
1610
+ ...(result.error === undefined ? {} : { error: result.error }),
1611
+ ...(result.durationMs === undefined ? {} : { responseMs: result.durationMs }),
1612
+ ...(result.status === undefined ? {} : { responseStatus: result.status }),
1613
+ }),
1614
+ 200,
1615
+ )
1616
+ } catch (cause) {
1617
+ return Response.upstream(c, cause)
1618
+ }
1619
+ },
1620
+ )
1621
+ .post(
1622
+ '/:id{wh_[A-Za-z0-9_-]+}/deliveries/:deliveryId/retry',
1623
+ Auth.policy({ apiKey: { scopes: ['webhooks:write'] } }),
1624
+ OpenApi.validate('param', schema.retryWebhookDelivery.Params, {
1625
+ code: 'param_invalid',
1626
+ message: 'Check the path parameters and try again.',
1627
+ }),
1628
+ OpenApi.describeRoute({
1629
+ description: 'Replay a previous webhook delivery and update the delivery result.',
1630
+ hide: hidden,
1631
+ operationId: 'retryWebhookDelivery',
1632
+ responses: OpenApi.responses({
1633
+ errors: {
1634
+ 404: {
1635
+ description: 'No webhook subscription or delivery was found for those IDs.',
1636
+ codes: ['webhook_not_found', 'delivery_not_found'],
1637
+ },
1638
+ },
1639
+ success: {
1640
+ description: 'Result of replaying the webhook delivery.',
1641
+ schema: schema.retryWebhookDelivery.Response,
1642
+ },
1643
+ }),
1644
+ summary: 'Retry webhook delivery',
1645
+ tags: ['Webhooks'],
1646
+ }),
1647
+ async (c) => {
1648
+ if (Auth.narrowAccess) return Auth.accessError(c)
1649
+ if (!c.get('webhook')) return notEnabled(c)
1650
+ if (OpenApi.narrowValidation)
1651
+ return OpenApi.validationError(c, {
1652
+ code: 'param_invalid',
1653
+ message: 'Check the path parameters and try again.',
1654
+ })
1655
+
1656
+ const store = c.get('webhook')!.store
1657
+ const owner = ownerFor(c)
1658
+ if (!owner) return unauthorized(c)
1659
+
1660
+ const { deliveryId, id } = c.req.valid('param')
1661
+ try {
1662
+ // Owner-scope the subscription first so unowned ids 404 like elsewhere.
1663
+ const subscription = await Webhooks.getSubscription(store, owner, id)
1664
+ if (!subscription) return notFound(c)
1665
+
1666
+ const delivery = await Webhooks.getDelivery(store, id, deliveryId)
1667
+ if (!delivery)
1668
+ return Response.error(c, {
1669
+ code: 'delivery_not_found',
1670
+ message: 'Delivery not found',
1671
+ status: 404,
1672
+ })
1673
+
1674
+ // Replay the exact persisted envelope inline. Unlike `ping`, this is a
1675
+ // real event, so the outcome updates the subscription's failure state.
1676
+ const result = await Webhooks.deliverAndRecord(store, subscription, delivery.envelope)
1677
+ return c.json(
1678
+ Response.validated(schema.retryWebhookDelivery.Response, {
1679
+ delivered: result.ok,
1680
+ eventId: delivery.envelope.id,
1681
+ ...(result.error === undefined ? {} : { error: result.error }),
1682
+ ...(result.durationMs === undefined ? {} : { responseMs: result.durationMs }),
1683
+ ...(result.status === undefined ? {} : { responseStatus: result.status }),
1684
+ }),
1685
+ 200,
1686
+ )
1687
+ } catch (cause) {
1688
+ return Response.upstream(c, cause)
1689
+ }
1690
+ },
1691
+ )
1692
+ .patch(
1693
+ '/:id{wh_[A-Za-z0-9_-]+}',
1694
+ Auth.policy({ apiKey: { scopes: ['webhooks:write'] } }),
1695
+ OpenApi.validate('param', schema.updateWebhook.Params, {
1696
+ code: 'param_invalid',
1697
+ message: 'Check the path parameters and try again.',
1698
+ }),
1699
+ OpenApi.validate('json', schema.updateWebhook.Body, {
1700
+ code: 'body_invalid',
1701
+ message: 'Check the request body and try again.',
1702
+ }),
1703
+ OpenApi.describeRoute({
1704
+ description: 'Update a webhook subscription URL, filters, or delivery status.',
1705
+ hide: hidden,
1706
+ operationId: 'updateWebhook',
1707
+ responses: OpenApi.responses({
1708
+ errors: {
1709
+ 404: {
1710
+ description: 'No webhook subscription was found for that ID.',
1711
+ codes: ['webhook_not_found'],
1712
+ },
1713
+ },
1714
+ success: {
1715
+ description: 'The updated webhook subscription.',
1716
+ schema: schema.updateWebhook.Response,
1717
+ },
1718
+ }),
1719
+ summary: 'Update webhook',
1720
+ tags: ['Webhooks'],
1721
+ }),
1722
+ async (c) => {
1723
+ if (Auth.narrowAccess) return Auth.accessError(c)
1724
+ if (!c.get('webhook')) return notEnabled(c)
1725
+ if (OpenApi.narrowValidation)
1726
+ return OpenApi.validationError(c, {
1727
+ code: 'param_invalid',
1728
+ message: 'Check the path parameters and try again.',
1729
+ })
1730
+ if (OpenApi.narrowValidation)
1731
+ return OpenApi.validationError(c, {
1732
+ code: 'body_invalid',
1733
+ message: 'Check the request body and try again.',
1734
+ })
1735
+
1736
+ const store = c.get('webhook')!.store
1737
+ const owner = ownerFor(c)
1738
+ if (!owner) return unauthorized(c)
1739
+
1740
+ const { id } = c.req.valid('param')
1741
+ const patch = c.req.valid('json')
1742
+ try {
1743
+ // The PATCH body accepts an opaque `filters` map (it can't know the
1744
+ // subscription's event type up front), so revalidate the incoming
1745
+ // filters against the stored `eventType` and fail closed before
1746
+ // persisting — otherwise a bad filter would silently widen the match
1747
+ // set at scan time.
1748
+ if (patch.filters !== undefined) {
1749
+ const existing = await Webhooks.getSubscription(store, owner, id)
1750
+ if (!existing) return notFound(c)
1751
+ parseFilters(existing.eventType, patch.filters)
1752
+ }
1753
+ const subscription = await Webhooks.updateSubscription(store, owner, id, patch)
1754
+ if (!subscription) return notFound(c)
1755
+ return c.json(
1756
+ Response.validated(schema.updateWebhook.Response, toPublic(subscription)),
1757
+ 200,
1758
+ )
1759
+ } catch (cause) {
1760
+ return mutationError(c, cause)
1761
+ }
1762
+ },
1763
+ )
1764
+ .delete(
1765
+ '/:id{wh_[A-Za-z0-9_-]+}',
1766
+ Auth.policy({ apiKey: { scopes: ['webhooks:write'] } }),
1767
+ OpenApi.validate('param', schema.deleteWebhook.Params, {
1768
+ code: 'param_invalid',
1769
+ message: 'Check the path parameters and try again.',
1770
+ }),
1771
+ OpenApi.describeRoute({
1772
+ description: 'Delete a webhook subscription and stop future deliveries immediately.',
1773
+ hide: hidden,
1774
+ operationId: 'deleteWebhook',
1775
+ responses: OpenApi.responses({
1776
+ errors: {
1777
+ 404: {
1778
+ description: 'No webhook subscription was found for that ID.',
1779
+ codes: ['webhook_not_found'],
1780
+ },
1781
+ },
1782
+ success: {
1783
+ description: 'Confirmation that the webhook subscription was deleted.',
1784
+ schema: schema.deleteWebhook.Response,
1785
+ },
1786
+ }),
1787
+ summary: 'Delete webhook',
1788
+ tags: ['Webhooks'],
1789
+ }),
1790
+ async (c) => {
1791
+ if (Auth.narrowAccess) return Auth.accessError(c)
1792
+ if (!c.get('webhook')) return notEnabled(c)
1793
+ if (OpenApi.narrowValidation)
1794
+ return OpenApi.validationError(c, {
1795
+ code: 'param_invalid',
1796
+ message: 'Check the path parameters and try again.',
1797
+ })
1798
+
1799
+ const store = c.get('webhook')!.store
1800
+ const owner = ownerFor(c)
1801
+ if (!owner) return unauthorized(c)
1802
+
1803
+ const { id } = c.req.valid('param')
1804
+ try {
1805
+ const deleted = await Webhooks.deleteSubscription(store, owner, id)
1806
+ if (!deleted) return notFound(c)
1807
+ return c.json(Response.validated(schema.deleteWebhook.Response, { id }), 200)
1808
+ } catch (cause) {
1809
+ return Response.upstream(c, cause)
1810
+ }
1811
+ },
1812
+ )
1813
+ )
1814
+ }
1815
+
1816
+ export declare namespace webhooks {
1817
+ /** Options for creating webhook routes. */
1818
+ type Options = {
1819
+ /** Whether webhook state is configured and routes should appear in OpenAPI. */
1820
+ enabled?: boolean | undefined
1821
+ }
1822
+ }
1823
+
1824
+ /**
1825
+ * Largest representable in-block index, used as an end-of-block cursor sentinel
1826
+ * so a fully-scanned block advances the cursor strictly past it (no row index
1827
+ * can equal it).
1828
+ */
1829
+ const endOfBlockIndex = 2_147_483_647
1830
+
1831
+ /** A scanned row paired with its `(block, index)` keyset position. */
1832
+ type ScanItem = { cursor: readonly [number, number]; data: unknown }
1833
+
1834
+ /**
1835
+ * The result of scanning one subscription for a tick. Two flavours:
1836
+ *
1837
+ * - **window** (the RPC scanners): every block in the confirmed window was
1838
+ * inspected in full, so `scannedThrough` is the highest block fully scanned.
1839
+ * The poller advances the cursor to `(scannedThrough, endOfBlockIndex)` on a
1840
+ * clean scan even when no rows matched — a rare-filter subscription never
1841
+ * rescans the same empty range.
1842
+ * - **keyset** (the TIDX legacy scanners): rows come back `LIMIT`-bounded, so
1843
+ * the page only reports whether `hasMore` rows remain past it; the poller
1844
+ * advances only past the last dispatched row (jumping to the window end only
1845
+ * when the page was empty and complete).
1846
+ */
1847
+ type ScanPage = {
1848
+ hasMore?: boolean | undefined
1849
+ items: readonly ScanItem[]
1850
+ scannedThrough?: number | undefined
1851
+ }
1852
+
1853
+ /**
1854
+ * Polls the chain over RPC for new events and dispatches a signed envelope per
1855
+ * matching row, advancing each active subscription's `(block, index)` keyset
1856
+ * cursor.
1857
+ *
1858
+ * The new monitoring event types (`log:emitted`, `block:created`,
1859
+ * `transaction:included`) scan a **narrow confirmed window** `[cursor, head -
1860
+ * confirmations]` directly over JSON-RPC (`eth_getLogs`, `eth_getBlockByNumber`,
1861
+ * `eth_getBlockReceipts`) — the same data sources the indexer itself consumes —
1862
+ * so they need no indexer and stay fresh. The pre-existing `token:transfer`
1863
+ * type continues to read its humanized shape from TIDX, clamped to the indexed
1864
+ * head so it never runs ahead of the indexer.
1865
+ *
1866
+ * The chain head is injectable (tests pin a fixed historical window); only
1867
+ * blocks at least `confirmations` behind head are emitted, giving at-least-once,
1868
+ * finalized-ish semantics (re-delivery overlap is harmless because the receiver
1869
+ * dedupes on the stable `event.id`).
1870
+ *
1871
+ * Cursor advances only past successfully dispatched rows, so a dispatch failure
1872
+ * pauses that subscription at the last good position and the next tick resumes.
1873
+ */
1874
+ export async function poll(options: poll.Options): Promise<poll.Result> {
1875
+ const {
1876
+ batchSize = 100,
1877
+ chainId,
1878
+ client,
1879
+ confirmations = 2,
1880
+ eventTypes = ['token:transfer', 'transaction:included', 'log:emitted', 'block:created'],
1881
+ maxBlocks = 1_000,
1882
+ store,
1883
+ tidx,
1884
+ } = options
1885
+ const now = options.now ?? (() => new Date())
1886
+ // Default (self-host) transport: deliver inline and record the outcome so a
1887
+ // sustained-failure subscription auto-disables. On Cloudflare, callers pass a
1888
+ // Queue producer instead and the consumer runs `deliverAndRecord`.
1889
+ const dispatch =
1890
+ options.dispatch ??
1891
+ (({ envelope, subscription }) => Webhooks.deliverAndRecord(store, subscription, envelope))
1892
+
1893
+ // Head resolution is lazy and per-source. RPC types work back from the live
1894
+ // RPC head; the legacy TIDX types from the indexed head (never ahead of the
1895
+ // indexer). A pinned `headBlockNumber` (tests) overrides both.
1896
+ const override = options.headBlockNumber
1897
+ let rpcHead: number | undefined = override === undefined ? undefined : Number(override)
1898
+ const resolveRpcHead = async () => (rpcHead ??= await getRpcHead(client))
1899
+ let tidxHead: number | undefined = override === undefined ? undefined : Number(override)
1900
+ const resolveTidxHead = async () =>
1901
+ (tidxHead ??= tidx ? await getIndexedHead(tidx, chainId) : await resolveRpcHead())
1902
+
1903
+ const rpcSafeBlock = Math.max(0, (await resolveRpcHead()) - confirmations)
1904
+ // Per-tick lazy cache so multiple subscriptions of the same/related types
1905
+ // share fetched blocks and receipts instead of refetching the same window.
1906
+ const cache = createChainCache(client)
1907
+ const result: poll.Result = {
1908
+ chainId,
1909
+ dispatched: 0,
1910
+ failed: 0,
1911
+ safeBlock: rpcSafeBlock,
1912
+ scanned: 0,
1913
+ subscriptions: 0,
1914
+ }
1915
+
1916
+ for (const eventType of eventTypes) {
1917
+ const legacy = eventType === 'token:transfer'
1918
+ const safeBlock = legacy ? Math.max(0, (await resolveTidxHead()) - confirmations) : rpcSafeBlock
1919
+ const subscriptions = await Webhooks.listActive(store, chainId, eventType)
1920
+ for (const subscription of subscriptions) {
1921
+ result.subscriptions++
1922
+ const cursor = await Webhooks.getCursor(store, subscription.id)
1923
+ if (cursor === null) {
1924
+ // First time we've seen this subscription: anchor it at the head of the
1925
+ // confirmed window so we never backfill from genesis. Events start
1926
+ // flowing from the next tick onward.
1927
+ await Webhooks.setCursor(
1928
+ store,
1929
+ subscription.id,
1930
+ Cursor.encode([safeBlock, endOfBlockIndex]),
1931
+ )
1932
+ continue
1933
+ }
1934
+ const page = await scanSubscription({
1935
+ batchSize,
1936
+ cache,
1937
+ client,
1938
+ cursor,
1939
+ maxBlocks,
1940
+ safeBlock,
1941
+ subscription,
1942
+ tidx,
1943
+ })
1944
+ result.scanned += page.items.length
1945
+
1946
+ let accepted: readonly [number, number] | undefined
1947
+ let failed = false
1948
+ for (const item of page.items) {
1949
+ // Enrich `token:transfer` rows with best-effort token metadata so
1950
+ // downstream destinations (Slack human amounts, richer `url`/`betterstack`
1951
+ // payloads) can render `amount` in token units. Non-fatal on failure.
1952
+ const data =
1953
+ eventType === 'token:transfer'
1954
+ ? await withTokenMetadata(item.data, { chainId, client, store })
1955
+ : item.data
1956
+ const envelope = Webhooks.buildEnvelope({
1957
+ blockNumber: item.cursor[0],
1958
+ createdAt: now(),
1959
+ data,
1960
+ logIndex: item.cursor[1],
1961
+ subscription,
1962
+ })
1963
+ const delivered = await dispatch({ envelope, subscription })
1964
+ if (!delivered.ok) {
1965
+ result.failed++
1966
+ failed = true
1967
+ break
1968
+ }
1969
+ accepted = item.cursor
1970
+ result.dispatched++
1971
+ }
1972
+
1973
+ // Advance the high-water mark. On a dispatch failure, stop at the last
1974
+ // accepted row so the failed one retries next tick. Otherwise: window
1975
+ // scanners jump to the end of the fully-scanned range (even with zero
1976
+ // matches); keyset scanners advance past the last dispatched row, or — on
1977
+ // a complete empty page — to the end of the confirmed window.
1978
+ if (failed) {
1979
+ if (accepted) await Webhooks.setCursor(store, subscription.id, Cursor.encode([...accepted]))
1980
+ } else if (page.scannedThrough !== undefined)
1981
+ await Webhooks.setCursor(
1982
+ store,
1983
+ subscription.id,
1984
+ Cursor.encode([page.scannedThrough, endOfBlockIndex]),
1985
+ )
1986
+ else if (accepted)
1987
+ await Webhooks.setCursor(store, subscription.id, Cursor.encode([...accepted]))
1988
+ else if (!page.hasMore)
1989
+ await Webhooks.setCursor(
1990
+ store,
1991
+ subscription.id,
1992
+ Cursor.encode([safeBlock, endOfBlockIndex]),
1993
+ )
1994
+ }
1995
+ }
1996
+
1997
+ return result
1998
+ }
1999
+
2000
+ export declare namespace poll {
2001
+ /** A row to dispatch, paired with its owning subscription. */
2002
+ type Dispatchable = {
2003
+ /** The signed-on-dispatch delivery envelope. */
2004
+ envelope: Webhooks.Envelope
2005
+ /** The subscription the event matched. */
2006
+ subscription: Webhooks.Subscription
2007
+ }
2008
+ /** Options for {@link poll}. */
2009
+ type Options = {
2010
+ /** Max matching rows scanned per subscription per tick (default 100). */
2011
+ batchSize?: number | undefined
2012
+ /** Chain to poll. Callers loop over chains. */
2013
+ chainId: number
2014
+ /** viem RPC client for the chain (used by the RPC monitoring scanners). */
2015
+ client: Viem.getClient.ReturnType
2016
+ /** Reorg-safety window: only emit blocks this far behind head (default 2). */
2017
+ confirmations?: number | undefined
2018
+ /**
2019
+ * Delivery transport. Defaults to inline {@link Webhooks.deliverAndRecord} (self-host);
2020
+ * on Cloudflare pass a Queue producer so retry/backoff/DLQ are native.
2021
+ */
2022
+ dispatch?: ((input: Dispatchable) => Promise<Webhooks.Result>) | undefined
2023
+ /** Event types to poll (default all). */
2024
+ eventTypes?: readonly EventType[] | undefined
2025
+ /** Current chain head; the poller emits up to `head - confirmations`. */
2026
+ headBlockNumber?: bigint | number | undefined
2027
+ /** Backfill cap: most blocks an RPC scanner inspects per tick (default 1000). */
2028
+ maxBlocks?: number | undefined
2029
+ /** Clock for envelope timestamps; injectable for tests. */
2030
+ now?: (() => Date) | undefined
2031
+ /** Webhook state store (subscriptions + cursors). */
2032
+ store: Store.State
2033
+ /** TIDX query client for the legacy `token:transfer` type. */
2034
+ tidx?: Tidx.Client | undefined
2035
+ }
2036
+ /** Summary counters for one poll tick. */
2037
+ type Result = {
2038
+ /** Chain that was polled. */
2039
+ chainId: number
2040
+ /** Rows successfully dispatched. */
2041
+ dispatched: number
2042
+ /** Rows whose dispatch failed (pausing that subscription). */
2043
+ failed: number
2044
+ /** Highest confirmed block emitted this tick (`head - confirmations`). */
2045
+ safeBlock: number
2046
+ /** Total rows scanned across all subscriptions. */
2047
+ scanned: number
2048
+ /** Active subscriptions visited. */
2049
+ subscriptions: number
2050
+ }
2051
+ }
2052
+
2053
+ /**
2054
+ * Attaches best-effort `{ symbol, decimals }` token metadata to a
2055
+ * `token:transfer` row so destinations can render human amounts. Reads the row's
2056
+ * `address` (the TIP-20 contract), resolves metadata via a memoized RPC lookup,
2057
+ * and returns the row unchanged when the address is missing or the lookup fails.
2058
+ */
2059
+ async function withTokenMetadata(
2060
+ data: unknown,
2061
+ deps: {
2062
+ chainId: number
2063
+ client: Viem.getClient.ReturnType
2064
+ store: Store.State
2065
+ },
2066
+ ): Promise<unknown> {
2067
+ if (typeof data !== 'object' || data === null) return data
2068
+ const address = (data as Record<string, unknown>)['address']
2069
+ if (typeof address !== 'string') return data
2070
+ const token = await resolveTokenMetadata(address, deps)
2071
+ return token ? { ...(data as Record<string, unknown>), token } : data
2072
+ }
2073
+
2074
+ /**
2075
+ * Resolves a token's `{ symbol, decimals }` via `token.getMetadata`, memoized per
2076
+ * `(chainId, address)` with a short TTL so a busy transfer stream shares one RPC
2077
+ * lookup per token. Returns `undefined` on any failure — metadata is a rendering
2078
+ * nicety, never a delivery blocker.
2079
+ */
2080
+ async function resolveTokenMetadata(
2081
+ address: string,
2082
+ deps: { chainId: number; client: Viem.getClient.ReturnType; store: Store.State },
2083
+ ): Promise<{ decimals: number; symbol: string } | undefined> {
2084
+ try {
2085
+ return await Store.memoize(
2086
+ async () => {
2087
+ const meta = await deps.client.token.getMetadata({ token: address as `0x${string}` })
2088
+ return { decimals: meta.decimals, symbol: meta.symbol }
2089
+ },
2090
+ {
2091
+ key: `webhook:token-meta:v1:${deps.chainId}:${address}`,
2092
+ store: deps.store,
2093
+ ttl: Ttl.minutes(5),
2094
+ },
2095
+ )
2096
+ } catch {
2097
+ // Metadata is best-effort; fall back to base-unit rendering.
2098
+ return undefined
2099
+ }
2100
+ }
2101
+
2102
+ /** Returns the live RPC head block number for the chain. */
2103
+ export async function getRpcHead(client: Viem.getClient.ReturnType): Promise<number> {
2104
+ return Number(await client.getBlockNumber())
2105
+ }
2106
+
2107
+ /**
2108
+ * Returns the highest block number TIDX has indexed for a chain (0 when none).
2109
+ * The legacy `token:transfer` type works back from this so it never scans past
2110
+ * the indexer.
2111
+ */
2112
+ export async function getIndexedHead(tidx: Tidx.Client, chainId: number): Promise<number> {
2113
+ const result = await tidx.fetch({
2114
+ chainId,
2115
+ query: 'SELECT num FROM blocks ORDER BY num DESC LIMIT 1' as string,
2116
+ })
2117
+ return Value.toNumber(result.rows[0]?.['num']) ?? 0
2118
+ }
2119
+
2120
+ /**
2121
+ * Scans new events for one subscription. The RPC monitoring types
2122
+ * (`log:emitted` / `block:created` / `transaction:included`) read a confirmed
2123
+ * window directly over JSON-RPC; the legacy `token:transfer` type dispatches to
2124
+ * its TIDX read core (`Transfers.scan`).
2125
+ */
2126
+ async function scanSubscription(options: {
2127
+ batchSize: number
2128
+ cache: ChainCache
2129
+ client: Viem.getClient.ReturnType
2130
+ cursor: string | undefined
2131
+ maxBlocks: number
2132
+ safeBlock: number
2133
+ subscription: Webhooks.Subscription
2134
+ tidx: Tidx.Client | undefined
2135
+ }): Promise<ScanPage> {
2136
+ const { batchSize, cache, client, cursor, maxBlocks, safeBlock, subscription, tidx } = options
2137
+ // Stored `chainId` was validated against `Schema.ChainId` at create time.
2138
+ const chainId = subscription.chainId as z.output<typeof Schema.ChainId>
2139
+
2140
+ if (subscription.eventType === 'log:emitted')
2141
+ return scanLogs({
2142
+ cache,
2143
+ client,
2144
+ cursor,
2145
+ filters: parseFilters('log:emitted', subscription.filters),
2146
+ maxBlocks,
2147
+ safeBlock,
2148
+ })
2149
+
2150
+ if (subscription.eventType === 'block:created')
2151
+ return scanBlocks({
2152
+ cache,
2153
+ cursor,
2154
+ filters: parseFilters('block:created', subscription.filters),
2155
+ maxBlocks,
2156
+ safeBlock,
2157
+ })
2158
+
2159
+ if (subscription.eventType === 'transaction:included')
2160
+ return scanTxs({
2161
+ cache,
2162
+ cursor,
2163
+ filters: parseFilters('transaction:included', subscription.filters),
2164
+ maxBlocks,
2165
+ safeBlock,
2166
+ })
2167
+
2168
+ // Legacy TIDX-backed type.
2169
+ if (!tidx) return { hasMore: false, items: [] }
2170
+ const filters = transferFilters(subscription.filters)
2171
+ const page = await Transfers.scan(
2172
+ { tidx },
2173
+ { chainId, cursor, limit: batchSize, order: 'asc', toBlock: safeBlock, ...filters },
2174
+ )
2175
+ return { hasMore: page.hasMore, items: page.items }
2176
+ }
2177
+
2178
+ const filterSchemas = {
2179
+ 'block:created': schema.BlockFilters,
2180
+ 'log:emitted': schema.LogFilters,
2181
+ 'token:transfer': schema.TransferFilters,
2182
+ 'transaction:included': schema.TxFilters,
2183
+ } satisfies Record<EventType, z.ZodMiniType>
2184
+
2185
+ /**
2186
+ * Validates `filters` against the schema for `eventType`, **failing closed**: an
2187
+ * invalid filter throws {@link Webhooks.InvalidFilterError} instead of degrading
2188
+ * to `{}` (a firehose). Used at create/patch time and by the scanners so a stored
2189
+ * bad filter can never silently widen the match set.
2190
+ */
2191
+ export function parseFilters(
2192
+ eventType: EventType,
2193
+ filters: Record<string, unknown> | undefined,
2194
+ ): Record<string, unknown> {
2195
+ const result = filterSchemas[eventType].safeParse(filters ?? {})
2196
+ if (!result.success)
2197
+ throw new Webhooks.InvalidFilterError(
2198
+ eventType,
2199
+ Response.validationDetails(result.error.issues),
2200
+ )
2201
+ return result.data as Record<string, unknown>
2202
+ }
2203
+
2204
+ /** Narrows stored `transfer` filters into validated `Transfers.scan` predicates. */
2205
+ function transferFilters(filters: Record<string, unknown>) {
2206
+ const parsed = schema.TransferFilters.safeParse(filters)
2207
+ const { address, recipient, sender, token } = parsed.success ? parsed.data : {}
2208
+ return {
2209
+ ...(address === undefined ? {} : { eitherSide: address }),
2210
+ ...(recipient === undefined ? {} : { recipient }),
2211
+ ...(sender === undefined ? {} : { sender }),
2212
+ ...(token === undefined ? {} : { token }),
2213
+ }
2214
+ }
2215
+
2216
+ /** Recursively lowercases string leaves of a filter operator (for address args). */
2217
+ function lowercaseLeaves(op: unknown): unknown {
2218
+ if (typeof op === 'string') return op.toLowerCase()
2219
+ if (Array.isArray(op)) return op.map(lowercaseLeaves)
2220
+ if (op && typeof op === 'object') {
2221
+ const out: Record<string, unknown> = {}
2222
+ for (const [key, value] of Object.entries(op)) out[key] = lowercaseLeaves(value)
2223
+ return out
2224
+ }
2225
+ return op
2226
+ }
2227
+
2228
+ /** Recursively renders decoded event args JSON-safe (bigint → decimal string). */
2229
+ function serializeArgs(value: unknown): unknown {
2230
+ if (typeof value === 'bigint') return value.toString()
2231
+ if (Array.isArray(value)) return value.map(serializeArgs)
2232
+ if (value && typeof value === 'object') {
2233
+ const out: Record<string, unknown> = {}
2234
+ for (const [key, inner] of Object.entries(value)) out[key] = serializeArgs(inner)
2235
+ return out
2236
+ }
2237
+ return value
2238
+ }
2239
+
2240
+ // Shared filter matching (JS predicates over RPC objects).
2241
+
2242
+ /** Parses a `0x`-hex (or decimal) quantity into a bigint for numeric comparison. */
2243
+ const toBig = (value: unknown) => BigInt(String(value))
2244
+
2245
+ /** Case-insensitive equality for string fields; bigint equality for numeric. */
2246
+ function operandEquals(value: string, target: unknown, kind: 'number' | 'string'): boolean {
2247
+ return kind === 'number'
2248
+ ? toBig(value) === toBig(target)
2249
+ : value.toLowerCase() === String(target).toLowerCase()
2250
+ }
2251
+
2252
+ /**
2253
+ * The JS counterpart of the old SQL `applyOperator`: evaluates one parsed filter
2254
+ * operator (from {@link schema.eq}/{@link schema.compare}/{@link schema.bytes})
2255
+ * against a single `0x`-hex field value pulled from an RPC object. The one place
2256
+ * operator semantics live, shared by every RPC scanner. Returns `false` for a
2257
+ * missing field so an absent value never matches (fail-closed).
2258
+ *
2259
+ * - bare value / `{ eq }` → equality
2260
+ * - `{ in: [...] }` → membership
2261
+ * - `{ not }` → inequality
2262
+ * - `{ gt|gte|lt|lte }` → numeric range (AND-combined)
2263
+ * - `{ selector }` → first 4 bytes (10 hex chars) equal
2264
+ * - `{ startsWith }` → hex prefix
2265
+ */
2266
+ export function matchesOperator(
2267
+ value: string | undefined,
2268
+ op: unknown,
2269
+ kind: 'number' | 'string' = 'string',
2270
+ ): boolean {
2271
+ if (value === undefined) return false
2272
+ if (typeof op !== 'object' || op === null) return operandEquals(value, op, kind)
2273
+ const o = op as Record<string, unknown>
2274
+ if ('selector' in o)
2275
+ return value.slice(0, 10).toLowerCase() === String(o['selector']).toLowerCase()
2276
+ if ('startsWith' in o)
2277
+ return value.toLowerCase().startsWith(String(o['startsWith']).toLowerCase())
2278
+ if ('in' in o) return (o['in'] as readonly unknown[]).some((v) => operandEquals(value, v, kind))
2279
+ if ('not' in o) return !operandEquals(value, o['not'], kind)
2280
+ // Comparison object: AND-combine whichever of eq/gt/gte/lt/lte are present.
2281
+ const n = toBig(value)
2282
+ let ok = true
2283
+ if ('eq' in o) ok = ok && n === toBig(o['eq'])
2284
+ if ('gt' in o) ok = ok && n > toBig(o['gt'])
2285
+ if ('gte' in o) ok = ok && n >= toBig(o['gte'])
2286
+ if ('lt' in o) ok = ok && n < toBig(o['lt'])
2287
+ if ('lte' in o) ok = ok && n <= toBig(o['lte'])
2288
+ return ok
2289
+ }
2290
+
2291
+ /** Matches an `eq`-operator against any inner account-abstraction call target. */
2292
+ function matchesCalls(calls: unknown, op: unknown): boolean {
2293
+ const targets = (Array.isArray(calls) ? calls : [])
2294
+ .map((c) => (c && typeof c === 'object' ? (c as Record<string, unknown>)['to'] : undefined))
2295
+ .filter((to): to is string => typeof to === 'string')
2296
+ .map((to) => to.toLowerCase())
2297
+ const has = (addr: unknown) => targets.includes(String(addr).toLowerCase())
2298
+ if (typeof op === 'object' && op !== null) {
2299
+ const o = op as Record<string, unknown>
2300
+ if ('in' in o) return (o['in'] as readonly unknown[]).some(has)
2301
+ if ('not' in o) return !has(o['not'])
2302
+ if ('eq' in o) return has(o['eq'])
2303
+ }
2304
+ return has(op)
2305
+ }
2306
+
2307
+ // RPC window helpers + per-tick lazy cache.
2308
+
2309
+ /** Renders a block number as a `0x`-hex tag for JSON-RPC params. */
2310
+ const blockTag = (n: number) => `0x${n.toString(16)}` as const
2311
+
2312
+ /** Issues a JSON-RPC request through the viem client for a method viem can't type. */
2313
+ function rpc<result>(
2314
+ client: Viem.getClient.ReturnType,
2315
+ method: string,
2316
+ params: readonly unknown[],
2317
+ ): Promise<result> {
2318
+ return (client.request as (args: { method: string; params: unknown[] }) => Promise<result>)({
2319
+ method,
2320
+ params: [...params],
2321
+ })
2322
+ }
2323
+
2324
+ /**
2325
+ * The confirmed scan window for a cursor. A cursor `(cb, ci)` means rows through
2326
+ * `(cb, ci)` were dispatched: when `ci` is the end-of-block sentinel the whole
2327
+ * block `cb` is done and scanning resumes at `cb + 1`; otherwise block `cb` is
2328
+ * re-scanned (skipping indices `<= ci`). `to` is capped by both `safeBlock` and
2329
+ * the per-tick backfill cap. Returns `undefined` when the subscription is
2330
+ * already caught up (nothing to scan, cursor unchanged).
2331
+ */
2332
+ function scanWindow(
2333
+ cursor: string | undefined,
2334
+ safeBlock: number,
2335
+ maxBlocks: number,
2336
+ ): { from: number; skip: readonly [number, number]; to: number } | undefined {
2337
+ const decoded = cursor ? Cursor.decode(cursor, ['int', 'int']) : undefined
2338
+ const cb = decoded ? (decoded[0] as number) : 0
2339
+ const ci = decoded ? (decoded[1] as number) : endOfBlockIndex
2340
+ const from = ci >= endOfBlockIndex ? cb + 1 : cb
2341
+ if (from > safeBlock) return undefined
2342
+ const to = Math.min(safeBlock, from + maxBlocks - 1)
2343
+ return { from, skip: [cb, ci], to }
2344
+ }
2345
+
2346
+ /** True when `(block, index)` is at or before the already-dispatched cursor. */
2347
+ function atOrBefore(block: number, index: number, skip: readonly [number, number]): boolean {
2348
+ return block < skip[0] || (block === skip[0] && index <= skip[1])
2349
+ }
2350
+
2351
+ /** A viem-formatted block header (no transactions), as returned by `getBlock`. */
2352
+ type RpcBlock = Awaited<ReturnType<Viem.getClient.ReturnType['getBlock']>>
2353
+
2354
+ /** Per-tick lazy cache of fetched block headers, full blocks, and receipts. */
2355
+ type ChainCache = {
2356
+ /** Full raw block (with transactions) via `eth_getBlockByNumber(n, true)`. */
2357
+ block(n: number): Promise<Record<string, unknown> | undefined>
2358
+ /** Viem-formatted block header (transaction hashes only) via `getBlock`. */
2359
+ header(n: number): Promise<RpcBlock | undefined>
2360
+ /** Receipts for a block keyed by lowercased transaction hash. */
2361
+ receipts(n: number): Promise<Map<string, Record<string, unknown>>>
2362
+ }
2363
+
2364
+ /** Builds a per-tick {@link ChainCache} bound to a viem client. */
2365
+ function createChainCache(client: Viem.getClient.ReturnType): ChainCache {
2366
+ const headers = new Map<number, Promise<RpcBlock | undefined>>()
2367
+ const blocks = new Map<number, Promise<Record<string, unknown> | undefined>>()
2368
+ const receipts = new Map<number, Promise<Map<string, Record<string, unknown>>>>()
2369
+ return {
2370
+ block(n) {
2371
+ let p = blocks.get(n)
2372
+ if (!p) {
2373
+ // The transaction payload reuses `humanizeTransaction`, which consumes
2374
+ // the raw RPC tx shape, so the full block stays on the untyped path.
2375
+ p = rpc<Record<string, unknown> | null>(client, 'eth_getBlockByNumber', [
2376
+ blockTag(n),
2377
+ true,
2378
+ ]).then((b) => b ?? undefined)
2379
+ blocks.set(n, p)
2380
+ }
2381
+ return p
2382
+ },
2383
+ header(n) {
2384
+ let p = headers.get(n)
2385
+ if (!p) {
2386
+ p = client
2387
+ .getBlock({ blockNumber: BigInt(n), includeTransactions: false })
2388
+ .catch(() => undefined)
2389
+ headers.set(n, p)
2390
+ }
2391
+ return p
2392
+ },
2393
+ receipts(n) {
2394
+ let p = receipts.get(n)
2395
+ if (!p) {
2396
+ p = rpc<Record<string, unknown>[]>(client, 'eth_getBlockReceipts', [blockTag(n)]).then(
2397
+ (list) => {
2398
+ const map = new Map<string, Record<string, unknown>>()
2399
+ for (const receipt of list ?? []) {
2400
+ const hash = receipt['transactionHash']
2401
+ if (typeof hash === 'string') map.set(hash.toLowerCase(), receipt)
2402
+ }
2403
+ return map
2404
+ },
2405
+ )
2406
+ receipts.set(n, p)
2407
+ }
2408
+ return p
2409
+ },
2410
+ }
2411
+ }
2412
+
2413
+ // log:emitted (eth_getLogs)
2414
+
2415
+ /**
2416
+ * A viem-formatted log, as returned by `getLogs`. When a `signature`/`event` is
2417
+ * supplied to `getLogs`, viem additionally best-effort decodes `args`/`eventName`
2418
+ * (and filters to logs matching the event selector), so the manual
2419
+ * `decodeEventLog`/selector-anchoring the scanner used to do is no longer needed.
2420
+ */
2421
+ type ScanLog = {
2422
+ address: string
2423
+ args?: Record<string, unknown> | readonly unknown[]
2424
+ blockNumber: bigint | null
2425
+ data: string
2426
+ eventName?: string
2427
+ logIndex: number | null
2428
+ topics: readonly string[]
2429
+ transactionHash: string | null
2430
+ transactionIndex: number | null
2431
+ }
2432
+
2433
+ /** The raw + best-effort-decoded payload for one `log:emitted` row. */
2434
+ type LogEventData = {
2435
+ address: string
2436
+ args?: Record<string, unknown>
2437
+ blockNumber: number
2438
+ data: string
2439
+ event?: { name: string; signature: string; topic0: string }
2440
+ logIndex: number
2441
+ timestamp: string
2442
+ topics: string[]
2443
+ transactionHash: string
2444
+ transactionIndex: number
2445
+ }
2446
+
2447
+ /**
2448
+ * Builds the delivered `log:emitted` payload from a viem-formatted log plus the
2449
+ * block timestamp. Returns `undefined` when a required field is missing/invalid
2450
+ * (the row is skipped). When a `signature`/`event` is supplied, `getLogs` has
2451
+ * already decoded `args`/`eventName`, so this just serializes them.
2452
+ */
2453
+ function toLogEvent(
2454
+ log: ScanLog,
2455
+ timestamp: string | undefined,
2456
+ signature: string | undefined,
2457
+ event: AbiEvent | undefined,
2458
+ ): LogEventData | undefined {
2459
+ const address = Schema.Address.safeParse(log.address)
2460
+ const transactionHash = Schema.Hash.safeParse(log.transactionHash)
2461
+ const blockNumber = log.blockNumber === null ? undefined : Number(log.blockNumber)
2462
+ const logIndex = log.logIndex ?? undefined
2463
+ const transactionIndex = log.transactionIndex ?? undefined
2464
+ const topics = log.topics.filter((t): t is string => typeof t === 'string')
2465
+ const topic0 = Schema.Hash.safeParse(topics[0])
2466
+ const data = typeof log.data === 'string' ? log.data : '0x'
2467
+ if (
2468
+ !address.success ||
2469
+ !transactionHash.success ||
2470
+ !topic0.success ||
2471
+ blockNumber === undefined ||
2472
+ logIndex === undefined ||
2473
+ transactionIndex === undefined ||
2474
+ timestamp === undefined
2475
+ )
2476
+ return undefined
2477
+
2478
+ const base: LogEventData = {
2479
+ address: address.data,
2480
+ blockNumber,
2481
+ data,
2482
+ logIndex,
2483
+ timestamp,
2484
+ topics,
2485
+ transactionHash: transactionHash.data,
2486
+ transactionIndex,
2487
+ }
2488
+ if (!signature || !event || log.args === undefined) return base
2489
+ return {
2490
+ ...base,
2491
+ args: serializeArgs(log.args) as Record<string, unknown>,
2492
+ event: { name: event.name, signature, topic0: topic0.data },
2493
+ }
2494
+ }
2495
+
2496
+ /** Narrows an `eq`/`in` address operator into the `getLogs` address pushdown. */
2497
+ function pushAddress(op: unknown): `0x${string}` | `0x${string}`[] | undefined {
2498
+ if (op === undefined) return undefined
2499
+ if (typeof op === 'string') return op as `0x${string}`
2500
+ if (typeof op === 'object' && op !== null) {
2501
+ const o = op as Record<string, unknown>
2502
+ if (typeof o['eq'] === 'string') return o['eq'] as `0x${string}`
2503
+ if (Array.isArray(o['in']))
2504
+ return o['in'].filter((v): v is string => typeof v === 'string') as `0x${string}`[]
2505
+ }
2506
+ // `not`/range forms can't be pushed; widen here, re-check in JS.
2507
+ return undefined
2508
+ }
2509
+
2510
+ /**
2511
+ * Scans contract event logs over the confirmed window via viem's `getLogs`,
2512
+ * then re-checks **every** filter in JS (so operators the RPC can't express —
2513
+ * `not`, ranges, decoded `args` — still apply). Two modes:
2514
+ * - **raw** (no `signature`): `topic0..3` are matched directly.
2515
+ * - **decoded** (`signature` present): `getLogs` is given the `event`, so it
2516
+ * pushes the event selector down, filters to matching logs, and best-effort
2517
+ * decodes `args` — which the JS pass then matches by name.
2518
+ *
2519
+ * `address` (`eq`/`in`) is pushed down to the node; all other operators are
2520
+ * re-checked in JS over the narrow window.
2521
+ */
2522
+ async function scanLogs(options: {
2523
+ cache: ChainCache
2524
+ client: Viem.getClient.ReturnType
2525
+ cursor: string | undefined
2526
+ filters: Record<string, unknown>
2527
+ maxBlocks: number
2528
+ safeBlock: number
2529
+ }): Promise<ScanPage> {
2530
+ const { cache, client, cursor, filters, maxBlocks, safeBlock } = options
2531
+ const f = filters as z.output<typeof schema.LogFilters>
2532
+ const window = scanWindow(cursor, safeBlock, maxBlocks)
2533
+ if (!window) return { items: [], scannedThrough: undefined }
2534
+
2535
+ const event = f.signature ? (parseAbiItem(f.signature) as AbiEvent) : undefined
2536
+ if (event && !/^[A-Za-z_]\w*$/.test(event.name))
2537
+ throw new Webhooks.InvalidFilterError('log:emitted', [
2538
+ { message: 'Unsupported event signature.', path: ['signature'] },
2539
+ ])
2540
+
2541
+ const address = pushAddress(f.address)
2542
+ const fromBlock = BigInt(window.from)
2543
+ const toBlock = BigInt(window.to)
2544
+ const logs = (event
2545
+ ? await client.getLogs({
2546
+ ...(address ? { address } : {}),
2547
+ event,
2548
+ fromBlock,
2549
+ strict: false,
2550
+ toBlock,
2551
+ })
2552
+ : await client.getLogs({
2553
+ ...(address ? { address } : {}),
2554
+ fromBlock,
2555
+ toBlock,
2556
+ })) as unknown as ScanLog[]
2557
+
2558
+ logs.sort((a, b) => {
2559
+ const ab = Number(a.blockNumber ?? 0n)
2560
+ const bb = Number(b.blockNumber ?? 0n)
2561
+ if (ab !== bb) return ab - bb
2562
+ return (a.logIndex ?? 0) - (b.logIndex ?? 0)
2563
+ })
2564
+
2565
+ const items: ScanItem[] = []
2566
+ for (const log of logs) {
2567
+ const blockNumber = log.blockNumber === null ? undefined : Number(log.blockNumber)
2568
+ const logIndex = log.logIndex ?? undefined
2569
+ if (blockNumber === undefined || logIndex === undefined) continue
2570
+ if (atOrBefore(blockNumber, logIndex, window.skip)) continue
2571
+ if (!matchesLog(log, f, event)) continue
2572
+ const header = await cache.header(blockNumber)
2573
+ const timestamp = blockIso(header)
2574
+ const data = toLogEvent(log, timestamp, f.signature, event)
2575
+ if (data) items.push({ cursor: [data.blockNumber, data.logIndex], data })
2576
+ }
2577
+ return { items, scannedThrough: window.to }
2578
+ }
2579
+
2580
+ /** Re-checks all log filters (including non-pushable operators) in JS. */
2581
+ function matchesLog(
2582
+ log: ScanLog,
2583
+ f: z.output<typeof schema.LogFilters>,
2584
+ event: AbiEvent | undefined,
2585
+ ): boolean {
2586
+ const topics = log.topics
2587
+ if (f.address !== undefined && !matchesOperator(log.address, f.address)) return false
2588
+ if (
2589
+ f.blockNumber !== undefined &&
2590
+ !matchesOperator(log.blockNumber?.toString(), f.blockNumber, 'number')
2591
+ )
2592
+ return false
2593
+ // `getLogs(event)` already anchors topic0 on the event selector, so the raw
2594
+ // `topic0` operator only applies in raw (no-signature) mode.
2595
+ if (!event && f.topic0 !== undefined && !matchesOperator(topics[0], f.topic0)) return false
2596
+ if (f.topic1 !== undefined && !matchesOperator(topics[1], f.topic1)) return false
2597
+ if (f.topic2 !== undefined && !matchesOperator(topics[2], f.topic2)) return false
2598
+ if (f.topic3 !== undefined && !matchesOperator(topics[3], f.topic3)) return false
2599
+
2600
+ if (event && f.args && Object.keys(f.args).length > 0) {
2601
+ // `getLogs(event, strict:false)` keeps non-decodable matches with no `args`.
2602
+ if (log.args === undefined || Array.isArray(log.args)) return false
2603
+ const args = log.args as Record<string, unknown>
2604
+ for (const [name, op] of Object.entries(f.args)) {
2605
+ const input = event.inputs.find((i) => i.name === name)
2606
+ if (!input)
2607
+ throw new Webhooks.InvalidFilterError('log:emitted', [
2608
+ { message: `Unknown event argument: ${name}.`, path: ['args', name] },
2609
+ ])
2610
+ const numeric = /^u?int/.test(input.type)
2611
+ const value = args[name]
2612
+ const normalized = input.type === 'address' ? lowercaseLeaves(op) : op
2613
+ if (!matchesOperator(asString(value), normalized, numeric ? 'number' : 'string')) return false
2614
+ }
2615
+ }
2616
+ return true
2617
+ }
2618
+
2619
+ // block:created (getBlock)
2620
+
2621
+ /** Converts a viem block's `timestamp` (seconds) to an ISO 8601 string. */
2622
+ function blockIso(block: RpcBlock | undefined): string | undefined {
2623
+ if (!block || typeof block.timestamp !== 'bigint') return undefined
2624
+ return new Date(Number(block.timestamp) * 1000).toISOString()
2625
+ }
2626
+
2627
+ type BlockEventData = {
2628
+ number: number
2629
+ hash: string
2630
+ parentHash: string
2631
+ miner: string
2632
+ gasUsed: number
2633
+ gasLimit: number
2634
+ transactionCount: number
2635
+ timestamp: string
2636
+ }
2637
+
2638
+ /** Builds the delivered `block:created` payload from a viem block header. */
2639
+ function toBlockEvent(block: RpcBlock): BlockEventData | undefined {
2640
+ const number = block.number === null ? undefined : Number(block.number)
2641
+ const hash = Schema.Hash.safeParse(block.hash)
2642
+ const parentHash = Schema.Hash.safeParse(block.parentHash)
2643
+ const miner = Schema.Address.safeParse(block.miner)
2644
+ const gasUsed = typeof block.gasUsed === 'bigint' ? Number(block.gasUsed) : undefined
2645
+ const gasLimit = typeof block.gasLimit === 'bigint' ? Number(block.gasLimit) : undefined
2646
+ const timestamp = blockIso(block)
2647
+ const transactionCount = Array.isArray(block.transactions) ? block.transactions.length : 0
2648
+ if (
2649
+ number === undefined ||
2650
+ !hash.success ||
2651
+ !parentHash.success ||
2652
+ !miner.success ||
2653
+ gasUsed === undefined ||
2654
+ gasLimit === undefined ||
2655
+ timestamp === undefined
2656
+ )
2657
+ return undefined
2658
+ return {
2659
+ gasLimit,
2660
+ gasUsed,
2661
+ hash: hash.data,
2662
+ miner: miner.data,
2663
+ number,
2664
+ parentHash: parentHash.data,
2665
+ timestamp,
2666
+ transactionCount,
2667
+ }
2668
+ }
2669
+
2670
+ /**
2671
+ * Scans new blocks over the confirmed window via viem's `getBlock`. One row per
2672
+ * block (the keyset index is always 0); filters match block-level metadata. An
2673
+ * empty filter is a per-block heartbeat.
2674
+ */
2675
+ async function scanBlocks(options: {
2676
+ cache: ChainCache
2677
+ cursor: string | undefined
2678
+ filters: Record<string, unknown>
2679
+ maxBlocks: number
2680
+ safeBlock: number
2681
+ }): Promise<ScanPage> {
2682
+ const { cache, cursor, filters, maxBlocks, safeBlock } = options
2683
+ const f = filters as z.output<typeof schema.BlockFilters>
2684
+ const window = scanWindow(cursor, safeBlock, maxBlocks)
2685
+ if (!window) return { items: [], scannedThrough: undefined }
2686
+
2687
+ const items: ScanItem[] = []
2688
+ for (let n = window.from; n <= window.to; n++) {
2689
+ if (atOrBefore(n, 0, window.skip)) continue
2690
+ const block = await cache.header(n)
2691
+ if (!block) continue
2692
+ if (!matchesBlock(block, f)) continue
2693
+ const data = toBlockEvent(block)
2694
+ if (data) items.push({ cursor: [data.number, 0], data })
2695
+ }
2696
+ return { items, scannedThrough: window.to }
2697
+ }
2698
+
2699
+ /** Re-checks all block filters in JS. The RPC has no consensus `proposer`. */
2700
+ function matchesBlock(block: RpcBlock, f: z.output<typeof schema.BlockFilters>): boolean {
2701
+ if (f.number !== undefined && !matchesOperator(block.number?.toString(), f.number, 'number'))
2702
+ return false
2703
+ if (f.miner !== undefined && !matchesOperator(block.miner, f.miner)) return false
2704
+ if (f.gasUsed !== undefined && !matchesOperator(block.gasUsed?.toString(), f.gasUsed, 'number'))
2705
+ return false
2706
+ if (
2707
+ f.gasLimit !== undefined &&
2708
+ !matchesOperator(block.gasLimit?.toString(), f.gasLimit, 'number')
2709
+ )
2710
+ return false
2711
+ if (
2712
+ f.timestamp !== undefined &&
2713
+ !matchesOperator(block.timestamp?.toString(), f.timestamp, 'number')
2714
+ )
2715
+ return false
2716
+ // `proposer` is not exposed over standard RPC, so it can never match.
2717
+ if (f.proposer !== undefined) return false
2718
+ return true
2719
+ }
2720
+
2721
+ // transaction:included (eth_getBlockByNumber + eth_getBlockReceipts)
2722
+
2723
+ /** Maps a `transaction:included` scalar filter field to its raw-tx field + kind. */
2724
+ const txFields = {
2725
+ blockNumber: { field: 'blockNumber', kind: 'number' },
2726
+ feeToken: { field: 'feeToken', kind: 'string' },
2727
+ gasLimit: { field: 'gas', kind: 'number' },
2728
+ hash: { field: 'hash', kind: 'string' },
2729
+ maxFeePerGas: { field: 'maxFeePerGas', kind: 'number' },
2730
+ maxPriorityFeePerGas: { field: 'maxPriorityFeePerGas', kind: 'number' },
2731
+ nonce: { field: 'nonce', kind: 'number' },
2732
+ nonceKey: { field: 'nonceKey', kind: 'string' },
2733
+ txType: { field: 'type', kind: 'string' },
2734
+ validAfter: { field: 'validAfter', kind: 'number' },
2735
+ validBefore: { field: 'validBefore', kind: 'number' },
2736
+ value: { field: 'value', kind: 'number' },
2737
+ } as const satisfies Record<string, { field: string; kind: 'number' | 'string' }>
2738
+
2739
+ /**
2740
+ * Scans included transactions over the confirmed window. Each block is fetched
2741
+ * with full transactions via `eth_getBlockByNumber(n, true)`; receipts are
2742
+ * fetched lazily (only when a `status`/`gasUsed`/`feePayer` filter needs them).
2743
+ * The delivered payload reuses `Transactions.humanizeTransaction`, so it is
2744
+ * byte-identical to the read endpoint.
2745
+ */
2746
+ async function scanTxs(options: {
2747
+ cache: ChainCache
2748
+ cursor: string | undefined
2749
+ filters: Record<string, unknown>
2750
+ maxBlocks: number
2751
+ safeBlock: number
2752
+ }): Promise<ScanPage> {
2753
+ const { cache, cursor, filters, maxBlocks, safeBlock } = options
2754
+ const f = filters as z.output<typeof schema.TxFilters>
2755
+ const window = scanWindow(cursor, safeBlock, maxBlocks)
2756
+ if (!window) return { items: [], scannedThrough: undefined }
2757
+
2758
+ const includeCalls = f.includeCalls ?? true
2759
+ const includeReceipt = f.includeReceipt ?? false
2760
+ // Filter-driven receipt need gates matching; `includeReceipt` only enriches the
2761
+ // payload, so it never drops a row when the receipt is missing.
2762
+ const needsReceipt = f.status !== undefined || f.gasUsed !== undefined || f.feePayer !== undefined
2763
+
2764
+ const items: ScanItem[] = []
2765
+ for (let n = window.from; n <= window.to; n++) {
2766
+ const block = await cache.block(n)
2767
+ const txs = Array.isArray(block?.['transactions']) ? (block!['transactions'] as unknown[]) : []
2768
+ if (txs.length === 0) continue
2769
+ const receipts = needsReceipt || includeReceipt ? await cache.receipts(n) : undefined
2770
+ const timestamp = asString(block?.['timestamp'])
2771
+
2772
+ for (let i = 0; i < txs.length; i++) {
2773
+ const tx = txs[i] as Record<string, unknown>
2774
+ const index = Value.hexToNumber(tx['transactionIndex']) ?? i
2775
+ if (atOrBefore(n, index, window.skip)) continue
2776
+ const receipt = receipts?.get(String(tx['hash']).toLowerCase())
2777
+ if (needsReceipt && !receipt) continue
2778
+ if (!matchesTx(tx, f, includeCalls, receipt, timestamp, n)) continue
2779
+ const parsed = Transactions.schema.Rpc.Transaction.safeParse(tx)
2780
+ if (!parsed.success) continue
2781
+ let data = Transactions.humanizeTransaction(parsed.data)
2782
+ // Embed the humanized receipt under `meta.receipt`, matching the read
2783
+ // endpoint's `include=receipt` shape so `data` stays a valid envelope.
2784
+ if (includeReceipt && receipt) {
2785
+ const r = Receipts.schema.Rpc.Receipt.safeParse(receipt)
2786
+ if (r.success)
2787
+ data = { ...data, meta: { ...data.meta, receipt: Receipts.humanizeReceipt(r.data) } }
2788
+ }
2789
+ items.push({ cursor: [n, index], data })
2790
+ }
2791
+ }
2792
+ return { items, scannedThrough: window.to }
2793
+ }
2794
+
2795
+ /** Re-checks all transaction filters in JS against the raw RPC tx (+ receipt). */
2796
+ function matchesTx(
2797
+ tx: Record<string, unknown>,
2798
+ f: z.output<typeof schema.TxFilters>,
2799
+ includeCalls: boolean,
2800
+ receipt: Record<string, unknown> | undefined,
2801
+ timestamp: string | undefined,
2802
+ blockNumber: number,
2803
+ ): boolean {
2804
+ const from = tx['from']
2805
+ const to = tx['to']
2806
+ const calls = tx['calls']
2807
+ if (f.from !== undefined && !matchesOperator(asString(from), f.from)) return false
2808
+ if (f.to !== undefined) {
2809
+ const direct = matchesOperator(asString(to), f.to)
2810
+ if (!direct && !(includeCalls && matchesCalls(calls, f.to))) return false
2811
+ }
2812
+ if (f.address !== undefined) {
2813
+ const direct =
2814
+ matchesOperator(asString(from), f.address) || matchesOperator(asString(to), f.address)
2815
+ if (!direct && !(includeCalls && matchesCalls(calls, f.address))) return false
2816
+ }
2817
+ if (f.calls?.to !== undefined && !matchesCalls(calls, f.calls.to)) return false
2818
+ if (f.input !== undefined && !matchesOperator(asString(tx['input']), f.input)) return false
2819
+ if (f.callCount !== undefined) {
2820
+ const count = Array.isArray(calls) ? calls.length : 0
2821
+ if (!matchesOperator(String(count), f.callCount, 'number')) return false
2822
+ }
2823
+
2824
+ for (const [name, spec] of Object.entries(txFields)) {
2825
+ const op = (f as Record<string, unknown>)[name]
2826
+ if (op === undefined) continue
2827
+ const value =
2828
+ name === 'blockNumber'
2829
+ ? (asString(tx['blockNumber']) ?? blockNumber.toString())
2830
+ : asString(tx[spec.field])
2831
+ if (!matchesOperator(value, op, spec.kind)) return false
2832
+ }
2833
+
2834
+ if (f.timestamp !== undefined && !matchesOperator(timestamp, f.timestamp, 'number')) return false
2835
+
2836
+ if (receipt) {
2837
+ if (f.feePayer !== undefined && !matchesOperator(asString(receipt['feePayer']), f.feePayer))
2838
+ return false
2839
+ if (
2840
+ f.gasUsed !== undefined &&
2841
+ !matchesOperator(asString(receipt['gasUsed']), f.gasUsed, 'number')
2842
+ )
2843
+ return false
2844
+ if (f.status !== undefined) {
2845
+ const ok = Value.hexToNumber(receipt['status']) === 1
2846
+ if ((f.status === 'success') !== ok) return false
2847
+ }
2848
+ }
2849
+ return true
2850
+ }
2851
+
2852
+ /** Coerces an RPC field to a string for matching, or `undefined` when absent. */
2853
+ function asString(value: unknown): string | undefined {
2854
+ return typeof value === 'string' ? value : undefined
2855
+ }
2856
+ function ownerFor(c: Context<App.Environment>): Webhooks.Owner | null {
2857
+ const principal = Auth.getPrincipal(c)
2858
+ if (!principal) return null
2859
+ if (principal.type === 'api_key') return { orgId: principal.orgId, type: 'api_key' }
2860
+ const payer = principal.payment?.payer
2861
+ return payer ? { payer, type: 'mpp' } : null
2862
+ }
2863
+
2864
+ /** Strips internal fields (`owner`, `secret`) from a subscription for reads. */
2865
+ function toPublic(subscription: Webhooks.Subscription) {
2866
+ return {
2867
+ chainId: subscription.chainId,
2868
+ ...(subscription.context === undefined ? {} : { context: subscription.context }),
2869
+ createdAt: subscription.createdAt,
2870
+ destination: subscription.destination,
2871
+ eventType: subscription.eventType,
2872
+ ...(subscription.expiresAt === undefined ? {} : { expiresAt: subscription.expiresAt }),
2873
+ failureCount: subscription.failureCount,
2874
+ filters: subscription.filters,
2875
+ id: subscription.id,
2876
+ ...(subscription.lastDeliveryAt === undefined
2877
+ ? {}
2878
+ : { lastDeliveryAt: subscription.lastDeliveryAt }),
2879
+ status: subscription.status,
2880
+ updatedAt: subscription.updatedAt,
2881
+ }
2882
+ }
2883
+
2884
+ /** Public read shape plus the one-time signing secret (creation only). */
2885
+ function toCreated(subscription: Webhooks.Subscription) {
2886
+ return { ...toPublic(subscription), secret: subscription.secret }
2887
+ }
2888
+
2889
+ function mutationError(c: Context<App.Environment>, cause: unknown) {
2890
+ if (cause instanceof Webhooks.InvalidUrlError)
2891
+ return Response.error(c, { code: 'url_invalid', message: cause.message, status: 400 })
2892
+ if (cause instanceof Webhooks.InvalidFilterError)
2893
+ return Response.error(c, {
2894
+ code: 'filters_invalid',
2895
+ details: cause.details,
2896
+ message: cause.message,
2897
+ status: 400,
2898
+ })
2899
+ if (cause instanceof Webhooks.LimitExceededError)
2900
+ return Response.error(c, { code: 'limit_exceeded', message: cause.message, status: 403 })
2901
+ return Response.upstream(c, cause)
2902
+ }
2903
+
2904
+ function notFound(c: Context<App.Environment>) {
2905
+ return Response.error(c, {
2906
+ code: 'webhook_not_found',
2907
+ message: 'Webhook not found',
2908
+ status: 404,
2909
+ })
2910
+ }
2911
+
2912
+ function notEnabled(c: Context<App.Environment>) {
2913
+ return Response.error(c, {
2914
+ code: 'webhooks_not_enabled',
2915
+ message: 'Webhooks are not enabled for this Tempo API deployment.',
2916
+ status: 404,
2917
+ })
2918
+ }
2919
+
2920
+ function unauthorized(c: Context<App.Environment>) {
2921
+ return Response.error(c, {
2922
+ code: 'unauthorized',
2923
+ message: 'Tempo could not determine which authenticated account owns this webhook.',
2924
+ status: 401,
2925
+ })
2926
+ }