tapimo 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/dist/App.d.ts +4887 -5074
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/Webhooks.d.ts +21 -20
  4. package/dist/Webhooks.d.ts.map +1 -1
  5. package/dist/Webhooks.js +66 -23
  6. package/dist/Webhooks.js.map +1 -1
  7. package/dist/admin/apps/verified-tokens.d.ts +168 -168
  8. package/dist/apps/Zones.d.ts +34 -22
  9. package/dist/apps/Zones.d.ts.map +1 -1
  10. package/dist/apps/Zones.js +9 -8
  11. package/dist/apps/Zones.js.map +1 -1
  12. package/dist/apps/data/App.d.ts +5198 -5099
  13. package/dist/apps/data/App.d.ts.map +1 -1
  14. package/dist/apps/data/FxOracle.d.ts +4 -5
  15. package/dist/apps/data/FxOracle.d.ts.map +1 -1
  16. package/dist/apps/data/FxOracle.js +19 -4
  17. package/dist/apps/data/FxOracle.js.map +1 -1
  18. package/dist/apps/data/routes/activities.d.ts +270 -270
  19. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  20. package/dist/apps/data/routes/activities.js +32 -27
  21. package/dist/apps/data/routes/activities.js.map +1 -1
  22. package/dist/apps/data/routes/addresses.d.ts +52 -52
  23. package/dist/apps/data/routes/balances.d.ts +36 -36
  24. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  25. package/dist/apps/data/routes/balances.js +135 -81
  26. package/dist/apps/data/routes/balances.js.map +1 -1
  27. package/dist/apps/data/routes/blocks.d.ts +125 -125
  28. package/dist/apps/data/routes/coingecko.d.ts +54 -54
  29. package/dist/apps/data/routes/earn.d.ts +512 -58
  30. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  31. package/dist/apps/data/routes/earn.js +368 -44
  32. package/dist/apps/data/routes/earn.js.map +1 -1
  33. package/dist/apps/data/routes/exchanges.d.ts +120 -120
  34. package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
  35. package/dist/apps/data/routes/exchanges.js +114 -118
  36. package/dist/apps/data/routes/exchanges.js.map +1 -1
  37. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  38. package/dist/apps/data/routes/receipts.js +68 -47
  39. package/dist/apps/data/routes/receipts.js.map +1 -1
  40. package/dist/apps/data/routes/rpc.d.ts +31 -31
  41. package/dist/apps/data/routes/tokens.d.ts +461 -459
  42. package/dist/apps/data/routes/tokens.d.ts.map +1 -1
  43. package/dist/apps/data/routes/transactions.d.ts +59 -51
  44. package/dist/apps/data/routes/transactions.d.ts.map +1 -1
  45. package/dist/apps/data/routes/transactions.js +25 -23
  46. package/dist/apps/data/routes/transactions.js.map +1 -1
  47. package/dist/apps/data/routes/transfers.d.ts +108 -86
  48. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  49. package/dist/apps/data/routes/transfers.js +618 -105
  50. package/dist/apps/data/routes/transfers.js.map +1 -1
  51. package/dist/apps/data/routes/valuation.d.ts +21 -21
  52. package/dist/apps/data/routes/valuation.d.ts.map +1 -1
  53. package/dist/apps/data/routes/valuation.js +2 -2
  54. package/dist/apps/data/routes/valuation.js.map +1 -1
  55. package/dist/apps/data/routes/verified-tokens.d.ts +37 -37
  56. package/dist/apps/data/routes/webhooks.d.ts +102 -505
  57. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  58. package/dist/apps/data/routes/webhooks.js +484 -342
  59. package/dist/apps/data/routes/webhooks.js.map +1 -1
  60. package/dist/apps/data/routes/zones.d.ts +12 -12
  61. package/dist/apps/data/routes/zones.d.ts.map +1 -1
  62. package/dist/apps/data/routes/zones.js +3 -1
  63. package/dist/apps/data/routes/zones.js.map +1 -1
  64. package/dist/apps/funding/App.d.ts +96 -96
  65. package/dist/apps/funding/routes/deposit-addresses.d.ts +52 -52
  66. package/dist/apps/funding/routes/quotes.d.ts +89 -89
  67. package/dist/apps/funding/routes/transfers.d.ts +4 -4
  68. package/dist/apps/management/App.d.ts +429 -727
  69. package/dist/apps/management/App.d.ts.map +1 -1
  70. package/dist/apps/management/routes/api-keys.d.ts +24 -24
  71. package/dist/apps/management/routes/billing.d.ts +72 -72
  72. package/dist/apps/management/routes/faucet.d.ts +6 -6
  73. package/dist/apps/management/routes/invitations.d.ts +134 -134
  74. package/dist/apps/management/routes/invite-links.d.ts +38 -38
  75. package/dist/apps/management/routes/me.d.ts +6 -6
  76. package/dist/apps/management/routes/members.d.ts +30 -30
  77. package/dist/apps/management/routes/orgs.d.ts +98 -98
  78. package/dist/apps/management/routes/projects.d.ts +20 -20
  79. package/dist/apps/management/routes/usage.d.ts +24 -24
  80. package/dist/apps/management/routes/verified-token-requests.d.ts +83 -83
  81. package/dist/apps/management/routes/webhooks.d.ts +158 -573
  82. package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
  83. package/dist/apps/mpp/App.d.ts +56 -44
  84. package/dist/apps/mpp/App.d.ts.map +1 -1
  85. package/dist/apps/mpp/Mpp.d.ts +3 -0
  86. package/dist/apps/mpp/Mpp.d.ts.map +1 -1
  87. package/dist/apps/mpp/Mpp.js +3 -0
  88. package/dist/apps/mpp/Mpp.js.map +1 -1
  89. package/dist/apps/mpp/internal/Adapter.d.ts.map +1 -1
  90. package/dist/apps/mpp/internal/Adapter.js +9 -2
  91. package/dist/apps/mpp/internal/Adapter.js.map +1 -1
  92. package/dist/db/Db.d.ts +2 -2
  93. package/dist/db/Db.d.ts.map +1 -1
  94. package/dist/db/Db.js +17 -15
  95. package/dist/db/Db.js.map +1 -1
  96. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  97. package/dist/db/tables/sponsoredTransactions.js +4 -1
  98. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  99. package/dist/db/tables/webhookSubscriptions.d.ts +56 -3
  100. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  101. package/dist/db/tables/webhookSubscriptions.js +76 -3
  102. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  103. package/dist/internal/MetricSink.d.ts +112 -3
  104. package/dist/internal/MetricSink.d.ts.map +1 -1
  105. package/dist/internal/MetricSink.js +116 -3
  106. package/dist/internal/MetricSink.js.map +1 -1
  107. package/dist/internal/Schema.js +1 -1
  108. package/dist/internal/Schema.js.map +1 -1
  109. package/dist/internal/Tidx.d.ts +1 -3
  110. package/dist/internal/Tidx.d.ts.map +1 -1
  111. package/dist/internal/Tidx.js +1 -3
  112. package/dist/internal/Tidx.js.map +1 -1
  113. package/dist/internal/Viem.d.ts.map +1 -1
  114. package/dist/internal/Viem.js +2 -1
  115. package/dist/internal/Viem.js.map +1 -1
  116. package/dist/internal/WebhookDestination.d.ts +2 -0
  117. package/dist/internal/WebhookDestination.d.ts.map +1 -1
  118. package/dist/internal/WebhookDestination.js +11 -0
  119. package/dist/internal/WebhookDestination.js.map +1 -1
  120. package/dist/internal/WebhookTransfer.d.ts +55 -0
  121. package/dist/internal/WebhookTransfer.d.ts.map +1 -0
  122. package/dist/internal/WebhookTransfer.js +296 -0
  123. package/dist/internal/WebhookTransfer.js.map +1 -0
  124. package/dist/internal/Webhooks.d.ts +37 -0
  125. package/dist/internal/Webhooks.d.ts.map +1 -1
  126. package/dist/internal/Webhooks.js +87 -13
  127. package/dist/internal/Webhooks.js.map +1 -1
  128. package/dist/internal/funding/Provider.d.ts +4 -0
  129. package/dist/internal/funding/Provider.d.ts.map +1 -1
  130. package/dist/internal/funding/Provider.js +11 -7
  131. package/dist/internal/funding/Provider.js.map +1 -1
  132. package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
  133. package/dist/internal/funding/Reconciliation.js +128 -0
  134. package/dist/internal/funding/Reconciliation.js.map +1 -1
  135. package/dist/internal/funding/providers/relay.d.ts +3 -0
  136. package/dist/internal/funding/providers/relay.d.ts.map +1 -1
  137. package/dist/internal/funding/providers/relay.js +12 -1
  138. package/dist/internal/funding/providers/relay.js.map +1 -1
  139. package/package.json +3 -3
  140. package/src/Client.test-d.ts +60 -5
  141. package/src/Webhooks.bench.ts +714 -0
  142. package/src/Webhooks.memory.bench.ts +250 -0
  143. package/src/Webhooks.test.ts +77 -6
  144. package/src/Webhooks.ts +83 -29
  145. package/src/apps/Zones.test.ts +5 -4
  146. package/src/apps/Zones.ts +9 -8
  147. package/src/apps/data/FxOracle.test.ts +18 -0
  148. package/src/apps/data/FxOracle.ts +28 -5
  149. package/src/apps/data/routes/activities.test.ts +19 -0
  150. package/src/apps/data/routes/activities.ts +34 -30
  151. package/src/apps/data/routes/balances.test.ts +119 -10
  152. package/src/apps/data/routes/balances.ts +154 -95
  153. package/src/apps/data/routes/blocks.test.ts +12 -6
  154. package/src/apps/data/routes/earn.test.ts +197 -0
  155. package/src/apps/data/routes/earn.ts +486 -44
  156. package/src/apps/data/routes/exchanges.test.ts +7 -6
  157. package/src/apps/data/routes/exchanges.ts +124 -117
  158. package/src/apps/data/routes/receipts.test.ts +24 -7
  159. package/src/apps/data/routes/receipts.ts +67 -50
  160. package/src/apps/data/routes/tokens.test.ts +51 -0
  161. package/src/apps/data/routes/transactions.test.ts +42 -0
  162. package/src/apps/data/routes/transactions.ts +44 -39
  163. package/src/apps/data/routes/transfers.test.ts +402 -24
  164. package/src/apps/data/routes/transfers.ts +708 -101
  165. package/src/apps/data/routes/valuation.test.ts +26 -0
  166. package/src/apps/data/routes/valuation.ts +6 -2
  167. package/src/apps/data/routes/webhooks.test.ts +414 -130
  168. package/src/apps/data/routes/webhooks.ts +603 -419
  169. package/src/apps/data/routes/zones.ts +6 -1
  170. package/src/apps/funding/routes/quotes.test.ts +1 -1
  171. package/src/apps/mpp/Mpp.test.ts +13 -1
  172. package/src/apps/mpp/Mpp.ts +8 -0
  173. package/src/apps/mpp/internal/Adapter.ts +10 -2
  174. package/src/db/Db.test.ts +40 -6
  175. package/src/db/Db.ts +33 -19
  176. package/src/db/tables/sponsoredTransactions.ts +9 -2
  177. package/src/db/tables/webhookSubscriptions.ts +116 -4
  178. package/src/internal/EarnVaults.test.ts +2 -2
  179. package/src/internal/MetricSink.test.ts +518 -0
  180. package/src/internal/MetricSink.ts +266 -9
  181. package/src/internal/Schema.ts +1 -1
  182. package/src/internal/Tidx.test.ts +4 -4
  183. package/src/internal/Tidx.ts +1 -3
  184. package/src/internal/Viem.test.ts +8 -0
  185. package/src/internal/Viem.ts +2 -1
  186. package/src/internal/WebhookDestination.test.ts +20 -0
  187. package/src/internal/WebhookDestination.ts +10 -0
  188. package/src/internal/WebhookTransfer.ts +426 -0
  189. package/src/internal/Webhooks.test.ts +137 -0
  190. package/src/internal/Webhooks.ts +131 -17
  191. package/src/internal/funding/Provider.test.ts +47 -7
  192. package/src/internal/funding/Provider.ts +12 -7
  193. package/src/internal/funding/Reconciliation.local.test.ts +3 -1
  194. package/src/internal/funding/Reconciliation.test.ts +1240 -0
  195. package/src/internal/funding/Reconciliation.ts +136 -0
  196. package/src/internal/funding/SourceObservation.test.ts +1 -0
  197. package/src/internal/funding/providers/relay.test.ts +1 -4
  198. package/src/internal/funding/providers/relay.ts +16 -1
@@ -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.'),
@@ -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
@@ -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
@@ -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)
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,26 @@ 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?: ((input: readonly Dispatchable[]) => Promise<Webhooks.Result>) | undefined
2159
2410
  /** Event types to poll (default all). */
2160
2411
  eventTypes?: readonly EventType[] | undefined
2161
2412
  /** Current chain head; the poller emits up to `head - confirmations`. */
2162
2413
  headBlockNumber?: bigint | number | undefined
2163
2414
  /** Backfill cap: most blocks an RPC scanner inspects per tick (default 1000). */
2164
2415
  maxBlocks?: number | undefined
2416
+ /** Records the worst confirmed-head distance without changing the poll result. */
2417
+ onCursorLag?: ((blocks: number) => void) | undefined
2418
+ /** Records bounded scan inputs before allocation and outcomes after completion. */
2419
+ onScanCheckpoint?: ((checkpoint: ScanCheckpoint) => void) | undefined
2420
+ /** Records bounded, per-tick poll-stage timings without subscription identities. */
2421
+ onStage?: ((stage: Stage) => void) | undefined
2422
+ /** Records subscriptions eligible for this poll before any scan runs. */
2423
+ onSubscriptions?: ((count: number) => void) | undefined
2165
2424
  /** Clock for envelope timestamps; injectable for tests. */
2166
2425
  now?: (() => Date) | undefined
2167
- /** TIDX query client for the legacy `token:transfer` type. */
2168
- tidx?: Tidx.Client | undefined
2426
+ /** Stops scanning and dispatch when the caller loses its execution lease. */
2427
+ signal?: AbortSignal | undefined
2169
2428
  }
2170
2429
  /** Summary counters for one poll tick. */
2171
2430
  type Result = {
@@ -2175,6 +2434,8 @@ export declare namespace poll {
2175
2434
  dispatched: number
2176
2435
  /** Rows whose dispatch failed (pausing that subscription). */
2177
2436
  failed: number
2437
+ /** True when a bounded scan left committed work behind the same head. */
2438
+ hasMore?: boolean | undefined
2178
2439
  /** Highest confirmed block emitted this tick (`head - confirmations`). */
2179
2440
  safeBlock: number
2180
2441
  /** Total rows scanned across all subscriptions. */
@@ -2182,6 +2443,44 @@ export declare namespace poll {
2182
2443
  /** Active subscriptions visited. */
2183
2444
  subscriptions: number
2184
2445
  }
2446
+ /** One scan boundary used to correlate production memory failures with workload shape. */
2447
+ type ScanCheckpoint = {
2448
+ /** Shared upper bound for concurrent upstream scans across event types. */
2449
+ concurrency: number
2450
+ /** Scan duration, present only after completion. */
2451
+ durationMs?: number | undefined
2452
+ /** Subscription event type scanned by this batch. */
2453
+ eventType: EventType
2454
+ /** Subscriptions whose scan rejected, present only after completion. */
2455
+ failedSubscriptions?: number | undefined
2456
+ /** Matching rows retained by completed pages, present only after completion. */
2457
+ matchedItems?: number | undefined
2458
+ /** Largest block window requested by one subscription. */
2459
+ maxWindowBlocks: number
2460
+ /** Whether the checkpoint precedes allocation or follows scan completion. */
2461
+ phase: 'complete' | 'start'
2462
+ /** Outcome of the completed RPC-backed scan. */
2463
+ outcome?: 'empty' | 'error' | 'missing' | 'ok' | undefined
2464
+ /** JSON-RPC method used by this event type's scanner. */
2465
+ rpcMethod: 'eth_getBlockByNumber' | 'eth_getLogs'
2466
+ /** Subscriptions retained in this scan batch. */
2467
+ subscriptionCount: number
2468
+ /** Maximum subscriptions retained by this event-type batch. */
2469
+ subscriptionLimit: number
2470
+ /** Sum of subscription block windows in this scan batch. */
2471
+ totalWindowBlocks: number
2472
+ }
2473
+ /** One bounded poll-stage timing observation. */
2474
+ type Stage = {
2475
+ /** Stage duration in milliseconds. */
2476
+ durationMs: number
2477
+ /** Event type being processed, omitted for the shared RPC-head stage. */
2478
+ eventType?: EventType | undefined
2479
+ /** Whether the stage completed. */
2480
+ outcome: 'error' | 'ok'
2481
+ /** Bounded stage name. */
2482
+ stage: MetricSink.PollStage
2483
+ }
2185
2484
  }
2186
2485
 
2187
2486
  /**
@@ -2270,7 +2569,7 @@ function withResolvedFeeToken(data: Record<string, unknown>, metadata: Map<strin
2270
2569
  return result
2271
2570
  }
2272
2571
 
2273
- /** Adds best-effort fee-token references to a transaction and its embedded receipt. */
2572
+ /** Adds a best-effort fee-token reference to a transaction. */
2274
2573
  async function withFeeTokenMetadata(
2275
2574
  data: unknown,
2276
2575
  deps: {
@@ -2281,37 +2580,12 @@ async function withFeeTokenMetadata(
2281
2580
  ): Promise<unknown> {
2282
2581
  if (typeof data !== 'object' || data === null) return data
2283
2582
  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
- }
2583
+ const address = feeTokenAddress(transaction['feeToken'])
2584
+ if (!address) return data
2585
+ const token = await resolveTokenMetadata(address, deps)
2586
+ const metadata = new Map<string, TokenMetadata>()
2587
+ if (token) metadata.set(address.toLowerCase(), token)
2588
+ return withResolvedFeeToken(transaction, metadata)
2315
2589
  }
2316
2590
 
2317
2591
  /** Returns the live RPC head block number for the chain. */
@@ -2319,81 +2593,74 @@ export async function getRpcHead(client: Viem.getClient.ReturnType): Promise<num
2319
2593
  return Number(await client.getBlockNumber())
2320
2594
  }
2321
2595
 
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
2596
+ /** Groups aligned cursors only when their bounded upstream scans are equivalent. */
2597
+ async function scanSubscriptions(options: {
2344
2598
  client: Viem.getClient.ReturnType
2345
- cursor: string | undefined
2599
+ eventType: EventType
2346
2600
  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
- })
2601
+ schedule: Schedule
2602
+ states: readonly PollState[]
2603
+ }): Promise<PromiseSettledResult<ScanPage>[]> {
2604
+ const { client, eventType, maxBlocks, schedule, states } = options
2605
+ if (eventType !== 'token:transfer')
2606
+ return scanIndependent({ client, eventType, maxBlocks, schedule, states })
2607
+ return WebhookTransfer.scan({
2608
+ client,
2609
+ concurrency: pollConcurrency,
2610
+ maxBlocks,
2611
+ schedule,
2612
+ states: states.map((state) => ({
2613
+ filters: state.filters,
2614
+ limit: Math.min(scanPageSize, state.remaining),
2615
+ window: scanWindow(state.cursor, state.safeBlock, maxBlocks),
2616
+ })),
2617
+ })
2618
+ }
2365
2619
 
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
- })
2620
+ /** Returns the bounded JSON-RPC method used by an event scanner. */
2621
+ function scanRpcMethod(eventType: EventType): poll.ScanCheckpoint['rpcMethod'] {
2622
+ return eventType === 'block:created' || eventType === 'transaction:included'
2623
+ ? 'eth_getBlockByNumber'
2624
+ : 'eth_getLogs'
2625
+ }
2375
2626
 
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
- })
2627
+ /** Classifies a completed batch without exposing an unbounded upstream error. */
2628
+ function scanResultOutcome(
2629
+ pages: readonly PromiseSettledResult<ScanPage>[],
2630
+ ): NonNullable<poll.ScanCheckpoint['outcome']> {
2631
+ const rejected = pages.filter((page) => page.status === 'rejected')
2632
+ if (rejected.some((page) => page.reason instanceof BlockUnavailableError)) return 'missing'
2633
+ if (rejected.length > 0) return 'error'
2634
+ return pages.every((page) => page.status === 'fulfilled' && page.value.items.length === 0)
2635
+ ? 'empty'
2636
+ : 'ok'
2637
+ }
2385
2638
 
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 },
2639
+ /** Scans CPU-matched streams per subscription while sharing concurrent block reads. */
2640
+ function scanIndependent(options: {
2641
+ client: Viem.getClient.ReturnType
2642
+ eventType: Exclude<EventType, 'token:transfer'>
2643
+ maxBlocks: number
2644
+ schedule: Schedule
2645
+ states: readonly PollState[]
2646
+ }): Promise<PromiseSettledResult<ScanPage>[]> {
2647
+ const { client, eventType, maxBlocks, schedule, states } = options
2648
+ const cache = createChainCache(client)
2649
+ return mapWithLimit(states, pollConcurrency, (state) =>
2650
+ schedule(() => {
2651
+ const shared = {
2652
+ batchSize: Math.min(scanPageSize, state.remaining),
2653
+ cache,
2654
+ cursor: state.cursor,
2655
+ filters: state.filters,
2656
+ maxBlocks,
2657
+ safeBlock: state.safeBlock,
2658
+ }
2659
+ if (eventType === 'block:created') return scanBlocks(shared)
2660
+ if (eventType === 'log:emitted') return scanLogs({ ...shared, client })
2661
+ return scanTxs(shared)
2662
+ }),
2392
2663
  )
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
2664
  }
2398
2665
 
2399
2666
  const filterSchemas = {
@@ -2422,18 +2689,6 @@ export function parseFilters(
2422
2689
  return result.data as Record<string, unknown>
2423
2690
  }
2424
2691
 
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
2692
  /** Recursively lowercases string leaves of a filter operator (for address args). */
2438
2693
  function lowercaseLeaves(op: unknown): unknown {
2439
2694
  if (typeof op === 'string') return op.toLowerCase()
@@ -2573,62 +2828,49 @@ function atOrBefore(block: number, index: number, skip: readonly [number, number
2573
2828
  /** A viem-formatted block header (no transactions), as returned by `getBlock`. */
2574
2829
  type RpcBlock = Awaited<ReturnType<Viem.getClient.ReturnType['getBlock']>>
2575
2830
 
2576
- /** Per-tick lazy cache of fetched block headers, full blocks, and receipts. */
2831
+ /** Per-batch single-flight cache for chain RPC reads. */
2577
2832
  type ChainCache = {
2578
2833
  /** Full raw block (with transactions) via `eth_getBlockByNumber(n, true)`. */
2579
2834
  block(n: number): Promise<Record<string, unknown>>
2580
2835
  /** Viem-formatted block header (transaction hashes only) via `getBlock`. */
2581
2836
  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
2837
  }
2585
2838
 
2586
- /** Builds a per-tick {@link ChainCache} bound to a viem client. */
2839
+ /** Builds a per-batch {@link ChainCache} bound to a viem client. */
2587
2840
  function createChainCache(client: Viem.getClient.ReturnType): ChainCache {
2588
2841
  const headers = new Map<number, Promise<RpcBlock | undefined>>()
2589
2842
  const blocks = new Map<number, Promise<Record<string, unknown>>>()
2590
- const receipts = new Map<number, Promise<Map<string, Record<string, unknown>>>>()
2843
+
2844
+ const singleFlight = <key, value>(
2845
+ pending: Map<key, Promise<value>>,
2846
+ key: key,
2847
+ load: () => Promise<value>,
2848
+ ) => {
2849
+ const existing = pending.get(key)
2850
+ if (existing) return existing
2851
+ const promise = load().finally(() => pending.delete(key))
2852
+ pending.set(key, promise)
2853
+ return promise
2854
+ }
2855
+
2591
2856
  return {
2592
2857
  block(n) {
2593
- let p = blocks.get(n)
2594
- if (!p) {
2858
+ return singleFlight(blocks, n, () =>
2595
2859
  // The transaction payload reuses `humanizeTransaction`, which consumes
2596
2860
  // the raw RPC tx shape, so the full block stays on the untyped path.
2597
- p = rpc<Record<string, unknown> | null>(client, 'eth_getBlockByNumber', [
2861
+ rpc<Record<string, unknown> | null>(client, 'eth_getBlockByNumber', [
2598
2862
  blockTag(n),
2599
2863
  true,
2600
2864
  ]).then((block) => {
2601
2865
  if (!block) throw new BlockUnavailableError(n)
2602
2866
  return block
2603
- })
2604
- blocks.set(n, p)
2605
- }
2606
- return p
2867
+ }),
2868
+ )
2607
2869
  },
2608
2870
  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
2871
+ return singleFlight(headers, n, () =>
2872
+ client.getBlock({ blockNumber: BigInt(n), includeTransactions: false }),
2873
+ )
2632
2874
  },
2633
2875
  }
2634
2876
  }
@@ -2788,7 +3030,7 @@ function pushTopics(f: z.output<typeof schema.LogFilters>, event: AbiEvent | und
2788
3030
  pushHex(f.topic2),
2789
3031
  pushHex(f.topic3),
2790
3032
  ]
2791
- while (topics.at(-1) === undefined) topics.pop()
3033
+ while (topics.length > 0 && topics.at(-1) === undefined) topics.pop()
2792
3034
  return topics.map((topic) => topic ?? null)
2793
3035
  }
2794
3036
 
@@ -2824,17 +3066,7 @@ function decodeScanLog(log: ScanLog, event: AbiEvent): ScanLog {
2824
3066
  }
2825
3067
  }
2826
3068
 
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
- */
3069
+ /** Scans contract logs for one subscription, then re-checks every filter in memory. */
2838
3070
  async function scanLogs(options: {
2839
3071
  batchSize: number
2840
3072
  cache: ChainCache
@@ -2856,8 +3088,6 @@ async function scanLogs(options: {
2856
3088
  ])
2857
3089
 
2858
3090
  const address = pushAddress(f.address)
2859
- const fromBlock = BigInt(window.from)
2860
- const toBlock = BigInt(window.to)
2861
3091
  const topics = pushTopics(f, event)
2862
3092
  const hasRawTopics = topics.slice(1).some((topic) => topic !== null)
2863
3093
  const args = event ? pushEventArgs(f.args, event) : {}
@@ -2878,16 +3108,13 @@ async function scanLogs(options: {
2878
3108
  ...(address ? { address } : {}),
2879
3109
  ...(Object.keys(args).length > 0 ? { args: args as never } : {}),
2880
3110
  event,
2881
- fromBlock,
3111
+ fromBlock: BigInt(window.from),
2882
3112
  strict: false,
2883
- toBlock,
3113
+ toBlock: BigInt(window.to),
2884
3114
  })) as unknown as ScanLog[]
2885
-
2886
3115
  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)
3116
+ const block = Number(a.blockNumber ?? 0n) - Number(b.blockNumber ?? 0n)
3117
+ return block || (a.logIndex ?? 0) - (b.logIndex ?? 0)
2891
3118
  })
2892
3119
 
2893
3120
  const items: ScanItem[] = []
@@ -2897,9 +3124,7 @@ async function scanLogs(options: {
2897
3124
  if (blockNumber === undefined || logIndex === undefined) continue
2898
3125
  if (atOrBefore(blockNumber, logIndex, window.skip)) continue
2899
3126
  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)
3127
+ const data = toLogEvent(log, blockIso(await cache.header(blockNumber)), f.signature, event)
2903
3128
  if (data) {
2904
3129
  items.push({ cursor: [data.blockNumber, data.logIndex], data })
2905
3130
  if (items.length >= batchSize) return { hasMore: true, items }
@@ -2915,6 +3140,10 @@ function matchesLog(
2915
3140
  event: AbiEvent | undefined,
2916
3141
  ): boolean {
2917
3142
  const topics = log.topics
3143
+ if (event) {
3144
+ const selector = encodeEventTopics({ abi: [event] })[0]
3145
+ if (topics[0]?.toLowerCase() !== selector.toLowerCase()) return false
3146
+ }
2918
3147
  if (f.address !== undefined && !matchesOperator(log.address, f.address)) return false
2919
3148
  if (
2920
3149
  f.blockNumber !== undefined &&
@@ -2998,11 +3227,7 @@ function toBlockEvent(block: RpcBlock): BlockEventData | undefined {
2998
3227
  }
2999
3228
  }
3000
3229
 
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
- */
3230
+ /** Scans new blocks over one subscription's confirmed window. */
3006
3231
  async function scanBlocks(options: {
3007
3232
  batchSize: number
3008
3233
  cache: ChainCache
@@ -3017,9 +3242,9 @@ async function scanBlocks(options: {
3017
3242
  if (!window) return { items: [], scannedThrough: undefined }
3018
3243
 
3019
3244
  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)
3245
+ for (let blockNumber = window.from; blockNumber <= window.to; blockNumber++) {
3246
+ if (atOrBefore(blockNumber, 0, window.skip)) continue
3247
+ const block = await cache.header(blockNumber)
3023
3248
  if (!block) continue
3024
3249
  if (!matchesBlock(block, f)) continue
3025
3250
  const data = toBlockEvent(block)
@@ -3053,7 +3278,7 @@ function matchesBlock(block: RpcBlock, f: z.output<typeof schema.BlockFilters>):
3053
3278
  return true
3054
3279
  }
3055
3280
 
3056
- // transaction:included (eth_getBlockByNumber + eth_getBlockReceipts)
3281
+ // transaction:included (eth_getBlockByNumber)
3057
3282
 
3058
3283
  /** Maps a `transaction:included` scalar filter field to its raw-tx field + kind. */
3059
3284
  const txFields = {
@@ -3071,13 +3296,7 @@ const txFields = {
3071
3296
  value: { field: 'value', kind: 'number' },
3072
3297
  } as const satisfies Record<string, { field: string; kind: 'number' | 'string' }>
3073
3298
 
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
- */
3299
+ /** Scans included transactions over one subscription's confirmed window. */
3081
3300
  async function scanTxs(options: {
3082
3301
  batchSize: number
3083
3302
  cache: ChainCache
@@ -3092,55 +3311,33 @@ async function scanTxs(options: {
3092
3311
  if (!window) return { items: [], scannedThrough: undefined }
3093
3312
 
3094
3313
  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
3314
  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
3315
+ for (let blockNumber = window.from; blockNumber <= window.to; blockNumber++) {
3316
+ const block = await cache.block(blockNumber)
3317
+ const transactions = Array.isArray(block['transactions'])
3318
+ ? (block['transactions'] as unknown[])
3319
+ : []
3106
3320
  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)
3321
+ for (let offset = 0; offset < transactions.length; offset++) {
3322
+ const transaction = transactions[offset] as Record<string, unknown>
3323
+ const transactionIndex = Value.hexToNumber(transaction['transactionIndex']) ?? offset
3324
+ if (atOrBefore(blockNumber, transactionIndex, window.skip)) continue
3325
+ if (!matchesTx(transaction, f, includeCalls, timestamp, blockNumber)) continue
3326
+ const parsed = Transactions.schema.Rpc.Transaction.safeParse(transaction)
3116
3327
  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 })
3328
+ const data = Transactions.humanizeTransaction(parsed.data)
3329
+ items.push({ cursor: [blockNumber, transactionIndex], data })
3132
3330
  if (items.length >= batchSize) return { hasMore: true, items }
3133
3331
  }
3134
3332
  }
3135
3333
  return { items, scannedThrough: window.to }
3136
3334
  }
3137
3335
 
3138
- /** Re-checks all transaction filters in JS against the raw RPC tx (+ receipt). */
3336
+ /** Re-checks transaction filters against the raw RPC transaction. */
3139
3337
  function matchesTx(
3140
3338
  tx: Record<string, unknown>,
3141
3339
  f: z.output<typeof schema.TxFilters>,
3142
3340
  includeCalls: boolean,
3143
- receipt: Record<string, unknown> | undefined,
3144
3341
  timestamp: string | undefined,
3145
3342
  blockNumber: number,
3146
3343
  ): boolean {
@@ -3176,19 +3373,6 @@ function matchesTx(
3176
3373
 
3177
3374
  if (f.timestamp !== undefined && !matchesOperator(timestamp, f.timestamp, 'number')) return false
3178
3375
 
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
3376
  return true
3193
3377
  }
3194
3378