tapimo 0.9.0 → 0.10.1

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 (319) hide show
  1. package/dist/ApiKey.d.ts +2 -1
  2. package/dist/ApiKey.d.ts.map +1 -1
  3. package/dist/ApiKeys.d.ts +4 -2
  4. package/dist/ApiKeys.d.ts.map +1 -1
  5. package/dist/ApiKeys.js +26 -1
  6. package/dist/ApiKeys.js.map +1 -1
  7. package/dist/App.d.ts +4292 -3708
  8. package/dist/App.d.ts.map +1 -1
  9. package/dist/App.js +1 -0
  10. package/dist/App.js.map +1 -1
  11. package/dist/Webhooks.d.ts +21 -20
  12. package/dist/Webhooks.d.ts.map +1 -1
  13. package/dist/Webhooks.js +66 -23
  14. package/dist/Webhooks.js.map +1 -1
  15. package/dist/admin/App.d.ts +91 -84
  16. package/dist/admin/App.d.ts.map +1 -1
  17. package/dist/admin/apps/api-keys.d.ts +26 -17
  18. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  19. package/dist/admin/apps/api-keys.js +6 -0
  20. package/dist/admin/apps/api-keys.js.map +1 -1
  21. package/dist/admin/apps/organizations.d.ts +4 -2
  22. package/dist/admin/apps/organizations.d.ts.map +1 -1
  23. package/dist/admin/apps/verified-tokens.d.ts +168 -168
  24. package/dist/admin/ui.generated.d.ts +1 -1
  25. package/dist/admin/ui.generated.js +69 -69
  26. package/dist/admin/ui.generated.js.map +1 -1
  27. package/dist/analytics/Analytics.d.ts.map +1 -1
  28. package/dist/analytics/Analytics.js +26 -0
  29. package/dist/analytics/Analytics.js.map +1 -1
  30. package/dist/apps/Zones.d.ts +4280 -84
  31. package/dist/apps/Zones.d.ts.map +1 -1
  32. package/dist/apps/Zones.js +30 -10
  33. package/dist/apps/Zones.js.map +1 -1
  34. package/dist/apps/data/App.d.ts +5799 -4929
  35. package/dist/apps/data/App.d.ts.map +1 -1
  36. package/dist/apps/data/FxOracle.d.ts +4 -5
  37. package/dist/apps/data/FxOracle.d.ts.map +1 -1
  38. package/dist/apps/data/FxOracle.js +19 -4
  39. package/dist/apps/data/FxOracle.js.map +1 -1
  40. package/dist/apps/data/routes/activities.d.ts +3391 -492
  41. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  42. package/dist/apps/data/routes/activities.js +372 -29
  43. package/dist/apps/data/routes/activities.js.map +1 -1
  44. package/dist/apps/data/routes/addresses.d.ts +853 -339
  45. package/dist/apps/data/routes/addresses.d.ts.map +1 -1
  46. package/dist/apps/data/routes/balances.d.ts +10 -10
  47. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  48. package/dist/apps/data/routes/balances.js +145 -83
  49. package/dist/apps/data/routes/balances.js.map +1 -1
  50. package/dist/apps/data/routes/blocks.d.ts +123 -123
  51. package/dist/apps/data/routes/coingecko.d.ts +169 -169
  52. package/dist/apps/data/routes/earn.d.ts +455 -1
  53. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  54. package/dist/apps/data/routes/earn.js +369 -45
  55. package/dist/apps/data/routes/earn.js.map +1 -1
  56. package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
  57. package/dist/apps/data/routes/exchanges.js +114 -118
  58. package/dist/apps/data/routes/exchanges.js.map +1 -1
  59. package/dist/apps/data/routes/fee-amm.d.ts +24 -24
  60. package/dist/apps/data/routes/indexer.d.ts +35 -34
  61. package/dist/apps/data/routes/indexer.d.ts.map +1 -1
  62. package/dist/apps/data/routes/indexer.js +3 -2
  63. package/dist/apps/data/routes/indexer.js.map +1 -1
  64. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  65. package/dist/apps/data/routes/receipts.js +68 -47
  66. package/dist/apps/data/routes/receipts.js.map +1 -1
  67. package/dist/apps/data/routes/rpc.d.ts +31 -31
  68. package/dist/apps/data/routes/rpc.d.ts.map +1 -1
  69. package/dist/apps/data/routes/rpc.js +5 -1
  70. package/dist/apps/data/routes/rpc.js.map +1 -1
  71. package/dist/apps/data/routes/tokenlist.d.ts +12 -12
  72. package/dist/apps/data/routes/tokens.d.ts +461 -459
  73. package/dist/apps/data/routes/tokens.d.ts.map +1 -1
  74. package/dist/apps/data/routes/transactions.d.ts +313 -305
  75. package/dist/apps/data/routes/transactions.d.ts.map +1 -1
  76. package/dist/apps/data/routes/transactions.js +25 -23
  77. package/dist/apps/data/routes/transactions.js.map +1 -1
  78. package/dist/apps/data/routes/transfers.d.ts +108 -86
  79. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  80. package/dist/apps/data/routes/transfers.js +618 -105
  81. package/dist/apps/data/routes/transfers.js.map +1 -1
  82. package/dist/apps/data/routes/valuation.d.ts +21 -21
  83. package/dist/apps/data/routes/valuation.d.ts.map +1 -1
  84. package/dist/apps/data/routes/valuation.js +2 -2
  85. package/dist/apps/data/routes/valuation.js.map +1 -1
  86. package/dist/apps/data/routes/webhooks.d.ts +519 -868
  87. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  88. package/dist/apps/data/routes/webhooks.js +642 -353
  89. package/dist/apps/data/routes/webhooks.js.map +1 -1
  90. package/dist/apps/data/routes/zones.d.ts +31 -31
  91. package/dist/apps/data/routes/zones.d.ts.map +1 -1
  92. package/dist/apps/data/routes/zones.js +4 -2
  93. package/dist/apps/data/routes/zones.js.map +1 -1
  94. package/dist/apps/funding/routes/deposit-addresses.js +1 -1
  95. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
  96. package/dist/apps/funding/routes/providers.d.ts +13 -13
  97. package/dist/apps/funding/routes/quotes.d.ts +89 -89
  98. package/dist/apps/funding/routes/transfers.d.ts +36 -36
  99. package/dist/apps/funding/routes/transfers.js +1 -1
  100. package/dist/apps/funding/routes/transfers.js.map +1 -1
  101. package/dist/apps/management/App.d.ts +11 -309
  102. package/dist/apps/management/App.d.ts.map +1 -1
  103. package/dist/apps/management/routes/api-keys.d.ts +74 -74
  104. package/dist/apps/management/routes/billing.js +1 -1
  105. package/dist/apps/management/routes/billing.js.map +1 -1
  106. package/dist/apps/management/routes/faucet.d.ts +35 -35
  107. package/dist/apps/management/routes/faucet.js +2 -2
  108. package/dist/apps/management/routes/faucet.js.map +1 -1
  109. package/dist/apps/management/routes/invitations.d.ts +134 -134
  110. package/dist/apps/management/routes/invite-links.d.ts +80 -80
  111. package/dist/apps/management/routes/members.d.ts +69 -69
  112. package/dist/apps/management/routes/orgs.d.ts +98 -98
  113. package/dist/apps/management/routes/projects.d.ts +60 -60
  114. package/dist/apps/management/routes/usage.d.ts +72 -72
  115. package/dist/apps/management/routes/verified-token-requests.d.ts +83 -83
  116. package/dist/apps/management/routes/webhooks.d.ts +203 -618
  117. package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
  118. package/dist/apps/mcp.d.ts.map +1 -1
  119. package/dist/apps/mcp.js +4 -1
  120. package/dist/apps/mcp.js.map +1 -1
  121. package/dist/apps/mpp/App.d.ts +56 -44
  122. package/dist/apps/mpp/App.d.ts.map +1 -1
  123. package/dist/apps/mpp/Mpp.d.ts +3 -0
  124. package/dist/apps/mpp/Mpp.d.ts.map +1 -1
  125. package/dist/apps/mpp/Mpp.js +3 -0
  126. package/dist/apps/mpp/Mpp.js.map +1 -1
  127. package/dist/apps/mpp/internal/Adapter.d.ts.map +1 -1
  128. package/dist/apps/mpp/internal/Adapter.js +9 -2
  129. package/dist/apps/mpp/internal/Adapter.js.map +1 -1
  130. package/dist/db/Db.d.ts +4 -2
  131. package/dist/db/Db.d.ts.map +1 -1
  132. package/dist/db/Db.js +75 -15
  133. package/dist/db/Db.js.map +1 -1
  134. package/dist/db/Schema.d.ts +19 -0
  135. package/dist/db/Schema.d.ts.map +1 -1
  136. package/dist/db/Schema.js +24 -0
  137. package/dist/db/Schema.js.map +1 -1
  138. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  139. package/dist/db/tables/sponsoredTransactions.js +4 -1
  140. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  141. package/dist/db/tables/webhookQueueEvents.d.ts +122 -0
  142. package/dist/db/tables/webhookQueueEvents.d.ts.map +1 -0
  143. package/dist/db/tables/webhookQueueEvents.js +160 -0
  144. package/dist/db/tables/webhookQueueEvents.js.map +1 -0
  145. package/dist/db/tables/webhookSubscriptions.d.ts +58 -3
  146. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  147. package/dist/db/tables/webhookSubscriptions.js +86 -3
  148. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  149. package/dist/internal/Auth.d.ts +3 -0
  150. package/dist/internal/Auth.d.ts.map +1 -1
  151. package/dist/internal/Auth.js +6 -4
  152. package/dist/internal/Auth.js.map +1 -1
  153. package/dist/internal/MetricSink.d.ts +139 -3
  154. package/dist/internal/MetricSink.d.ts.map +1 -1
  155. package/dist/internal/MetricSink.js +144 -4
  156. package/dist/internal/MetricSink.js.map +1 -1
  157. package/dist/internal/RateLimit.d.ts +4 -6
  158. package/dist/internal/RateLimit.d.ts.map +1 -1
  159. package/dist/internal/RateLimit.js +15 -14
  160. package/dist/internal/RateLimit.js.map +1 -1
  161. package/dist/internal/Schema.js +1 -1
  162. package/dist/internal/Schema.js.map +1 -1
  163. package/dist/internal/Store.d.ts +2 -1
  164. package/dist/internal/Store.d.ts.map +1 -1
  165. package/dist/internal/Store.js +57 -18
  166. package/dist/internal/Store.js.map +1 -1
  167. package/dist/internal/Tidx.d.ts +1 -3
  168. package/dist/internal/Tidx.d.ts.map +1 -1
  169. package/dist/internal/Tidx.js +1 -3
  170. package/dist/internal/Tidx.js.map +1 -1
  171. package/dist/internal/Value.d.ts +2 -0
  172. package/dist/internal/Value.d.ts.map +1 -1
  173. package/dist/internal/Value.js +25 -0
  174. package/dist/internal/Value.js.map +1 -1
  175. package/dist/internal/Viem.d.ts.map +1 -1
  176. package/dist/internal/Viem.js +2 -1
  177. package/dist/internal/Viem.js.map +1 -1
  178. package/dist/internal/WebhookDestination.d.ts +2 -0
  179. package/dist/internal/WebhookDestination.d.ts.map +1 -1
  180. package/dist/internal/WebhookDestination.js +11 -0
  181. package/dist/internal/WebhookDestination.js.map +1 -1
  182. package/dist/internal/WebhookTransfer.d.ts +75 -0
  183. package/dist/internal/WebhookTransfer.d.ts.map +1 -0
  184. package/dist/internal/WebhookTransfer.js +299 -0
  185. package/dist/internal/WebhookTransfer.js.map +1 -0
  186. package/dist/internal/Webhooks.d.ts +176 -0
  187. package/dist/internal/Webhooks.d.ts.map +1 -1
  188. package/dist/internal/Webhooks.js +216 -20
  189. package/dist/internal/Webhooks.js.map +1 -1
  190. package/dist/internal/funding/Provider.d.ts +4 -0
  191. package/dist/internal/funding/Provider.d.ts.map +1 -1
  192. package/dist/internal/funding/Provider.js +11 -7
  193. package/dist/internal/funding/Provider.js.map +1 -1
  194. package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
  195. package/dist/internal/funding/Reconciliation.js +128 -0
  196. package/dist/internal/funding/Reconciliation.js.map +1 -1
  197. package/dist/internal/funding/providers/relay.d.ts +6 -4
  198. package/dist/internal/funding/providers/relay.d.ts.map +1 -1
  199. package/dist/internal/funding/providers/relay.js +71 -9
  200. package/dist/internal/funding/providers/relay.js.map +1 -1
  201. package/package.json +4 -4
  202. package/src/ApiKeys.test.ts +20 -0
  203. package/src/ApiKeys.ts +28 -1
  204. package/src/App.test.ts +10 -8
  205. package/src/App.ts +1 -0
  206. package/src/Client.test-d.ts +61 -5
  207. package/src/Webhooks.bench.ts +714 -0
  208. package/src/Webhooks.memory.bench.ts +250 -0
  209. package/src/Webhooks.test.ts +77 -6
  210. package/src/Webhooks.ts +85 -29
  211. package/src/admin/apps/api-keys.test.ts +9 -0
  212. package/src/admin/apps/api-keys.ts +15 -0
  213. package/src/admin/ui/src/lib/query-client.ts +3 -2
  214. package/src/admin/ui/src/routes/api-keys.tsx +31 -9
  215. package/src/admin/ui.generated.ts +69 -69
  216. package/src/analytics/Analytics.test.ts +8 -0
  217. package/src/analytics/Analytics.ts +26 -0
  218. package/src/apps/Zones.test.ts +23 -5
  219. package/src/apps/Zones.ts +31 -10
  220. package/src/apps/composition.test.ts +1 -0
  221. package/src/apps/data/App.test.ts +2 -2
  222. package/src/apps/data/FxOracle.test.ts +18 -0
  223. package/src/apps/data/FxOracle.ts +28 -5
  224. package/src/apps/data/routes/activities.bench.ts +41 -0
  225. package/src/apps/data/routes/activities.test.ts +122 -2
  226. package/src/apps/data/routes/activities.ts +435 -32
  227. package/src/apps/data/routes/balances.bench.ts +21 -0
  228. package/src/apps/data/routes/balances.test.ts +119 -10
  229. package/src/apps/data/routes/balances.ts +164 -97
  230. package/src/apps/data/routes/blocks.bench.ts +34 -0
  231. package/src/apps/data/routes/blocks.test.ts +12 -6
  232. package/src/apps/data/routes/coingecko.bench.ts +53 -0
  233. package/src/apps/data/routes/earn.bench.ts +71 -0
  234. package/src/apps/data/routes/earn.test.ts +197 -0
  235. package/src/apps/data/routes/earn.ts +487 -45
  236. package/src/apps/data/routes/exchanges.bench.ts +85 -0
  237. package/src/apps/data/routes/exchanges.test.ts +7 -6
  238. package/src/apps/data/routes/exchanges.ts +124 -117
  239. package/src/apps/data/routes/fee-amm.bench.ts +13 -0
  240. package/src/apps/data/routes/indexer.bench.ts +8 -0
  241. package/src/apps/data/routes/indexer.test.ts +2 -1
  242. package/src/apps/data/routes/indexer.ts +3 -2
  243. package/src/apps/data/routes/receipts.bench.ts +39 -0
  244. package/src/apps/data/routes/receipts.test.ts +24 -7
  245. package/src/apps/data/routes/receipts.ts +67 -50
  246. package/src/apps/data/routes/rpc.bench.ts +14 -0
  247. package/src/apps/data/routes/rpc.test.ts +27 -0
  248. package/src/apps/data/routes/rpc.ts +5 -1
  249. package/src/apps/data/routes/tokenlist.bench.ts +3 -0
  250. package/src/apps/data/routes/tokens.bench.ts +76 -0
  251. package/src/apps/data/routes/tokens.test.ts +51 -0
  252. package/src/apps/data/routes/transactions.bench.ts +40 -0
  253. package/src/apps/data/routes/transactions.test.ts +42 -0
  254. package/src/apps/data/routes/transactions.ts +44 -39
  255. package/src/apps/data/routes/transfers.bench.ts +28 -0
  256. package/src/apps/data/routes/transfers.test.ts +402 -24
  257. package/src/apps/data/routes/transfers.ts +708 -101
  258. package/src/apps/data/routes/valuation.bench.ts +14 -0
  259. package/src/apps/data/routes/valuation.test.ts +26 -0
  260. package/src/apps/data/routes/valuation.ts +6 -2
  261. package/src/apps/data/routes/verified-tokens.bench.ts +8 -0
  262. package/src/apps/data/routes/webhooks.bench.ts +26 -0
  263. package/src/apps/data/routes/webhooks.scan.test.ts +326 -0
  264. package/src/apps/data/routes/webhooks.test.ts +466 -135
  265. package/src/apps/data/routes/webhooks.ts +820 -429
  266. package/src/apps/data/routes/zones.bench.ts +32 -0
  267. package/src/apps/data/routes/zones.test.ts +1 -1
  268. package/src/apps/data/routes/zones.ts +7 -2
  269. package/src/apps/funding/routes/chains.test.ts +1 -1
  270. package/src/apps/funding/routes/deposit-addresses.test.ts +1 -1
  271. package/src/apps/funding/routes/deposit-addresses.ts +1 -1
  272. package/src/apps/funding/routes/providers.test.ts +1 -1
  273. package/src/apps/funding/routes/quotes.test.ts +2 -2
  274. package/src/apps/funding/routes/transfers.test.ts +3 -6
  275. package/src/apps/funding/routes/transfers.ts +1 -1
  276. package/src/apps/management/routes/billing.ts +1 -1
  277. package/src/apps/management/routes/faucet.ts +2 -2
  278. package/src/apps/management/routes/me.test.ts +31 -0
  279. package/src/apps/mcp.ts +4 -1
  280. package/src/apps/mpp/Mpp.test.ts +13 -1
  281. package/src/apps/mpp/Mpp.ts +8 -0
  282. package/src/apps/mpp/internal/Adapter.ts +10 -2
  283. package/src/db/Db.test.ts +40 -6
  284. package/src/db/Db.ts +114 -19
  285. package/src/db/Schema.ts +32 -0
  286. package/src/db/tables/sponsoredTransactions.ts +9 -2
  287. package/src/db/tables/webhookQueueEvents.ts +285 -0
  288. package/src/db/tables/webhookSubscriptions.ts +131 -4
  289. package/src/internal/Auth.test.ts +61 -50
  290. package/src/internal/Auth.ts +9 -4
  291. package/src/internal/EarnVaults.test.ts +2 -2
  292. package/src/internal/MetricSink.test.ts +575 -0
  293. package/src/internal/MetricSink.ts +325 -10
  294. package/src/internal/RateLimit.test.ts +23 -18
  295. package/src/internal/RateLimit.ts +23 -15
  296. package/src/internal/Schema.ts +1 -1
  297. package/src/internal/Store.test.ts +99 -13
  298. package/src/internal/Store.ts +52 -17
  299. package/src/internal/Tidx.test.ts +4 -4
  300. package/src/internal/Tidx.ts +1 -3
  301. package/src/internal/Value.test.ts +25 -0
  302. package/src/internal/Value.ts +29 -0
  303. package/src/internal/Viem.test.ts +8 -0
  304. package/src/internal/Viem.ts +2 -1
  305. package/src/internal/WebhookDestination.test.ts +20 -0
  306. package/src/internal/WebhookDestination.ts +10 -0
  307. package/src/internal/WebhookTransfer.test.ts +89 -0
  308. package/src/internal/WebhookTransfer.ts +430 -0
  309. package/src/internal/Webhooks.test.ts +616 -0
  310. package/src/internal/Webhooks.ts +412 -24
  311. package/src/internal/funding/Provider.test.ts +48 -8
  312. package/src/internal/funding/Provider.ts +12 -7
  313. package/src/internal/funding/Reconciliation.local.test.ts +3 -1
  314. package/src/internal/funding/Reconciliation.test.ts +1240 -0
  315. package/src/internal/funding/Reconciliation.ts +136 -0
  316. package/src/internal/funding/SourceObservation.test.ts +1 -0
  317. package/src/internal/funding/providers/index.test.ts +73 -3
  318. package/src/internal/funding/providers/relay.test.ts +2 -9
  319. package/src/internal/funding/providers/relay.ts +72 -13
@@ -7,17 +7,17 @@ import * as Auth from '../../../internal/Auth.js'
7
7
  import * as Cache from '../../../internal/Cache.js'
8
8
  import * as Cursor from '../../../internal/Cursor.js'
9
9
  import * as Db from '../../../db/Db.js'
10
+ import type * as MetricSink from '../../../internal/MetricSink.js'
10
11
  import * as OpenApi from '../../../internal/OpenApi.js'
11
12
  import * as Response from '../../../internal/Response.js'
12
13
  import * as Schema from '../../../internal/Schema.js'
13
14
  import * as Store from '../../../internal/Store.js'
14
- import type * as Tidx from '../../../internal/Tidx.js'
15
15
  import * as Ttl from '../../../internal/Ttl.js'
16
16
  import * as Value from '../../../internal/Value.js'
17
17
  import type * as Viem from '../../../internal/Viem.js'
18
18
  import * as WebhookDestination from '../../../internal/WebhookDestination.js'
19
+ import * as WebhookTransfer from '../../../internal/WebhookTransfer.js'
19
20
  import * as Webhooks from '../../../internal/Webhooks.js'
20
- import * as Receipts from './receipts.js'
21
21
  import * as Transactions from './transactions.js'
22
22
  import * as Transfers from './transfers.js'
23
23
 
@@ -67,7 +67,7 @@ const eventTypeDescriptions = [
67
67
  },
68
68
  {
69
69
  description:
70
- '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.',
70
+ 'A transaction included in a block, filterable on transaction fields such as sender, recipient, value, calldata, inner account-abstraction calls, fees, and nonce.',
71
71
  type: 'transaction:included',
72
72
  },
73
73
  ] as const satisfies readonly { description: string; type: EventType }[]
@@ -233,13 +233,12 @@ export namespace schema {
233
233
 
234
234
  /**
235
235
  * `transaction:included` subscription filters: operators over every column the
236
- * `txs` envelope exposes, plus inner account-abstraction `calls`, the
237
- * top-level `input` calldata, and the receipt-sourced execution `status`. All
238
- * optional and AND-combined; omit every field for the full stream.
236
+ * `txs` envelope exposes, plus inner account-abstraction `calls` and the
237
+ * top-level `input` calldata. All optional and AND-combined; omit every field
238
+ * for the full stream.
239
239
  *
240
240
  * `address` matches either side (`from` OR `to`); when `includeCalls` is set
241
241
  * (default), `to`/`address` also match an inner call target (`calls[].to`).
242
- * Set `includeReceipt` to embed the receipt in the delivered payload.
243
242
  */
244
243
  export const TxFilters = z
245
244
  .strictObject({
@@ -269,16 +268,6 @@ export namespace schema {
269
268
  z.meta({ examples: [true] }),
270
269
  ),
271
270
  ),
272
- includeReceipt: z.optional(
273
- z
274
- .boolean()
275
- .check(
276
- z.describe(
277
- '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`.',
278
- ),
279
- z.meta({ examples: [true] }),
280
- ),
281
- ),
282
271
  value: z.optional(compare.check(z.describe('Match the transaction value (wei) bounds.'))),
283
272
  input: z.optional(
284
273
  bytes.check(
@@ -305,9 +294,7 @@ export namespace schema {
305
294
  feeToken: z.optional(
306
295
  eq(Schema.TokenAddress).check(z.describe('Match the fee token address.')),
307
296
  ),
308
- feePayer: z.optional(eq(Schema.Address).check(z.describe('Match the fee payer address.'))),
309
297
  gasLimit: z.optional(compare.check(z.describe('Match the gas limit bounds.'))),
310
- gasUsed: z.optional(compare.check(z.describe('Match the gas used bounds.'))),
311
298
  maxFeePerGas: z.optional(compare.check(z.describe('Match the max fee per gas bounds.'))),
312
299
  maxPriorityFeePerGas: z.optional(
313
300
  compare.check(z.describe('Match the max priority fee per gas bounds.')),
@@ -322,14 +309,6 @@ export namespace schema {
322
309
  timestamp: z.optional(
323
310
  compare.check(z.describe('Match the block timestamp bounds (unix s).')),
324
311
  ),
325
- status: z.optional(
326
- z
327
- .enum(['success', 'reverted'])
328
- .check(
329
- z.describe('Match the receipt execution status (routes through `receipts`).'),
330
- z.meta({ examples: ['success'] }),
331
- ),
332
- ),
333
312
  })
334
313
  .check(
335
314
  z.describe('Filters that narrow which included transactions trigger this webhook.'),
@@ -685,7 +664,7 @@ export namespace schema {
685
664
  .datetime()
686
665
  .check(
687
666
  z.describe('Block timestamp as an ISO 8601 string.'),
688
- z.meta({ examples: ['2026-01-14T18:38:03.000Z'] }),
667
+ z.meta({ examples: ['2026-01-14T18:38:03.685Z'] }),
689
668
  ),
690
669
  token: z.optional(
691
670
  z
@@ -720,10 +699,21 @@ export namespace schema {
720
699
  )
721
700
 
722
701
  /**
723
- * A `transaction` event payload: identical to the `GET /transactions/:hash`
724
- * response (the poller reuses the same humanized shape).
702
+ * A `transaction` event payload using the humanized transaction shape without
703
+ * opt-in read resources such as receipts.
725
704
  */
726
- export const TransactionEvent = Transactions.schema.getTransaction.Response
705
+ export const TransactionEvent = z
706
+ .object({
707
+ ...Transactions.schema.Transaction.shape,
708
+ meta: z
709
+ .object({
710
+ rpc: Transactions.schema.Rpc.Transaction.check(
711
+ z.describe('The original JSON-RPC transaction payload.'),
712
+ ),
713
+ })
714
+ .check(z.describe('Metadata for this transaction webhook event.')),
715
+ })
716
+ .check(z.describe('A transaction included in a block.'))
727
717
 
728
718
  /**
729
719
  * A `log:emitted` event payload: the raw contract event log. When a
@@ -800,7 +790,7 @@ export namespace schema {
800
790
  .datetime()
801
791
  .check(
802
792
  z.describe('When the block was produced, as an ISO 8601 timestamp.'),
803
- z.meta({ examples: ['2026-01-14T18:38:03.000Z'] }),
793
+ z.meta({ examples: ['2026-01-14T18:38:03.685Z'] }),
804
794
  ),
805
795
  topics: z.array(Schema.Hash).check(
806
796
  z.describe('Log topics, starting with the event signature hash (topic 0).'),
@@ -830,7 +820,7 @@ export namespace schema {
830
820
  })
831
821
  .check(z.describe('Payload for a `log:emitted` event (a raw contract event log).'))
832
822
 
833
- /** Payload for a `block:created` event (a TIDX-derived block header). */
823
+ /** Payload for a `block:created` event (an RPC-derived block header). */
834
824
  export const BlockEvent = z
835
825
  .object({
836
826
  number: z
@@ -891,7 +881,7 @@ export namespace schema {
891
881
  .datetime()
892
882
  .check(
893
883
  z.describe('When the block was produced, as an ISO 8601 timestamp.'),
894
- z.meta({ examples: ['2026-01-14T18:38:03.000Z'] }),
884
+ z.meta({ examples: ['2026-01-14T18:38:03.685Z'] }),
895
885
  ),
896
886
  })
897
887
  .check(z.describe('Payload for a `block:created` event (a new block header).'))
@@ -1893,15 +1883,9 @@ const scanPageSize = 100
1893
1883
  type ScanItem = { cursor: readonly [number, number]; data: unknown }
1894
1884
 
1895
1885
  /**
1896
- * The result of scanning one subscription page. Two flavours:
1897
- *
1898
- * - **window** (the RPC scanners): `scannedThrough` is the highest block fully
1899
- * scanned. A batch-limited page omits it, so the poller resumes after the last
1900
- * dispatched row instead of skipping later matches.
1901
- * - **keyset** (the TIDX legacy scanners): rows come back `LIMIT`-bounded, so
1902
- * the page only reports whether `hasMore` rows remain past it; the poller
1903
- * advances only past the last dispatched row (jumping to the window end only
1904
- * when the page was empty and complete).
1886
+ * The result of scanning one subscription page. `scannedThrough` is the
1887
+ * highest block fully scanned. A batch-limited page omits it, so the poller
1888
+ * resumes after the last dispatched row instead of skipping later matches.
1905
1889
  */
1906
1890
  type ScanPage = {
1907
1891
  hasMore?: boolean | undefined
@@ -1912,11 +1896,63 @@ type ScanPage = {
1912
1896
  /** Per-subscription state retained between fair scan passes. */
1913
1897
  type PollState = {
1914
1898
  cursor: string
1899
+ filters: Record<string, unknown>
1915
1900
  remaining: number
1916
1901
  safeBlock: number
1917
1902
  subscription: Webhooks.Subscription
1918
1903
  }
1919
1904
 
1905
+ /** Concurrent per-subscription scans and cursor writes per poll round; caps RPC and DB fan-out. */
1906
+ const pollConcurrency = 8
1907
+
1908
+ /** Event types scanned together when their combined state fits one existing scan round. */
1909
+ const pollEventTypeConcurrency = 2
1910
+
1911
+ /** Maximum subscription states retained by concurrent event-type scan batches. */
1912
+ const pollStateLimit = WebhookTransfer.scanCohortSize * pollConcurrency
1913
+
1914
+ type Schedule = <value>(fn: () => Promise<value>) => Promise<value>
1915
+
1916
+ /** Schedules asynchronous work through one shared concurrency bound. */
1917
+ function createLimiter(limit: number): Schedule {
1918
+ let active = 0
1919
+ const waiting: (() => void)[] = []
1920
+ return async (fn) => {
1921
+ if (active >= limit) await new Promise<void>((resolve) => waiting.push(resolve))
1922
+ else active++
1923
+ try {
1924
+ return await fn()
1925
+ } finally {
1926
+ const next = waiting.shift()
1927
+ if (next) next()
1928
+ else active--
1929
+ }
1930
+ }
1931
+ }
1932
+
1933
+ /** Runs `fn` over `items` with at most `limit` in flight, preserving order. */
1934
+ async function mapWithLimit<item, value>(
1935
+ items: readonly item[],
1936
+ limit: number,
1937
+ fn: (item: item) => Promise<value>,
1938
+ ): Promise<PromiseSettledResult<value>[]> {
1939
+ const results: PromiseSettledResult<value>[] = []
1940
+ let next = 0
1941
+ await Promise.all(
1942
+ Array.from({ length: Math.min(limit, items.length) }, async () => {
1943
+ while (next < items.length) {
1944
+ const index = next++
1945
+ try {
1946
+ results[index] = { status: 'fulfilled', value: await fn(items[index]!) }
1947
+ } catch (reason) {
1948
+ results[index] = { reason, status: 'rejected' }
1949
+ }
1950
+ }
1951
+ }),
1952
+ )
1953
+ return results
1954
+ }
1955
+
1920
1956
  /** Returns `candidate` only when it is lexicographically after `current`. */
1921
1957
  function laterCursor(current: string, candidate: string): string {
1922
1958
  const before = Cursor.decode(current, ['int', 'int'])
@@ -1935,13 +1971,9 @@ function laterCursor(current: string, candidate: string): string {
1935
1971
  * matching row, advancing each active subscription's `(block, index)` keyset
1936
1972
  * cursor.
1937
1973
  *
1938
- * The new monitoring event types (`log:emitted`, `block:created`,
1939
- * `transaction:included`) scan a **narrow confirmed window** `[cursor, head -
1940
- * confirmations]` directly over JSON-RPC (`eth_getLogs`, `eth_getBlockByNumber`,
1941
- * `eth_getBlockReceipts`) — the same data sources the indexer itself consumes —
1942
- * so they need no indexer and stay fresh. The pre-existing `token:transfer`
1943
- * type continues to read its humanized shape from TIDX, clamped to the indexed
1944
- * head so it never runs ahead of the indexer.
1974
+ * Every event type scans a **narrow confirmed window** `[cursor, head -
1975
+ * confirmations]` directly over JSON-RPC (`eth_getLogs`,
1976
+ * `eth_getBlockByNumber`) so webhook delivery does not wait for an indexer.
1945
1977
  *
1946
1978
  * The chain head is injectable (tests pin a fixed historical window); only
1947
1979
  * blocks at least `confirmations` behind head are emitted, giving at-least-once,
@@ -1959,37 +1991,51 @@ export async function poll(options: poll.Options): Promise<poll.Result> {
1959
1991
  cache: cache_metadata = Store.memory(),
1960
1992
  chainId,
1961
1993
  client,
1962
- confirmations = 2,
1994
+ confirmations = 1,
1963
1995
  db,
1964
- eventTypes = ['token:transfer', 'transaction:included', 'log:emitted', 'block:created'],
1996
+ eventTypes: eventTypes_input = [
1997
+ 'token:transfer',
1998
+ 'transaction:included',
1999
+ 'log:emitted',
2000
+ 'block:created',
2001
+ ],
1965
2002
  maxBlocks = 1_000,
1966
- tidx,
1967
2003
  } = options
1968
2004
  const now = options.now ?? (() => new Date())
2005
+ options.signal?.throwIfAborted()
1969
2006
  // Default (self-host) transport: deliver inline and record the outcome so a
1970
2007
  // sustained-failure subscription auto-disables. On Cloudflare, callers pass a
1971
2008
  // Queue producer instead and the consumer runs `deliverAndRecord`.
1972
2009
  const dispatch =
1973
2010
  options.dispatch ??
1974
2011
  (({ envelope, subscription }) => Webhooks.deliverAndRecord(db, subscription, envelope))
2012
+ const dispatchBatch = options.dispatchBatch
1975
2013
 
1976
- // Head resolution is lazy and per-source. RPC types work back from the live
1977
- // RPC head; the legacy TIDX types from the indexed head (never ahead of the
1978
- // indexer). A pinned `headBlockNumber` (tests) overrides both.
2014
+ // A pinned `headBlockNumber` lets a head subscription supply the exact live
2015
+ // boundary and keeps historical tests deterministic.
1979
2016
  const override = options.headBlockNumber
1980
2017
  let rpcHead: number | undefined = override === undefined ? undefined : Number(override)
1981
2018
  const resolveRpcHead = async () => (rpcHead ??= await getRpcHead(client))
1982
- let tidxHead: number | undefined = override === undefined ? undefined : Number(override)
1983
- const resolveTidxHead = async () =>
1984
- (tidxHead ??= tidx ? await getIndexedHead(tidx, chainId) : await resolveRpcHead())
1985
-
1986
- const rpcSafeBlock = Math.max(0, (await resolveRpcHead()) - confirmations)
1987
- // Reclaim expired rows (MPP-owned subscriptions, aged delivery logs) once
1988
- // per tick; reads filter expiry at query time, so this is hygiene only.
1989
- await Webhooks.pruneExpired(db, { now }).catch(() => {})
1990
- // Per-tick lazy cache so multiple subscriptions of the same/related types
1991
- // share fetched blocks and receipts instead of refetching the same window.
1992
- const cache = createChainCache(client)
2019
+
2020
+ const headStartedAt = Date.now()
2021
+ const rpcSafeBlock = await (async () => {
2022
+ try {
2023
+ const safeBlock = Math.max(0, (await resolveRpcHead()) - confirmations)
2024
+ options.onStage?.({
2025
+ durationMs: Date.now() - headStartedAt,
2026
+ outcome: 'ok',
2027
+ stage: 'rpc_head',
2028
+ })
2029
+ return safeBlock
2030
+ } catch (error) {
2031
+ options.onStage?.({
2032
+ durationMs: Date.now() - headStartedAt,
2033
+ outcome: 'error',
2034
+ stage: 'rpc_head',
2035
+ })
2036
+ throw error
2037
+ }
2038
+ })()
1993
2039
  const result: poll.Result = {
1994
2040
  chainId,
1995
2041
  dispatched: 0,
@@ -1998,134 +2044,337 @@ export async function poll(options: poll.Options): Promise<poll.Result> {
1998
2044
  scanned: 0,
1999
2045
  subscriptions: 0,
2000
2046
  }
2001
- const scanErrors: unknown[] = []
2002
-
2003
- for (const eventType of eventTypes) {
2004
- const legacy = eventType === 'token:transfer'
2005
- const safeBlock = legacy ? Math.max(0, (await resolveTidxHead()) - confirmations) : rpcSafeBlock
2006
- const subscriptions = await Webhooks.listActive(db, chainId, eventType)
2007
- let states: PollState[] = []
2008
- for (const subscription of subscriptions) {
2009
- result.subscriptions++
2010
- let cursor = await Webhooks.getCursor(db, subscription.id)
2011
- if (cursor === null) {
2012
- // Legacy subscriptions without a creation checkpoint anchor at the
2013
- // current confirmed head, avoiding a genesis backfill.
2014
- await Webhooks.compareAndSetCursor(db, {
2015
- cursor: Cursor.encode([safeBlock, endOfBlockIndex]),
2016
- expectedCursor: null,
2017
- subscriptionId: subscription.id,
2018
- })
2019
- continue
2047
+ let cursorLagBlocks = 0
2048
+ const pollErrors: unknown[] = []
2049
+
2050
+ const eventTypes = [...new Set(eventTypes_input)]
2051
+ const subscriptionsStartedAt = Date.now()
2052
+ const pairs = await Webhooks.listActiveForChain(db, { chainId, eventTypes })
2053
+ options.signal?.throwIfAborted()
2054
+ options.onSubscriptions?.(pairs.length)
2055
+ options.onStage?.({
2056
+ durationMs: Date.now() - subscriptionsStartedAt,
2057
+ outcome: 'ok',
2058
+ stage: 'subscription_read',
2059
+ })
2060
+ const pairsByEventType = Map.groupBy(pairs, (pair) => pair.subscription.eventType)
2061
+ const eventTypes_active = eventTypes.filter(
2062
+ (eventType) => (pairsByEventType.get(eventType)?.length ?? 0) > 0,
2063
+ )
2064
+ const eventTypeConcurrency =
2065
+ pairs.length <= pollStateLimit
2066
+ ? Math.min(pollEventTypeConcurrency, eventTypes_active.length)
2067
+ : Math.min(1, eventTypes_active.length)
2068
+ const scanRoundSize = Math.floor(pollStateLimit / Math.max(1, eventTypeConcurrency))
2069
+ const scheduleScan = createLimiter(pollConcurrency)
2070
+
2071
+ const eventTypeResults = await mapWithLimit(
2072
+ eventTypes_active,
2073
+ eventTypeConcurrency,
2074
+ async (eventType) => {
2075
+ options.signal?.throwIfAborted()
2076
+ const safeBlock = rpcSafeBlock
2077
+ const pairs = pairsByEventType.get(eventType) ?? []
2078
+ const durations = {
2079
+ cursor_write: 0,
2080
+ enrich: 0,
2081
+ scan: 0,
2082
+ } satisfies Record<Exclude<MetricSink.PollStage, 'rpc_head' | 'subscription_read'>, number>
2083
+ const observed = new Set<keyof typeof durations>()
2084
+ let cursorWriteOutcome: 'error' | 'ok' = 'ok'
2085
+ let scanOutcome: 'error' | 'ok' = 'ok'
2086
+ const writeCursor = async (parameters: Webhooks.compareAndSetCursor.Options) => {
2087
+ const startedAt = Date.now()
2088
+ observed.add('cursor_write')
2089
+ try {
2090
+ return await Webhooks.compareAndSetCursor(db, parameters)
2091
+ } catch (error) {
2092
+ cursorWriteOutcome = 'error'
2093
+ pollErrors.push(error)
2094
+ return false
2095
+ } finally {
2096
+ durations.cursor_write += Date.now() - startedAt
2097
+ }
2020
2098
  }
2021
- const checkpoint = Cursor.decode(cursor, ['int'])
2022
- if (checkpoint) {
2023
- // Creation checkpoints record the live head without finalizing it. Rebase
2024
- // to this poller's confirmed boundary so replacement blocks stay eligible.
2025
- const confirmedCursor = Cursor.encode([
2026
- Math.max(0, (checkpoint[0] as number) - confirmations),
2027
- endOfBlockIndex,
2028
- ])
2029
- const committed = await Webhooks.compareAndSetCursor(db, {
2030
- cursor: confirmedCursor,
2031
- expectedCursor: cursor,
2032
- subscriptionId: subscription.id,
2099
+ let states: PollState[] = []
2100
+ for (const pair of pairs) {
2101
+ const { subscription } = pair
2102
+ result.subscriptions++
2103
+ let filters: Record<string, unknown>
2104
+ try {
2105
+ filters = parseFilters(subscription.eventType, subscription.filters)
2106
+ } catch (error) {
2107
+ if (!(error instanceof Webhooks.InvalidFilterError)) throw error
2108
+ options.signal?.throwIfAborted()
2109
+ // A breaking filter-schema removal must not poison every chain poll.
2110
+ // Disable the legacy subscription without widening its match set.
2111
+ await Webhooks.updateSubscription(db, subscription.owner, subscription.id, {
2112
+ status: 'disabled',
2113
+ })
2114
+ continue
2115
+ }
2116
+ let cursor = pair.cursor
2117
+ if (cursor === null) {
2118
+ // Legacy subscriptions without a creation checkpoint anchor at the
2119
+ // current confirmed head, avoiding a genesis backfill.
2120
+ await writeCursor({
2121
+ cursor: Cursor.encode([safeBlock, endOfBlockIndex]),
2122
+ expectedCursor: null,
2123
+ subscriptionId: subscription.id,
2124
+ })
2125
+ continue
2126
+ }
2127
+ const checkpoint = Cursor.decode(cursor, ['int'])
2128
+ if (checkpoint) {
2129
+ // Creation checkpoints record the live head without finalizing it. Rebase
2130
+ // to this poller's confirmed boundary so replacement blocks stay eligible.
2131
+ const confirmedCursor = Cursor.encode([
2132
+ Math.max(0, (checkpoint[0] as number) - confirmations),
2133
+ endOfBlockIndex,
2134
+ ])
2135
+ const committed = await writeCursor({
2136
+ cursor: confirmedCursor,
2137
+ expectedCursor: cursor,
2138
+ subscriptionId: subscription.id,
2139
+ })
2140
+ if (!committed) continue
2141
+ cursor = confirmedCursor
2142
+ }
2143
+ const position = Cursor.decode(cursor, ['int', 'int'])
2144
+ if (position)
2145
+ cursorLagBlocks = Math.max(cursorLagBlocks, safeBlock - (position[0] as number))
2146
+ const window = scanWindow(cursor, safeBlock, maxBlocks)
2147
+ if (window && window.to < safeBlock) result.hasMore = true
2148
+ states.push({
2149
+ cursor,
2150
+ filters,
2151
+ remaining: batchSize,
2152
+ safeBlock: window?.to ?? safeBlock,
2153
+ subscription,
2033
2154
  })
2034
- if (!committed) continue
2035
- cursor = confirmedCursor
2036
2155
  }
2037
- const window = legacy ? undefined : scanWindow(cursor, safeBlock, maxBlocks)
2038
- states.push({
2039
- cursor,
2040
- remaining: batchSize,
2041
- safeBlock: window?.to ?? safeBlock,
2042
- subscription,
2043
- })
2044
- }
2045
2156
 
2046
- // Every subscription gets one bounded page before any subscription gets a
2047
- // continuation page, preserving fairness while allowing catch-up.
2048
- while (states.length > 0) {
2049
- const continuations: PollState[] = []
2050
- for (const state of states) {
2051
- const { subscription } = state
2052
- let page: ScanPage
2053
- try {
2054
- page = await scanSubscription({
2055
- batchSize: Math.min(scanPageSize, state.remaining),
2056
- cache,
2157
+ // Every subscription gets one bounded page before any subscription gets a
2158
+ // continuation page, preserving fairness while allowing catch-up.
2159
+ while (states.length > 0) {
2160
+ options.signal?.throwIfAborted()
2161
+ const continuations: PollState[] = []
2162
+ const round = states
2163
+ for (let offset = 0; offset < round.length; offset += scanRoundSize) {
2164
+ const batch = round.slice(offset, offset + scanRoundSize)
2165
+ const windows = batch.flatMap((state) => {
2166
+ const window = scanWindow(state.cursor, state.safeBlock, maxBlocks)
2167
+ return window ? [window] : []
2168
+ })
2169
+ const maxWindowBlocks = Math.max(
2170
+ 0,
2171
+ ...windows.map((window) => window.to - window.from + 1),
2172
+ )
2173
+ const totalWindowBlocks = windows.reduce(
2174
+ (total, window) => total + window.to - window.from + 1,
2175
+ 0,
2176
+ )
2177
+ options.onScanCheckpoint?.({
2178
+ concurrency: pollConcurrency,
2179
+ eventType,
2180
+ maxWindowBlocks,
2181
+ phase: 'start',
2182
+ rpcMethod: scanRpcMethod(eventType),
2183
+ subscriptionLimit: scanRoundSize,
2184
+ subscriptionCount: batch.length,
2185
+ totalWindowBlocks,
2186
+ })
2187
+ const scanStartedAt = Date.now()
2188
+ const pages = await scanSubscriptions({
2057
2189
  client,
2058
- cursor: state.cursor,
2190
+ eventType,
2059
2191
  maxBlocks,
2060
- safeBlock: state.safeBlock,
2061
- subscription,
2062
- tidx,
2192
+ schedule: scheduleScan,
2193
+ states: batch,
2063
2194
  })
2064
- } catch (error) {
2065
- scanErrors.push(error)
2066
- continue
2067
- }
2068
- result.scanned += page.items.length
2069
- state.remaining -= page.items.length
2070
-
2071
- let accepted: readonly [number, number] | undefined
2072
- let failed = false
2073
- for (const item of page.items) {
2074
- // Token metadata is best-effort for webhooks: transfers use it for display,
2075
- // while transactions expose fee-token references without blocking delivery.
2076
- const data =
2077
- eventType === 'token:transfer'
2078
- ? await withTokenMetadata(item.data, { cache: cache_metadata, chainId, client })
2079
- : eventType === 'transaction:included'
2080
- ? await withFeeTokenMetadata(item.data, { cache: cache_metadata, chainId, client })
2081
- : item.data
2082
- const envelope = Webhooks.buildEnvelope({
2083
- blockNumber: item.cursor[0],
2084
- createdAt: now(),
2085
- data,
2086
- logIndex: item.cursor[1],
2087
- subscription,
2195
+ options.onScanCheckpoint?.({
2196
+ concurrency: pollConcurrency,
2197
+ durationMs: Date.now() - scanStartedAt,
2198
+ eventType,
2199
+ failedSubscriptions: pages.filter((page) => page.status === 'rejected').length,
2200
+ matchedItems: pages.reduce(
2201
+ (total, page) => total + (page.status === 'fulfilled' ? page.value.items.length : 0),
2202
+ 0,
2203
+ ),
2204
+ maxWindowBlocks,
2205
+ outcome: scanResultOutcome(pages),
2206
+ phase: 'complete',
2207
+ rpcMethod: scanRpcMethod(eventType),
2208
+ subscriptionLimit: scanRoundSize,
2209
+ subscriptionCount: batch.length,
2210
+ totalWindowBlocks,
2088
2211
  })
2089
- const delivered = await dispatch({ envelope, subscription })
2090
- if (!delivered.ok) {
2091
- result.failed++
2092
- failed = true
2093
- break
2212
+ options.signal?.throwIfAborted()
2213
+ durations.scan += Date.now() - scanStartedAt
2214
+ observed.add('scan')
2215
+ if (pages.some((page) => page.status === 'rejected')) scanOutcome = 'error'
2216
+
2217
+ type Scanned = { page: ScanPage; state: PollState }
2218
+ const busy: Scanned[] = []
2219
+ const idle: Scanned[] = []
2220
+ for (let i = 0; i < batch.length; i++) {
2221
+ const state = batch[i]!
2222
+ const settled = pages[i]!
2223
+ if (settled.status === 'rejected') {
2224
+ pollErrors.push(settled.reason)
2225
+ continue
2226
+ }
2227
+ result.scanned += settled.value.items.length
2228
+ state.remaining -= settled.value.items.length
2229
+ ;(settled.value.items.length > 0 ? busy : idle).push({ page: settled.value, state })
2094
2230
  }
2095
- accepted = item.cursor
2096
- result.dispatched++
2097
- }
2098
2231
 
2099
- // Persist every accepted page before another subscription begins its
2100
- // continuation, so a later scan failure retains completed work.
2101
- const candidate = (() => {
2102
- if (failed) return accepted ? Cursor.encode([...accepted]) : undefined
2103
- if (page.scannedThrough !== undefined)
2104
- return Cursor.encode([page.scannedThrough, endOfBlockIndex])
2105
- if (accepted) return Cursor.encode([...accepted])
2106
- if (!page.hasMore) return Cursor.encode([state.safeBlock, endOfBlockIndex])
2107
- return undefined
2108
- })()
2109
- const cursor = candidate ? laterCursor(state.cursor, candidate) : state.cursor
2110
- const progressed = cursor !== state.cursor
2111
- let committed = false
2112
- if (progressed) {
2113
- committed = await Webhooks.compareAndSetCursor(db, {
2114
- cursor,
2115
- expectedCursor: state.cursor,
2116
- subscriptionId: subscription.id,
2232
+ // Empty pages only advance their cursor: no dispatch order to preserve,
2233
+ // so one statement commits the cohort while retaining per-row CAS semantics.
2234
+ const cursorWritesStartedAt = Date.now()
2235
+ const candidates = idle.flatMap(({ page, state }) => {
2236
+ options.signal?.throwIfAborted()
2237
+ const candidate =
2238
+ page.scannedThrough !== undefined
2239
+ ? Cursor.encode([page.scannedThrough, endOfBlockIndex])
2240
+ : page.hasMore
2241
+ ? undefined
2242
+ : Cursor.encode([state.safeBlock, endOfBlockIndex])
2243
+ const cursor = candidate ? laterCursor(state.cursor, candidate) : state.cursor
2244
+ return cursor === state.cursor ? [] : [{ cursor, state }]
2117
2245
  })
2118
- if (committed) state.cursor = cursor
2119
- }
2246
+ if (candidates.length > 0) {
2247
+ const committed = await (async () => {
2248
+ try {
2249
+ return await Webhooks.compareAndSetCursors(db, {
2250
+ cursors: candidates.map(({ cursor, state }) => ({
2251
+ cursor,
2252
+ expectedCursor: state.cursor,
2253
+ subscriptionId: state.subscription.id,
2254
+ })),
2255
+ })
2256
+ } catch (error) {
2257
+ cursorWriteOutcome = 'error'
2258
+ pollErrors.push(error)
2259
+ return new Set<string>()
2260
+ }
2261
+ })()
2262
+ for (const { cursor, state } of candidates)
2263
+ if (committed.has(state.subscription.id)) state.cursor = cursor
2264
+ durations.cursor_write += Date.now() - cursorWritesStartedAt
2265
+ observed.add('cursor_write')
2266
+ }
2120
2267
 
2121
- if (!failed && page.hasMore === true && committed && state.remaining > 0)
2122
- continuations.push(state)
2268
+ for (const { page, state } of busy) {
2269
+ options.signal?.throwIfAborted()
2270
+ const { subscription } = state
2271
+
2272
+ let accepted: readonly [number, number] | undefined
2273
+ let failed = false
2274
+ const buildDispatchable = async (item: ScanItem): Promise<poll.Dispatchable> => {
2275
+ options.signal?.throwIfAborted()
2276
+ // Token metadata is best-effort for webhooks: transfers use it for display,
2277
+ // while transactions expose fee-token references without blocking delivery.
2278
+ const enrichStartedAt = Date.now()
2279
+ const data =
2280
+ eventType === 'token:transfer'
2281
+ ? await withTokenMetadata(item.data, { cache: cache_metadata, chainId, client })
2282
+ : eventType === 'transaction:included'
2283
+ ? await withFeeTokenMetadata(item.data, {
2284
+ cache: cache_metadata,
2285
+ chainId,
2286
+ client,
2287
+ })
2288
+ : item.data
2289
+ durations.enrich += Date.now() - enrichStartedAt
2290
+ observed.add('enrich')
2291
+ options.signal?.throwIfAborted()
2292
+ const envelope = Webhooks.buildEnvelope({
2293
+ blockNumber: item.cursor[0],
2294
+ createdAt: now(),
2295
+ data,
2296
+ logIndex: item.cursor[1],
2297
+ subscription,
2298
+ })
2299
+ return { envelope, subscription }
2300
+ }
2301
+
2302
+ if (dispatchBatch) {
2303
+ const dispatchables: poll.Dispatchable[] = []
2304
+ for (const item of page.items) dispatchables.push(await buildDispatchable(item))
2305
+ options.signal?.throwIfAborted()
2306
+ const delivered = await dispatchBatch(dispatchables, { db })
2307
+ if (delivered.ok) {
2308
+ accepted = page.items.at(-1)?.cursor
2309
+ result.dispatched += dispatchables.length
2310
+ } else {
2311
+ result.failed += dispatchables.length
2312
+ failed = true
2313
+ }
2314
+ } else {
2315
+ for (const item of page.items) {
2316
+ const dispatchable = await buildDispatchable(item)
2317
+ const delivered = await dispatch(dispatchable)
2318
+ if (!delivered.ok) {
2319
+ result.failed++
2320
+ failed = true
2321
+ break
2322
+ }
2323
+ accepted = item.cursor
2324
+ result.dispatched++
2325
+ }
2326
+ }
2327
+
2328
+ // Persist every accepted page before another subscription begins its
2329
+ // continuation, so a later scan failure retains completed work.
2330
+ const candidate = (() => {
2331
+ if (failed) return accepted ? Cursor.encode([...accepted]) : undefined
2332
+ if (page.scannedThrough !== undefined)
2333
+ return Cursor.encode([page.scannedThrough, endOfBlockIndex])
2334
+ if (accepted) return Cursor.encode([...accepted])
2335
+ if (!page.hasMore) return Cursor.encode([state.safeBlock, endOfBlockIndex])
2336
+ return undefined
2337
+ })()
2338
+ const cursor = candidate ? laterCursor(state.cursor, candidate) : state.cursor
2339
+ const progressed = cursor !== state.cursor
2340
+ let committed = false
2341
+ if (progressed) {
2342
+ committed = await writeCursor({
2343
+ cursor,
2344
+ expectedCursor: state.cursor,
2345
+ subscriptionId: subscription.id,
2346
+ })
2347
+ if (committed) state.cursor = cursor
2348
+ }
2349
+
2350
+ if (!failed && page.hasMore === true) {
2351
+ if (committed && state.remaining > 0) continuations.push(state)
2352
+ else result.hasMore = true
2353
+ }
2354
+ }
2355
+ }
2356
+ states = continuations
2123
2357
  }
2124
- states = continuations
2125
- }
2126
- }
2358
+ for (const stage of observed) {
2359
+ const outcome = (() => {
2360
+ if (stage === 'cursor_write') return cursorWriteOutcome
2361
+ if (stage === 'scan') return scanOutcome
2362
+ return 'ok'
2363
+ })()
2364
+ options.onStage?.({
2365
+ durationMs: durations[stage],
2366
+ eventType,
2367
+ outcome,
2368
+ stage,
2369
+ })
2370
+ }
2371
+ },
2372
+ )
2373
+ for (const result of eventTypeResults)
2374
+ if (result.status === 'rejected') pollErrors.push(result.reason)
2127
2375
 
2128
- if (scanErrors.length > 0) throw scanErrors[0]
2376
+ options.onCursorLag?.(cursorLagBlocks)
2377
+ if (pollErrors.length > 0) throw pollErrors[0]
2129
2378
  return result
2130
2379
  }
2131
2380
 
@@ -2145,9 +2394,9 @@ export declare namespace poll {
2145
2394
  cache?: Store.Store | undefined
2146
2395
  /** Chain to poll. Callers loop over chains. */
2147
2396
  chainId: number
2148
- /** viem RPC client for the chain (used by the RPC monitoring scanners). */
2397
+ /** viem RPC client for the chain. */
2149
2398
  client: Viem.getClient.ReturnType
2150
- /** Reorg-safety window: only emit blocks this far behind head (default 2). */
2399
+ /** Reorg-safety window: only emit blocks this far behind head (default 1). */
2151
2400
  confirmations?: number | undefined
2152
2401
  /** Database holding subscriptions + cursors. */
2153
2402
  db: Db.Db
@@ -2156,16 +2405,34 @@ export declare namespace poll {
2156
2405
  * on Cloudflare pass a Queue producer so retry/backoff/DLQ are native.
2157
2406
  */
2158
2407
  dispatch?: ((input: Dispatchable) => Promise<Webhooks.Result>) | undefined
2408
+ /** Batch delivery transport. A successful result confirms every row in the input. */
2409
+ dispatchBatch?:
2410
+ | ((input: readonly Dispatchable[], context: DispatchContext) => Promise<Webhooks.Result>)
2411
+ | undefined
2159
2412
  /** Event types to poll (default all). */
2160
2413
  eventTypes?: readonly EventType[] | undefined
2161
2414
  /** Current chain head; the poller emits up to `head - confirmations`. */
2162
2415
  headBlockNumber?: bigint | number | undefined
2163
2416
  /** Backfill cap: most blocks an RPC scanner inspects per tick (default 1000). */
2164
2417
  maxBlocks?: number | undefined
2418
+ /** Records the worst confirmed-head distance without changing the poll result. */
2419
+ onCursorLag?: ((blocks: number) => void) | undefined
2420
+ /** Records bounded scan inputs before allocation and outcomes after completion. */
2421
+ onScanCheckpoint?: ((checkpoint: ScanCheckpoint) => void) | undefined
2422
+ /** Records bounded, per-tick poll-stage timings without subscription identities. */
2423
+ onStage?: ((stage: Stage) => void) | undefined
2424
+ /** Records subscriptions eligible for this poll before any scan runs. */
2425
+ onSubscriptions?: ((count: number) => void) | undefined
2165
2426
  /** Clock for envelope timestamps; injectable for tests. */
2166
2427
  now?: (() => Date) | undefined
2167
- /** TIDX query client for the legacy `token:transfer` type. */
2168
- tidx?: Tidx.Client | undefined
2428
+ /** Stops scanning and dispatch when the caller loses its execution lease. */
2429
+ signal?: AbortSignal | undefined
2430
+ }
2431
+
2432
+ /** Runtime context shared with a batch delivery transport. */
2433
+ type DispatchContext = {
2434
+ /** Database instance already resolved for this poll. */
2435
+ db: Db.Db
2169
2436
  }
2170
2437
  /** Summary counters for one poll tick. */
2171
2438
  type Result = {
@@ -2175,6 +2442,8 @@ export declare namespace poll {
2175
2442
  dispatched: number
2176
2443
  /** Rows whose dispatch failed (pausing that subscription). */
2177
2444
  failed: number
2445
+ /** True when a bounded scan left committed work behind the same head. */
2446
+ hasMore?: boolean | undefined
2178
2447
  /** Highest confirmed block emitted this tick (`head - confirmations`). */
2179
2448
  safeBlock: number
2180
2449
  /** Total rows scanned across all subscriptions. */
@@ -2182,6 +2451,44 @@ export declare namespace poll {
2182
2451
  /** Active subscriptions visited. */
2183
2452
  subscriptions: number
2184
2453
  }
2454
+ /** One scan boundary used to correlate production memory failures with workload shape. */
2455
+ type ScanCheckpoint = {
2456
+ /** Shared upper bound for concurrent upstream scans across event types. */
2457
+ concurrency: number
2458
+ /** Scan duration, present only after completion. */
2459
+ durationMs?: number | undefined
2460
+ /** Subscription event type scanned by this batch. */
2461
+ eventType: EventType
2462
+ /** Subscriptions whose scan rejected, present only after completion. */
2463
+ failedSubscriptions?: number | undefined
2464
+ /** Matching rows retained by completed pages, present only after completion. */
2465
+ matchedItems?: number | undefined
2466
+ /** Largest block window requested by one subscription. */
2467
+ maxWindowBlocks: number
2468
+ /** Whether the checkpoint precedes allocation or follows scan completion. */
2469
+ phase: 'complete' | 'start'
2470
+ /** Outcome of the completed RPC-backed scan. */
2471
+ outcome?: 'empty' | 'error' | 'missing' | 'ok' | undefined
2472
+ /** JSON-RPC method used by this event type's scanner. */
2473
+ rpcMethod: 'eth_getBlockByNumber' | 'eth_getLogs'
2474
+ /** Subscriptions retained in this scan batch. */
2475
+ subscriptionCount: number
2476
+ /** Maximum subscriptions retained by this event-type batch. */
2477
+ subscriptionLimit: number
2478
+ /** Sum of subscription block windows in this scan batch. */
2479
+ totalWindowBlocks: number
2480
+ }
2481
+ /** One bounded poll-stage timing observation. */
2482
+ type Stage = {
2483
+ /** Stage duration in milliseconds. */
2484
+ durationMs: number
2485
+ /** Event type being processed, omitted for the shared RPC-head stage. */
2486
+ eventType?: EventType | undefined
2487
+ /** Whether the stage completed. */
2488
+ outcome: 'error' | 'ok'
2489
+ /** Bounded stage name. */
2490
+ stage: MetricSink.PollStage
2491
+ }
2185
2492
  }
2186
2493
 
2187
2494
  /**
@@ -2270,7 +2577,7 @@ function withResolvedFeeToken(data: Record<string, unknown>, metadata: Map<strin
2270
2577
  return result
2271
2578
  }
2272
2579
 
2273
- /** Adds best-effort fee-token references to a transaction and its embedded receipt. */
2580
+ /** Adds a best-effort fee-token reference to a transaction. */
2274
2581
  async function withFeeTokenMetadata(
2275
2582
  data: unknown,
2276
2583
  deps: {
@@ -2281,37 +2588,12 @@ async function withFeeTokenMetadata(
2281
2588
  ): Promise<unknown> {
2282
2589
  if (typeof data !== 'object' || data === null) return data
2283
2590
  const transaction = data as Record<string, unknown>
2284
- const meta = transaction['meta']
2285
- const receipt =
2286
- typeof meta === 'object' && meta !== null
2287
- ? (meta as Record<string, unknown>)['receipt']
2288
- : undefined
2289
- const addresses = new Set<string>()
2290
- const transactionAddress = feeTokenAddress(transaction['feeToken'])
2291
- if (transactionAddress) addresses.add(transactionAddress.toLowerCase())
2292
- if (typeof receipt === 'object' && receipt !== null) {
2293
- const receiptAddress = feeTokenAddress((receipt as Record<string, unknown>)['feeToken'])
2294
- if (receiptAddress) addresses.add(receiptAddress.toLowerCase())
2295
- }
2296
- if (addresses.size === 0) return data
2297
-
2298
- const resolved = await Promise.all(
2299
- [...addresses].map(
2300
- async (address) => [address, await resolveTokenMetadata(address, deps)] as const,
2301
- ),
2302
- )
2303
- const metadata = new Map(
2304
- resolved.filter((entry): entry is readonly [string, TokenMetadata] => entry[1] !== undefined),
2305
- )
2306
- const result = withResolvedFeeToken(transaction, metadata)
2307
- if (typeof receipt !== 'object' || receipt === null) return result
2308
- return {
2309
- ...result,
2310
- meta: {
2311
- ...(meta as Record<string, unknown>),
2312
- receipt: withResolvedFeeToken(receipt as Record<string, unknown>, metadata),
2313
- },
2314
- }
2591
+ const address = feeTokenAddress(transaction['feeToken'])
2592
+ if (!address) return data
2593
+ const token = await resolveTokenMetadata(address, deps)
2594
+ const metadata = new Map<string, TokenMetadata>()
2595
+ if (token) metadata.set(address.toLowerCase(), token)
2596
+ return withResolvedFeeToken(transaction, metadata)
2315
2597
  }
2316
2598
 
2317
2599
  /** Returns the live RPC head block number for the chain. */
@@ -2319,81 +2601,74 @@ export async function getRpcHead(client: Viem.getClient.ReturnType): Promise<num
2319
2601
  return Number(await client.getBlockNumber())
2320
2602
  }
2321
2603
 
2322
- /**
2323
- * Returns the highest block number TIDX has indexed for a chain (0 when none).
2324
- * The legacy `token:transfer` type works back from this so it never scans past
2325
- * the indexer.
2326
- */
2327
- export async function getIndexedHead(tidx: Tidx.Client, chainId: number): Promise<number> {
2328
- const result = await tidx.fetch({
2329
- chainId,
2330
- query: 'SELECT num FROM blocks ORDER BY num DESC LIMIT 1' as string,
2331
- })
2332
- return Value.toNumber(result.rows[0]?.['num']) ?? 0
2333
- }
2334
-
2335
- /**
2336
- * Scans new events for one subscription. The RPC monitoring types
2337
- * (`log:emitted` / `block:created` / `transaction:included`) read a confirmed
2338
- * window directly over JSON-RPC; the legacy `token:transfer` type dispatches to
2339
- * its TIDX read core (`Transfers.scan`).
2340
- */
2341
- async function scanSubscription(options: {
2342
- batchSize: number
2343
- cache: ChainCache
2604
+ /** Groups aligned cursors only when their bounded upstream scans are equivalent. */
2605
+ async function scanSubscriptions(options: {
2344
2606
  client: Viem.getClient.ReturnType
2345
- cursor: string | undefined
2607
+ eventType: EventType
2346
2608
  maxBlocks: number
2347
- safeBlock: number
2348
- subscription: Webhooks.Subscription
2349
- tidx: Tidx.Client | undefined
2350
- }): Promise<ScanPage> {
2351
- const { batchSize, cache, client, cursor, maxBlocks, safeBlock, subscription, tidx } = options
2352
- // Stored `chainId` was validated against `Schema.ChainId` at create time.
2353
- const chainId = subscription.chainId as z.output<typeof Schema.ChainId>
2354
-
2355
- if (subscription.eventType === 'log:emitted')
2356
- return scanLogs({
2357
- batchSize,
2358
- cache,
2359
- client,
2360
- cursor,
2361
- filters: parseFilters('log:emitted', subscription.filters),
2362
- maxBlocks,
2363
- safeBlock,
2364
- })
2609
+ schedule: Schedule
2610
+ states: readonly PollState[]
2611
+ }): Promise<PromiseSettledResult<ScanPage>[]> {
2612
+ const { client, eventType, maxBlocks, schedule, states } = options
2613
+ if (eventType !== 'token:transfer')
2614
+ return scanIndependent({ client, eventType, maxBlocks, schedule, states })
2615
+ return WebhookTransfer.scan({
2616
+ client,
2617
+ concurrency: pollConcurrency,
2618
+ maxBlocks,
2619
+ schedule,
2620
+ states: states.map((state) => ({
2621
+ filters: state.filters,
2622
+ limit: Math.min(scanPageSize, state.remaining),
2623
+ window: scanWindow(state.cursor, state.safeBlock, maxBlocks),
2624
+ })),
2625
+ })
2626
+ }
2365
2627
 
2366
- if (subscription.eventType === 'block:created')
2367
- return scanBlocks({
2368
- batchSize,
2369
- cache,
2370
- cursor,
2371
- filters: parseFilters('block:created', subscription.filters),
2372
- maxBlocks,
2373
- safeBlock,
2374
- })
2628
+ /** Returns the bounded JSON-RPC method used by an event scanner. */
2629
+ function scanRpcMethod(eventType: EventType): poll.ScanCheckpoint['rpcMethod'] {
2630
+ return eventType === 'block:created' || eventType === 'transaction:included'
2631
+ ? 'eth_getBlockByNumber'
2632
+ : 'eth_getLogs'
2633
+ }
2375
2634
 
2376
- if (subscription.eventType === 'transaction:included')
2377
- return scanTxs({
2378
- batchSize,
2379
- cache,
2380
- cursor,
2381
- filters: parseFilters('transaction:included', subscription.filters),
2382
- maxBlocks,
2383
- safeBlock,
2384
- })
2635
+ /** Classifies a completed batch without exposing an unbounded upstream error. */
2636
+ function scanResultOutcome(
2637
+ pages: readonly PromiseSettledResult<ScanPage>[],
2638
+ ): NonNullable<poll.ScanCheckpoint['outcome']> {
2639
+ const rejected = pages.filter((page) => page.status === 'rejected')
2640
+ if (rejected.some((page) => page.reason instanceof BlockUnavailableError)) return 'missing'
2641
+ if (rejected.length > 0) return 'error'
2642
+ return pages.every((page) => page.status === 'fulfilled' && page.value.items.length === 0)
2643
+ ? 'empty'
2644
+ : 'ok'
2645
+ }
2385
2646
 
2386
- // Legacy TIDX-backed type.
2387
- if (!tidx) return { hasMore: false, items: [] }
2388
- const filters = transferFilters(subscription.filters)
2389
- const page = await Transfers.scan(
2390
- { tidx },
2391
- { chainId, cursor, limit: batchSize, order: 'asc', toBlock: safeBlock, ...filters },
2647
+ /** Scans CPU-matched streams per subscription while sharing concurrent block reads. */
2648
+ function scanIndependent(options: {
2649
+ client: Viem.getClient.ReturnType
2650
+ eventType: Exclude<EventType, 'token:transfer'>
2651
+ maxBlocks: number
2652
+ schedule: Schedule
2653
+ states: readonly PollState[]
2654
+ }): Promise<PromiseSettledResult<ScanPage>[]> {
2655
+ const { client, eventType, maxBlocks, schedule, states } = options
2656
+ const cache = createChainCache(client)
2657
+ return mapWithLimit(states, pollConcurrency, (state) =>
2658
+ schedule(() => {
2659
+ const shared = {
2660
+ batchSize: Math.min(scanPageSize, state.remaining),
2661
+ cache,
2662
+ cursor: state.cursor,
2663
+ filters: state.filters,
2664
+ maxBlocks,
2665
+ safeBlock: state.safeBlock,
2666
+ }
2667
+ if (eventType === 'block:created') return scanBlocks(shared)
2668
+ if (eventType === 'log:emitted') return scanLogs({ ...shared, client })
2669
+ return scanTxs(shared)
2670
+ }),
2392
2671
  )
2393
- // A cap-truncated fallback sample is an arbitrary subset: dispatching it
2394
- // would advance the cursor past unseen matches. Hold position and retry.
2395
- if (page.capped) return { hasMore: true, items: [] }
2396
- return { hasMore: page.hasMore, items: page.items }
2397
2672
  }
2398
2673
 
2399
2674
  const filterSchemas = {
@@ -2422,18 +2697,6 @@ export function parseFilters(
2422
2697
  return result.data as Record<string, unknown>
2423
2698
  }
2424
2699
 
2425
- /** Narrows stored `transfer` filters into validated `Transfers.scan` predicates. */
2426
- function transferFilters(filters: Record<string, unknown>) {
2427
- const parsed = schema.TransferFilters.safeParse(filters)
2428
- const { address, recipient, sender, token } = parsed.success ? parsed.data : {}
2429
- return {
2430
- ...(address === undefined ? {} : { eitherSide: address }),
2431
- ...(recipient === undefined ? {} : { recipient }),
2432
- ...(sender === undefined ? {} : { sender }),
2433
- ...(token === undefined ? {} : { token }),
2434
- }
2435
- }
2436
-
2437
2700
  /** Recursively lowercases string leaves of a filter operator (for address args). */
2438
2701
  function lowercaseLeaves(op: unknown): unknown {
2439
2702
  if (typeof op === 'string') return op.toLowerCase()
@@ -2573,62 +2836,49 @@ function atOrBefore(block: number, index: number, skip: readonly [number, number
2573
2836
  /** A viem-formatted block header (no transactions), as returned by `getBlock`. */
2574
2837
  type RpcBlock = Awaited<ReturnType<Viem.getClient.ReturnType['getBlock']>>
2575
2838
 
2576
- /** Per-tick lazy cache of fetched block headers, full blocks, and receipts. */
2839
+ /** Per-batch single-flight cache for chain RPC reads. */
2577
2840
  type ChainCache = {
2578
2841
  /** Full raw block (with transactions) via `eth_getBlockByNumber(n, true)`. */
2579
2842
  block(n: number): Promise<Record<string, unknown>>
2580
2843
  /** Viem-formatted block header (transaction hashes only) via `getBlock`. */
2581
2844
  header(n: number): Promise<RpcBlock | undefined>
2582
- /** Receipts for a block keyed by lowercased transaction hash. */
2583
- receipts(n: number): Promise<Map<string, Record<string, unknown>>>
2584
2845
  }
2585
2846
 
2586
- /** Builds a per-tick {@link ChainCache} bound to a viem client. */
2847
+ /** Builds a per-batch {@link ChainCache} bound to a viem client. */
2587
2848
  function createChainCache(client: Viem.getClient.ReturnType): ChainCache {
2588
2849
  const headers = new Map<number, Promise<RpcBlock | undefined>>()
2589
2850
  const blocks = new Map<number, Promise<Record<string, unknown>>>()
2590
- const receipts = new Map<number, Promise<Map<string, Record<string, unknown>>>>()
2851
+
2852
+ const singleFlight = <key, value>(
2853
+ pending: Map<key, Promise<value>>,
2854
+ key: key,
2855
+ load: () => Promise<value>,
2856
+ ) => {
2857
+ const existing = pending.get(key)
2858
+ if (existing) return existing
2859
+ const promise = load().finally(() => pending.delete(key))
2860
+ pending.set(key, promise)
2861
+ return promise
2862
+ }
2863
+
2591
2864
  return {
2592
2865
  block(n) {
2593
- let p = blocks.get(n)
2594
- if (!p) {
2866
+ return singleFlight(blocks, n, () =>
2595
2867
  // The transaction payload reuses `humanizeTransaction`, which consumes
2596
2868
  // the raw RPC tx shape, so the full block stays on the untyped path.
2597
- p = rpc<Record<string, unknown> | null>(client, 'eth_getBlockByNumber', [
2869
+ rpc<Record<string, unknown> | null>(client, 'eth_getBlockByNumber', [
2598
2870
  blockTag(n),
2599
2871
  true,
2600
2872
  ]).then((block) => {
2601
2873
  if (!block) throw new BlockUnavailableError(n)
2602
2874
  return block
2603
- })
2604
- blocks.set(n, p)
2605
- }
2606
- return p
2875
+ }),
2876
+ )
2607
2877
  },
2608
2878
  header(n) {
2609
- let p = headers.get(n)
2610
- if (!p) {
2611
- p = client.getBlock({ blockNumber: BigInt(n), includeTransactions: false })
2612
- headers.set(n, p)
2613
- }
2614
- return p
2615
- },
2616
- receipts(n) {
2617
- let p = receipts.get(n)
2618
- if (!p) {
2619
- p = rpc<Record<string, unknown>[]>(client, 'eth_getBlockReceipts', [blockTag(n)]).then(
2620
- (list) => {
2621
- const map = new Map<string, Record<string, unknown>>()
2622
- for (const receipt of list ?? []) {
2623
- const hash = receipt['transactionHash']
2624
- if (typeof hash === 'string') map.set(hash.toLowerCase(), receipt)
2625
- }
2626
- return map
2627
- },
2628
- )
2629
- receipts.set(n, p)
2630
- }
2631
- return p
2879
+ return singleFlight(headers, n, () =>
2880
+ client.getBlock({ blockNumber: BigInt(n), includeTransactions: false }),
2881
+ )
2632
2882
  },
2633
2883
  }
2634
2884
  }
@@ -2788,7 +3038,7 @@ function pushTopics(f: z.output<typeof schema.LogFilters>, event: AbiEvent | und
2788
3038
  pushHex(f.topic2),
2789
3039
  pushHex(f.topic3),
2790
3040
  ]
2791
- while (topics.at(-1) === undefined) topics.pop()
3041
+ while (topics.length > 0 && topics.at(-1) === undefined) topics.pop()
2792
3042
  return topics.map((topic) => topic ?? null)
2793
3043
  }
2794
3044
 
@@ -2824,17 +3074,7 @@ function decodeScanLog(log: ScanLog, event: AbiEvent): ScanLog {
2824
3074
  }
2825
3075
  }
2826
3076
 
2827
- /**
2828
- * Scans contract event logs over the confirmed window via `eth_getLogs`,
2829
- * then re-checks **every** filter in JS. Operators the RPC cannot express
2830
- * (`not`, ranges, non-indexed `args`) still apply. Two modes:
2831
- * - **raw** (no `signature`): `topic0..3` are matched directly.
2832
- * - **decoded** (`signature` present): the selector, raw topics, and supported
2833
- * indexed `args` are pushed down before the JS pass matches decoded values.
2834
- *
2835
- * Exact and membership address/topic operators are pushed down to the node;
2836
- * all operators are re-checked in JS over the narrow window.
2837
- */
3077
+ /** Scans contract logs for one subscription, then re-checks every filter in memory. */
2838
3078
  async function scanLogs(options: {
2839
3079
  batchSize: number
2840
3080
  cache: ChainCache
@@ -2856,8 +3096,6 @@ async function scanLogs(options: {
2856
3096
  ])
2857
3097
 
2858
3098
  const address = pushAddress(f.address)
2859
- const fromBlock = BigInt(window.from)
2860
- const toBlock = BigInt(window.to)
2861
3099
  const topics = pushTopics(f, event)
2862
3100
  const hasRawTopics = topics.slice(1).some((topic) => topic !== null)
2863
3101
  const args = event ? pushEventArgs(f.args, event) : {}
@@ -2878,16 +3116,13 @@ async function scanLogs(options: {
2878
3116
  ...(address ? { address } : {}),
2879
3117
  ...(Object.keys(args).length > 0 ? { args: args as never } : {}),
2880
3118
  event,
2881
- fromBlock,
3119
+ fromBlock: BigInt(window.from),
2882
3120
  strict: false,
2883
- toBlock,
3121
+ toBlock: BigInt(window.to),
2884
3122
  })) as unknown as ScanLog[]
2885
-
2886
3123
  logs.sort((a, b) => {
2887
- const ab = Number(a.blockNumber ?? 0n)
2888
- const bb = Number(b.blockNumber ?? 0n)
2889
- if (ab !== bb) return ab - bb
2890
- return (a.logIndex ?? 0) - (b.logIndex ?? 0)
3124
+ const block = Number(a.blockNumber ?? 0n) - Number(b.blockNumber ?? 0n)
3125
+ return block || (a.logIndex ?? 0) - (b.logIndex ?? 0)
2891
3126
  })
2892
3127
 
2893
3128
  const items: ScanItem[] = []
@@ -2897,9 +3132,12 @@ async function scanLogs(options: {
2897
3132
  if (blockNumber === undefined || logIndex === undefined) continue
2898
3133
  if (atOrBefore(blockNumber, logIndex, window.skip)) continue
2899
3134
  if (!matchesLog(log, f, event)) continue
2900
- const header = await cache.header(blockNumber)
2901
- const timestamp = blockIso(header)
2902
- const data = toLogEvent(log, timestamp, f.signature, event)
3135
+ const data = toLogEvent(
3136
+ log,
3137
+ Value.blockToIso(await cache.header(blockNumber)),
3138
+ f.signature,
3139
+ event,
3140
+ )
2903
3141
  if (data) {
2904
3142
  items.push({ cursor: [data.blockNumber, data.logIndex], data })
2905
3143
  if (items.length >= batchSize) return { hasMore: true, items }
@@ -2915,6 +3153,10 @@ function matchesLog(
2915
3153
  event: AbiEvent | undefined,
2916
3154
  ): boolean {
2917
3155
  const topics = log.topics
3156
+ if (event) {
3157
+ const selector = encodeEventTopics({ abi: [event] })[0]
3158
+ if (topics[0]?.toLowerCase() !== selector.toLowerCase()) return false
3159
+ }
2918
3160
  if (f.address !== undefined && !matchesOperator(log.address, f.address)) return false
2919
3161
  if (
2920
3162
  f.blockNumber !== undefined &&
@@ -2949,12 +3191,6 @@ function matchesLog(
2949
3191
 
2950
3192
  // block:created (getBlock)
2951
3193
 
2952
- /** Converts a viem block's `timestamp` (seconds) to an ISO 8601 string. */
2953
- function blockIso(block: RpcBlock | undefined): string | undefined {
2954
- if (!block || typeof block.timestamp !== 'bigint') return undefined
2955
- return new Date(Number(block.timestamp) * 1000).toISOString()
2956
- }
2957
-
2958
3194
  type BlockEventData = {
2959
3195
  number: number
2960
3196
  hash: string
@@ -2974,7 +3210,7 @@ function toBlockEvent(block: RpcBlock): BlockEventData | undefined {
2974
3210
  const miner = Schema.Address.safeParse(block.miner)
2975
3211
  const gasUsed = typeof block.gasUsed === 'bigint' ? Number(block.gasUsed) : undefined
2976
3212
  const gasLimit = typeof block.gasLimit === 'bigint' ? Number(block.gasLimit) : undefined
2977
- const timestamp = blockIso(block)
3213
+ const timestamp = Value.blockToIso(block)
2978
3214
  const transactionCount = Array.isArray(block.transactions) ? block.transactions.length : 0
2979
3215
  if (
2980
3216
  number === undefined ||
@@ -2998,11 +3234,7 @@ function toBlockEvent(block: RpcBlock): BlockEventData | undefined {
2998
3234
  }
2999
3235
  }
3000
3236
 
3001
- /**
3002
- * Scans new blocks over the confirmed window via viem's `getBlock`. One row per
3003
- * block (the keyset index is always 0); filters match block-level metadata. An
3004
- * empty filter is a per-block heartbeat.
3005
- */
3237
+ /** Scans new blocks over one subscription's confirmed window. */
3006
3238
  async function scanBlocks(options: {
3007
3239
  batchSize: number
3008
3240
  cache: ChainCache
@@ -3017,9 +3249,9 @@ async function scanBlocks(options: {
3017
3249
  if (!window) return { items: [], scannedThrough: undefined }
3018
3250
 
3019
3251
  const items: ScanItem[] = []
3020
- for (let n = window.from; n <= window.to; n++) {
3021
- if (atOrBefore(n, 0, window.skip)) continue
3022
- const block = await cache.header(n)
3252
+ for (let blockNumber = window.from; blockNumber <= window.to; blockNumber++) {
3253
+ if (atOrBefore(blockNumber, 0, window.skip)) continue
3254
+ const block = await cache.header(blockNumber)
3023
3255
  if (!block) continue
3024
3256
  if (!matchesBlock(block, f)) continue
3025
3257
  const data = toBlockEvent(block)
@@ -3053,7 +3285,7 @@ function matchesBlock(block: RpcBlock, f: z.output<typeof schema.BlockFilters>):
3053
3285
  return true
3054
3286
  }
3055
3287
 
3056
- // transaction:included (eth_getBlockByNumber + eth_getBlockReceipts)
3288
+ // transaction:included (eth_getBlockByNumber)
3057
3289
 
3058
3290
  /** Maps a `transaction:included` scalar filter field to its raw-tx field + kind. */
3059
3291
  const txFields = {
@@ -3071,13 +3303,7 @@ const txFields = {
3071
3303
  value: { field: 'value', kind: 'number' },
3072
3304
  } as const satisfies Record<string, { field: string; kind: 'number' | 'string' }>
3073
3305
 
3074
- /**
3075
- * Scans included transactions over the confirmed window. Each block is fetched
3076
- * with full transactions via `eth_getBlockByNumber(n, true)`; receipts are
3077
- * fetched lazily (only when a `status`/`gasUsed`/`feePayer` filter needs them).
3078
- * The delivered payload starts from `Transactions.humanizeTransaction`; the
3079
- * poller then resolves its fee-token references.
3080
- */
3306
+ /** Scans included transactions over one subscription's confirmed window. */
3081
3307
  async function scanTxs(options: {
3082
3308
  batchSize: number
3083
3309
  cache: ChainCache
@@ -3092,55 +3318,37 @@ async function scanTxs(options: {
3092
3318
  if (!window) return { items: [], scannedThrough: undefined }
3093
3319
 
3094
3320
  const includeCalls = f.includeCalls ?? true
3095
- const includeReceipt = f.includeReceipt ?? false
3096
- // Filter-driven receipt need gates matching; `includeReceipt` only enriches the
3097
- // payload, so it never drops a row when the receipt is missing.
3098
- const needsReceipt = f.status !== undefined || f.gasUsed !== undefined || f.feePayer !== undefined
3099
-
3100
3321
  const items: ScanItem[] = []
3101
- for (let n = window.from; n <= window.to; n++) {
3102
- const block = await cache.block(n)
3103
- const txs = Array.isArray(block['transactions']) ? (block['transactions'] as unknown[]) : []
3104
- if (txs.length === 0) continue
3105
- const receipts = needsReceipt || includeReceipt ? await cache.receipts(n) : undefined
3322
+ for (let blockNumber = window.from; blockNumber <= window.to; blockNumber++) {
3323
+ const block = await cache.block(blockNumber)
3324
+ const transactions = Array.isArray(block['transactions'])
3325
+ ? (block['transactions'] as unknown[])
3326
+ : []
3106
3327
  const timestamp = asString(block['timestamp'])
3107
-
3108
- for (let i = 0; i < txs.length; i++) {
3109
- const tx = txs[i] as Record<string, unknown>
3110
- const index = Value.hexToNumber(tx['transactionIndex']) ?? i
3111
- if (atOrBefore(n, index, window.skip)) continue
3112
- const receipt = receipts?.get(String(tx['hash']).toLowerCase())
3113
- if (needsReceipt && !receipt) continue
3114
- if (!matchesTx(tx, f, includeCalls, receipt, timestamp, n)) continue
3115
- const parsed = Transactions.schema.Rpc.Transaction.safeParse(tx)
3328
+ // A transaction's own `blockTimestamp` is seconds-only, so the delivered
3329
+ // timestamp comes from the enclosing block, which carries milliseconds.
3330
+ const blockTime = Value.blockToIso(block)
3331
+ for (let offset = 0; offset < transactions.length; offset++) {
3332
+ const transaction = transactions[offset] as Record<string, unknown>
3333
+ const transactionIndex = Value.hexToNumber(transaction['transactionIndex']) ?? offset
3334
+ if (atOrBefore(blockNumber, transactionIndex, window.skip)) continue
3335
+ if (!matchesTx(transaction, f, includeCalls, timestamp, blockNumber)) continue
3336
+ const parsed = Transactions.schema.Rpc.Transaction.safeParse(transaction)
3116
3337
  if (!parsed.success) continue
3117
- let data: Record<string, unknown> = Transactions.humanizeTransaction(parsed.data)
3118
- // Embed the humanized receipt under `meta.receipt`, matching the read
3119
- // endpoint's `include=receipt` shape so `data` stays a valid envelope.
3120
- if (includeReceipt && receipt) {
3121
- const r = Receipts.schema.Rpc.Receipt.safeParse(receipt)
3122
- if (r.success)
3123
- data = {
3124
- ...data,
3125
- meta: {
3126
- ...(data['meta'] as Record<string, unknown>),
3127
- receipt: Receipts.humanizeReceipt(r.data),
3128
- },
3129
- }
3130
- }
3131
- items.push({ cursor: [n, index], data })
3338
+ const humanized = Transactions.humanizeTransaction(parsed.data)
3339
+ const data = blockTime === undefined ? humanized : { ...humanized, timestamp: blockTime }
3340
+ items.push({ cursor: [blockNumber, transactionIndex], data })
3132
3341
  if (items.length >= batchSize) return { hasMore: true, items }
3133
3342
  }
3134
3343
  }
3135
3344
  return { items, scannedThrough: window.to }
3136
3345
  }
3137
3346
 
3138
- /** Re-checks all transaction filters in JS against the raw RPC tx (+ receipt). */
3347
+ /** Re-checks transaction filters against the raw RPC transaction. */
3139
3348
  function matchesTx(
3140
3349
  tx: Record<string, unknown>,
3141
3350
  f: z.output<typeof schema.TxFilters>,
3142
3351
  includeCalls: boolean,
3143
- receipt: Record<string, unknown> | undefined,
3144
3352
  timestamp: string | undefined,
3145
3353
  blockNumber: number,
3146
3354
  ): boolean {
@@ -3176,19 +3384,6 @@ function matchesTx(
3176
3384
 
3177
3385
  if (f.timestamp !== undefined && !matchesOperator(timestamp, f.timestamp, 'number')) return false
3178
3386
 
3179
- if (receipt) {
3180
- if (f.feePayer !== undefined && !matchesOperator(asString(receipt['feePayer']), f.feePayer))
3181
- return false
3182
- if (
3183
- f.gasUsed !== undefined &&
3184
- !matchesOperator(asString(receipt['gasUsed']), f.gasUsed, 'number')
3185
- )
3186
- return false
3187
- if (f.status !== undefined) {
3188
- const ok = Value.hexToNumber(receipt['status']) === 1
3189
- if ((f.status === 'success') !== ok) return false
3190
- }
3191
- }
3192
3387
  return true
3193
3388
  }
3194
3389
 
@@ -3298,6 +3493,202 @@ function unauthorized(c: Context<App.Environment>) {
3298
3493
  })
3299
3494
  }
3300
3495
 
3496
+ // Pure per-block scanning (no RPC, no cursors, no pagination).
3497
+
3498
+ // Pure over its inputs: the caller fetches the raw block (with transactions)
3499
+ // and its logs; enrichment happens downstream. Rows require a parsable block number.
3500
+ /**
3501
+ * Matches one fetched block against a subscription set, producing the rows
3502
+ * each event type delivers. Invalid stored filters fail closed into `invalid`.
3503
+ */
3504
+ export function scanBlock<const subscription extends scanBlock.Subscription>(
3505
+ options: scanBlock.Options<subscription>,
3506
+ ): scanBlock.Result<subscription> {
3507
+ const { block, logs, subscriptions } = options
3508
+ const blockNumber = Value.hexToNumber(block['number'])
3509
+ const timestamp = Value.blockToIso(block)
3510
+ const header = toScanHeader(block)
3511
+ const viemLogs = logs.map(fromRpcLog)
3512
+ const matches: scanBlock.Match<subscription>[] = []
3513
+ const invalid: scanBlock.Invalid<subscription>[] = []
3514
+
3515
+ for (const subscription of subscriptions) {
3516
+ const filters = (() => {
3517
+ try {
3518
+ return parseFilters(subscription.eventType, subscription.filters)
3519
+ } catch (error) {
3520
+ invalid.push({ error, subscription })
3521
+ return undefined
3522
+ }
3523
+ })()
3524
+ if (filters === undefined) continue
3525
+ const matched = matches.length
3526
+ const push = (data: unknown, logIndex: number) => {
3527
+ if (blockNumber !== undefined) matches.push({ blockNumber, data, logIndex, subscription })
3528
+ }
3529
+
3530
+ // A throwing matcher invalidates only this subscription, rolling back any
3531
+ // rows it matched before the failure.
3532
+ try {
3533
+ switch (subscription.eventType) {
3534
+ case 'block:created': {
3535
+ const f = filters as z.output<typeof schema.BlockFilters>
3536
+ if (!matchesBlock(header, f)) break
3537
+ const data = toBlockEvent(header)
3538
+ if (data) push(data, 0)
3539
+ break
3540
+ }
3541
+ case 'log:emitted': {
3542
+ const f = filters as z.output<typeof schema.LogFilters>
3543
+ const event = parseEvent(f.signature)
3544
+ if (f.signature && (!event || !/^[A-Za-z_]\w*$/.test(event.name))) {
3545
+ invalid.push({
3546
+ error: new Webhooks.InvalidFilterError('log:emitted', [
3547
+ { message: 'Unsupported event signature.', path: ['signature'] },
3548
+ ]),
3549
+ subscription,
3550
+ })
3551
+ break
3552
+ }
3553
+ // Validate arg names up front so a bad filter fails closed even
3554
+ // when the block has no decodable logs.
3555
+ const unknown =
3556
+ event && f.args
3557
+ ? Object.keys(f.args).find(
3558
+ (name) => !event.inputs.some((input) => input.name === name),
3559
+ )
3560
+ : undefined
3561
+ if (unknown !== undefined) {
3562
+ invalid.push({
3563
+ error: new Webhooks.InvalidFilterError('log:emitted', [
3564
+ { message: `Unknown event argument: ${unknown}.`, path: ['args', unknown] },
3565
+ ]),
3566
+ subscription,
3567
+ })
3568
+ break
3569
+ }
3570
+ for (const log of viemLogs) {
3571
+ const decoded = event ? decodeScanLog(log, event) : log
3572
+ const logIndex = decoded.logIndex ?? undefined
3573
+ if (logIndex === undefined) continue
3574
+ if (!matchesLog(decoded, f, event)) continue
3575
+ const data = toLogEvent(decoded, timestamp, f.signature, event)
3576
+ if (data) push(data, logIndex)
3577
+ }
3578
+ break
3579
+ }
3580
+ case 'token:transfer': {
3581
+ if (timestamp === undefined) break
3582
+ const f = filters as WebhookTransfer.Filters
3583
+ for (const log of logs) {
3584
+ const data = WebhookTransfer.decode(log, timestamp)
3585
+ if (!data || !WebhookTransfer.matches(data, f)) continue
3586
+ push(data, data.logIndex)
3587
+ }
3588
+ break
3589
+ }
3590
+ case 'transaction:included': {
3591
+ const f = filters as z.output<typeof schema.TxFilters>
3592
+ const includeCalls = f.includeCalls ?? true
3593
+ const transactions = Array.isArray(block['transactions'])
3594
+ ? (block['transactions'] as unknown[])
3595
+ : []
3596
+ const rawTimestamp = asString(block['timestamp'])
3597
+ for (let offset = 0; offset < transactions.length; offset++) {
3598
+ const transaction = transactions[offset]
3599
+ if (!transaction || typeof transaction !== 'object') continue
3600
+ const tx = transaction as Record<string, unknown>
3601
+ const transactionIndex = Value.hexToNumber(tx['transactionIndex']) ?? offset
3602
+ if (!matchesTx(tx, f, includeCalls, rawTimestamp, blockNumber ?? 0)) continue
3603
+ const parsed = Transactions.schema.Rpc.Transaction.safeParse(tx)
3604
+ if (!parsed.success) continue
3605
+ const humanized = Transactions.humanizeTransaction(parsed.data)
3606
+ // A transaction's own timestamp is seconds-only; the enclosing
3607
+ // block carries milliseconds.
3608
+ push(
3609
+ timestamp === undefined ? humanized : { ...humanized, timestamp },
3610
+ transactionIndex,
3611
+ )
3612
+ }
3613
+ break
3614
+ }
3615
+ }
3616
+ } catch (error) {
3617
+ matches.length = matched
3618
+ invalid.push({ error, subscription })
3619
+ }
3620
+ }
3621
+ return { invalid, matches }
3622
+ }
3623
+
3624
+ /** Normalizes a raw RPC header to the viem-shaped fields the block matchers read. */
3625
+ function toScanHeader(block: Record<string, unknown>): RpcBlock {
3626
+ const quantity = (value: unknown) => {
3627
+ try {
3628
+ return typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint'
3629
+ ? BigInt(value)
3630
+ : undefined
3631
+ } catch {
3632
+ return undefined
3633
+ }
3634
+ }
3635
+ return {
3636
+ ...block,
3637
+ gasLimit: quantity(block['gasLimit']),
3638
+ gasUsed: quantity(block['gasUsed']),
3639
+ number: quantity(block['number']),
3640
+ timestamp: quantity(block['timestamp']),
3641
+ } as RpcBlock
3642
+ }
3643
+
3644
+ export declare namespace scanBlock {
3645
+ /** The subscription fields matching consumes; extra fields flow through. */
3646
+ type Subscription = {
3647
+ /** Event type selecting the matcher and payload shape. */
3648
+ eventType: EventType
3649
+ /** Stored filter predicates, validated fail-closed per event type. */
3650
+ filters: Record<string, unknown>
3651
+ }
3652
+
3653
+ /** One fetched block plus the subscriptions to match it against. */
3654
+ type Options<subscription extends Subscription> = {
3655
+ /** Raw `eth_getBlockByNumber(_, true)` result: header plus raw transactions. */
3656
+ block: Record<string, unknown>
3657
+ /** Raw `eth_getLogs` entries for exactly this block, in log order. */
3658
+ logs: readonly RpcLog[]
3659
+ /** Active subscriptions for the chain; any mix of event types. */
3660
+ subscriptions: readonly subscription[]
3661
+ }
3662
+
3663
+ /** One deliverable row for one subscription. */
3664
+ type Match<subscription extends Subscription> = {
3665
+ /** Block the row was produced from. */
3666
+ blockNumber: number
3667
+ /** Payload the matching read endpoint returns (pre-enrichment). */
3668
+ data: unknown
3669
+ /** Row position within the block (log or transaction index; 0 for blocks). */
3670
+ logIndex: number
3671
+ /** The matched subscription, passed through verbatim. */
3672
+ subscription: subscription
3673
+ }
3674
+
3675
+ /** A subscription whose stored filters failed validation. */
3676
+ type Invalid<subscription extends Subscription> = {
3677
+ /** The validation failure. */
3678
+ error: unknown
3679
+ /** The subscription with invalid filters. */
3680
+ subscription: subscription
3681
+ }
3682
+
3683
+ /** Matches plus fail-closed filter rejections. */
3684
+ type Result<subscription extends Subscription> = {
3685
+ /** Subscriptions skipped because their stored filters failed validation. */
3686
+ invalid: readonly Invalid<subscription>[]
3687
+ /** Deliverable rows in subscription order, block order within each type. */
3688
+ matches: readonly Match<subscription>[]
3689
+ }
3690
+ }
3691
+
3301
3692
  class BlockUnavailableError extends Error {
3302
3693
  override name = 'Webhooks.BlockUnavailableError'
3303
3694