envio 3.3.0-alpha.1 → 3.3.0-alpha.11

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 (143) hide show
  1. package/evm.schema.json +10 -0
  2. package/index.d.ts +45 -25
  3. package/licenses/CLA.md +35 -0
  4. package/licenses/EULA.md +67 -0
  5. package/licenses/LICENSE.md +45 -0
  6. package/licenses/README.md +35 -0
  7. package/package.json +10 -9
  8. package/src/Address.res +5 -2
  9. package/src/Address.res.mjs +3 -1
  10. package/src/Api.res +1 -1
  11. package/src/Api.res.mjs +1 -1
  12. package/src/Batch.res +0 -6
  13. package/src/Batch.res.mjs +1 -12
  14. package/src/BatchProcessing.res +7 -12
  15. package/src/BatchProcessing.res.mjs +8 -7
  16. package/src/Bin.res +3 -0
  17. package/src/Bin.res.mjs +4 -0
  18. package/src/ChainFetching.res +53 -16
  19. package/src/ChainFetching.res.mjs +37 -19
  20. package/src/ChainMetadata.res +1 -11
  21. package/src/ChainMetadata.res.mjs +1 -10
  22. package/src/ChainState.res +630 -161
  23. package/src/ChainState.res.mjs +438 -101
  24. package/src/ChainState.resi +77 -4
  25. package/src/Config.res +70 -38
  26. package/src/Config.res.mjs +52 -12
  27. package/src/ContractRegisterContext.res +2 -12
  28. package/src/ContractRegisterContext.res.mjs +3 -5
  29. package/src/Core.res +34 -3
  30. package/src/Core.res.mjs +12 -0
  31. package/src/CrossChainState.res +130 -89
  32. package/src/CrossChainState.res.mjs +66 -61
  33. package/src/CrossChainState.resi +1 -1
  34. package/src/Ecosystem.res +5 -6
  35. package/src/Ecosystem.res.mjs +3 -3
  36. package/src/Envio.res +17 -11
  37. package/src/EnvioGlobal.res +53 -0
  38. package/src/EnvioGlobal.res.mjs +31 -0
  39. package/src/EventConfigBuilder.res +191 -127
  40. package/src/EventConfigBuilder.res.mjs +116 -66
  41. package/src/EventProcessing.res +77 -28
  42. package/src/EventProcessing.res.mjs +56 -29
  43. package/src/ExitOnCaughtUp.res +10 -2
  44. package/src/ExitOnCaughtUp.res.mjs +10 -1
  45. package/src/FetchState.res +684 -415
  46. package/src/FetchState.res.mjs +505 -486
  47. package/src/HandlerLoader.res +8 -104
  48. package/src/HandlerLoader.res.mjs +2 -81
  49. package/src/HandlerRegister.res +520 -144
  50. package/src/HandlerRegister.res.mjs +286 -133
  51. package/src/HandlerRegister.resi +24 -8
  52. package/src/IndexerState.res +10 -3
  53. package/src/IndexerState.res.mjs +11 -4
  54. package/src/IndexerState.resi +2 -1
  55. package/src/IndexingAddresses.res +108 -0
  56. package/src/IndexingAddresses.res.mjs +101 -0
  57. package/src/IndexingAddresses.resi +34 -0
  58. package/src/Internal.res +166 -48
  59. package/src/Internal.res.mjs +30 -2
  60. package/src/LoadLayer.res +5 -5
  61. package/src/LoadLayer.res.mjs +6 -6
  62. package/src/LogSelection.res +92 -217
  63. package/src/LogSelection.res.mjs +95 -184
  64. package/src/Main.res +71 -236
  65. package/src/Main.res.mjs +60 -145
  66. package/src/Metrics.res +74 -0
  67. package/src/Metrics.res.mjs +75 -0
  68. package/src/PgStorage.res +4 -4
  69. package/src/PgStorage.res.mjs +5 -5
  70. package/src/Prometheus.res +10 -63
  71. package/src/Prometheus.res.mjs +124 -171
  72. package/src/PruneStaleHistory.res +2 -2
  73. package/src/PruneStaleHistory.res.mjs +3 -3
  74. package/src/RawEvent.res +7 -2
  75. package/src/RawEvent.res.mjs +4 -4
  76. package/src/Rollback.res +3 -3
  77. package/src/Rollback.res.mjs +4 -4
  78. package/src/RollbackCommit.res +4 -1
  79. package/src/RollbackCommit.res.mjs +3 -2
  80. package/src/SimulateDeadInputTracker.res +85 -0
  81. package/src/SimulateDeadInputTracker.res.mjs +73 -0
  82. package/src/SimulateDeadInputTracker.resi +12 -0
  83. package/src/SimulateItems.res +128 -28
  84. package/src/SimulateItems.res.mjs +82 -40
  85. package/src/TestIndexer.res +55 -41
  86. package/src/TestIndexer.res.mjs +39 -32
  87. package/src/TopicFilter.res +1 -25
  88. package/src/TopicFilter.res.mjs +0 -74
  89. package/src/bindings/Performance.res +7 -0
  90. package/src/bindings/Performance.res.mjs +21 -0
  91. package/src/bindings/Performance.resi +7 -0
  92. package/src/bindings/Viem.res +0 -46
  93. package/src/bindings/Viem.res.mjs +1 -43
  94. package/src/sources/BlockStore.res +46 -0
  95. package/src/sources/BlockStore.res.mjs +24 -0
  96. package/src/sources/EventRouter.res +31 -49
  97. package/src/sources/EventRouter.res.mjs +12 -25
  98. package/src/sources/Evm.res +83 -72
  99. package/src/sources/Evm.res.mjs +53 -69
  100. package/src/sources/EvmChain.res +7 -34
  101. package/src/sources/EvmChain.res.mjs +6 -27
  102. package/src/sources/EvmRpcClient.res +71 -5
  103. package/src/sources/EvmRpcClient.res.mjs +13 -4
  104. package/src/sources/FieldMask.res +39 -0
  105. package/src/sources/FieldMask.res.mjs +42 -0
  106. package/src/sources/Fuel.res +9 -6
  107. package/src/sources/Fuel.res.mjs +5 -10
  108. package/src/sources/HyperFuelSource.res +46 -50
  109. package/src/sources/HyperFuelSource.res.mjs +64 -54
  110. package/src/sources/HyperSync.res +45 -42
  111. package/src/sources/HyperSync.res.mjs +68 -60
  112. package/src/sources/HyperSync.resi +14 -4
  113. package/src/sources/HyperSyncClient.res +107 -119
  114. package/src/sources/HyperSyncClient.res.mjs +36 -31
  115. package/src/sources/HyperSyncHeightStream.res +1 -8
  116. package/src/sources/HyperSyncHeightStream.res.mjs +0 -2
  117. package/src/sources/HyperSyncSource.res +60 -254
  118. package/src/sources/HyperSyncSource.res.mjs +57 -180
  119. package/src/sources/Rpc.res +15 -79
  120. package/src/sources/Rpc.res.mjs +25 -101
  121. package/src/sources/RpcSource.res +221 -514
  122. package/src/sources/RpcSource.res.mjs +216 -411
  123. package/src/sources/RpcWebSocketHeightStream.res +0 -5
  124. package/src/sources/RpcWebSocketHeightStream.res.mjs +0 -2
  125. package/src/sources/SimulateSource.res +44 -20
  126. package/src/sources/SimulateSource.res.mjs +41 -14
  127. package/src/sources/Source.res +34 -6
  128. package/src/sources/SourceManager.res +77 -19
  129. package/src/sources/SourceManager.res.mjs +63 -21
  130. package/src/sources/SourceManager.resi +10 -0
  131. package/src/sources/Svm.res +31 -16
  132. package/src/sources/Svm.res.mjs +36 -13
  133. package/src/sources/SvmHyperSyncClient.res +5 -32
  134. package/src/sources/SvmHyperSyncSource.res +159 -134
  135. package/src/sources/SvmHyperSyncSource.res.mjs +164 -129
  136. package/src/sources/TransactionStore.res +49 -0
  137. package/src/sources/TransactionStore.res.mjs +30 -0
  138. package/src/tui/Tui.res +13 -16
  139. package/src/tui/Tui.res.mjs +12 -14
  140. package/svm.schema.json +49 -37
  141. package/src/bindings/Hrtime.res +0 -58
  142. package/src/bindings/Hrtime.res.mjs +0 -90
  143. package/src/bindings/Hrtime.resi +0 -30
@@ -4,19 +4,59 @@
4
4
 
5
5
  type t = {
6
6
  logger: Pino.t,
7
+ // The registrations used to build this chain's sources and route native items.
8
+ onEventRegistrations: array<Internal.onEventRegistration>,
7
9
  mutable fetchState: FetchState.t,
10
+ // The chain-wide address index. Not `mutable`: the dict is mutated in place by
11
+ // register/rollback, so the reference is stable across fetchState versions.
12
+ indexingAddresses: IndexingAddresses.t,
8
13
  sourceManager: SourceManager.t,
9
14
  chainConfig: Config.chain,
10
15
  mutable isProgressAtHead: bool,
11
16
  mutable timestampCaughtUpToHeadOrEndblock: option<Date.t>,
12
17
  mutable committedProgressBlockNumber: int,
18
+ // Progress of the batch currently being processed. The buffer is consumed at
19
+ // batch creation (see advanceAfterBatch), so this runs ahead of
20
+ // committedProgressBlockNumber until the batch commits — it's the true lower
21
+ // boundary of the remaining buffer's block span.
22
+ mutable processingBlockNumber: int,
13
23
  mutable numEventsProcessed: float,
14
- // Running sum of in-flight queries' estResponseSize, kept here so the
24
+ // Running sum of in-flight queries' itemsEst, kept here so the
15
25
  // scheduler doesn't re-sum pending queries on every tick. Incremented when
16
26
  // queries are dispatched, decremented as their responses land.
17
27
  mutable pendingBudget: float,
28
+ // Chain-wide events/block, used to turn a chain's item budget into a target
29
+ // block for query sizing. Seeded from cumulative progress on construction,
30
+ // then smoothed with an EMA on every batch (see applyBatchProgress). None
31
+ // until the chain has processed at least one event.
32
+ mutable chainDensity: option<float>,
18
33
  mutable reorgDetection: ReorgDetection.t,
19
34
  mutable safeCheckpointTracking: option<SafeCheckpointTracking.t>,
35
+ // Holds this chain's transactions (kept in Rust) keyed by (blockNumber,
36
+ // transactionIndex). Fetch responses merge their page in; entries are pruned
37
+ // as the chain progresses and dropped above the target on rollback.
38
+ transactionStore: TransactionStore.t,
39
+ // Holds this chain's blocks (kept in Rust) keyed by block number. Same merge /
40
+ // prune / rollback lifecycle as the transaction store.
41
+ blockStore: BlockStore.t,
42
+ }
43
+
44
+ // Per-chain shape returned by the status API.
45
+ type chainData = {
46
+ chainId: float,
47
+ poweredByHyperSync: bool,
48
+ firstEventBlockNumber: option<int>,
49
+ latestProcessedBlock: option<int>,
50
+ timestampCaughtUpToHeadOrEndblock: option<Date.t>,
51
+ numEventsProcessed: float,
52
+ latestFetchedBlockNumber: int,
53
+ // Need this for API backwards compatibility
54
+ @as("currentBlockHeight")
55
+ knownHeight: int,
56
+ numBatchesFetched: int,
57
+ startBlock: int,
58
+ endBlock: option<int>,
59
+ numAddresses: int,
20
60
  }
21
61
 
22
62
  let configAddresses = (chainConfig: Config.chain): array<Internal.indexingAddress> => {
@@ -33,9 +73,23 @@ let configAddresses = (chainConfig: Config.chain): array<Internal.indexingAddres
33
73
  addresses
34
74
  }
35
75
 
76
+ let validateOnEventRegistrations = (
77
+ ~chainId: int,
78
+ registrations: array<Internal.onEventRegistration>,
79
+ ) =>
80
+ registrations->Array.forEachWithIndex((registration, expectedIndex) => {
81
+ if registration.index !== expectedIndex {
82
+ JsError.throwWithMessage(
83
+ `Invalid onEvent registration index for chain ${chainId->Int.toString}: ${registration.eventConfig.contractName}.${registration.eventConfig.name} has index ${registration.index->Int.toString}, but its ChainState position is ${expectedIndex->Int.toString}.`,
84
+ )
85
+ }
86
+ })
87
+
36
88
  let make = (
37
89
  ~chainConfig: Config.chain,
38
90
  ~fetchState: FetchState.t,
91
+ ~onEventRegistrations=[],
92
+ ~indexingAddresses: IndexingAddresses.t,
39
93
  ~sourceManager: SourceManager.t,
40
94
  ~reorgDetection: ReorgDetection.t,
41
95
  ~committedProgressBlockNumber: int,
@@ -43,19 +97,31 @@ let make = (
43
97
  ~numEventsProcessed=0.,
44
98
  ~timestampCaughtUpToHeadOrEndblock=None,
45
99
  ~isProgressAtHead=false,
100
+ ~transactionStore=TransactionStore.make(~ecosystem=Ecosystem.Evm, ~shouldChecksum=false),
101
+ ~chainDensity=None,
102
+ ~blockStore=BlockStore.make(~ecosystem=Ecosystem.Evm, ~shouldChecksum=false),
46
103
  ~logger: Pino.t,
47
104
  ): t => {
48
- logger,
49
- fetchState,
50
- sourceManager,
51
- chainConfig,
52
- isProgressAtHead,
53
- timestampCaughtUpToHeadOrEndblock,
54
- committedProgressBlockNumber,
55
- numEventsProcessed,
56
- pendingBudget: 0.,
57
- reorgDetection,
58
- safeCheckpointTracking,
105
+ validateOnEventRegistrations(~chainId=chainConfig.id, onEventRegistrations)
106
+ {
107
+ logger,
108
+ onEventRegistrations,
109
+ fetchState,
110
+ indexingAddresses,
111
+ sourceManager,
112
+ chainConfig,
113
+ isProgressAtHead,
114
+ timestampCaughtUpToHeadOrEndblock,
115
+ committedProgressBlockNumber,
116
+ processingBlockNumber: committedProgressBlockNumber,
117
+ numEventsProcessed,
118
+ pendingBudget: 0.,
119
+ chainDensity,
120
+ reorgDetection,
121
+ safeCheckpointTracking,
122
+ transactionStore,
123
+ blockStore,
124
+ }
59
125
  }
60
126
 
61
127
  let makeInternal = (
@@ -66,7 +132,7 @@ let makeInternal = (
66
132
  ~firstEventBlock=None,
67
133
  ~progressBlockNumber,
68
134
  ~config: Config.t,
69
- ~registrations: HandlerRegister.registrations,
135
+ ~registrationsByChainId: HandlerRegister.registrationsByChainId,
70
136
  ~logger,
71
137
  ~timestampCaughtUpToHeadOrEndblock,
72
138
  ~numEventsProcessed,
@@ -77,129 +143,35 @@ let makeInternal = (
77
143
  ~knownHeight=0,
78
144
  ~reducedPollingInterval=?,
79
145
  ): t => {
80
- // We don't need the router itself, but only validation logic,
81
- // since now event router is created for selection of events
82
- // and validation doesn't work correctly in routers.
83
- // Ideally to split it into two different parts.
84
- let eventRouter = EventRouter.empty()
85
-
86
- // Aggregate events we want to fetch
87
- let eventConfigs: array<Internal.eventConfig> = []
88
-
89
- let notRegisteredEvents = []
146
+ // Handler binding + `where`-derived fetch state, and onBlock registrations,
147
+ // are already collected by `HandlerRegister.finishRegistration`, keyed by
148
+ // chain - this just looks up this chain's slice.
149
+ let {onEventRegistrations, onBlockRegistrations} =
150
+ registrationsByChainId
151
+ ->Utils.Dict.dangerouslyGetNonOption(chainConfig.id->Int.toString)
152
+ ->Option.getOr({onEventRegistrations: [], onBlockRegistrations: []})
90
153
 
91
154
  chainConfig.contracts->Array.forEach(contract => {
92
- let contractName = contract.name
93
-
94
- contract.events->Array.forEach(eventConfig => {
95
- let {isWildcard} = eventConfig
96
- let hasContractRegister = eventConfig.contractRegister->Option.isSome
97
-
98
- // Should validate the events
99
- eventRouter->EventRouter.addOrThrow(
100
- eventConfig.id,
101
- (),
102
- ~contractName,
103
- ~chain=ChainMap.Chain.makeUnsafe(~chainId=chainConfig.id),
104
- ~eventName=eventConfig.name,
105
- ~isWildcard,
106
- )
107
-
108
- // Filter out non-preRegistration events on preRegistration phase
109
- // so we don't care about it in fetch state and workers anymore
110
- let shouldBeIncluded = if config.enableRawEvents {
111
- true
112
- } else {
113
- let isRegistered = hasContractRegister || eventConfig.handler->Option.isSome
114
- if !isRegistered {
115
- notRegisteredEvents->Array.push(eventConfig)
116
- }
117
- isRegistered
118
- }
119
-
120
- // Check if event has Static([]) filters (from a dynamic where
121
- // callback returning `false` / SkipAll for this chain).
122
- // If so, skip it entirely - it should never be fetched
123
- let shouldSkip = try {
124
- let getEventFiltersOrThrow = (
125
- eventConfig->(Utils.magic: Internal.eventConfig => Internal.evmEventConfig)
126
- ).getEventFiltersOrThrow
127
-
128
- // Check for non-evm chains
129
- if (
130
- getEventFiltersOrThrow->(Utils.magic: (ChainMap.Chain.t => Internal.eventFilters) => bool)
131
- ) {
132
- switch getEventFiltersOrThrow(ChainMap.Chain.makeUnsafe(~chainId=chainConfig.id)) {
133
- | Static([]) => true
134
- | _ => false
135
- }
136
- } else {
137
- false
138
- }
139
- } catch {
140
- // Can throw when filter is invalid
141
- // Don't skip an event in this case. Let it throw in a better place - source code
142
- | _ => false
143
- }
144
-
145
- if shouldBeIncluded && !shouldSkip {
146
- eventConfigs->Array.push(eventConfig)
147
- }
148
- })
149
-
150
155
  switch contract.startBlock {
151
156
  | Some(startBlock) if startBlock < chainConfig.startBlock =>
152
157
  JsError.throwWithMessage(
153
- `The start block for contract "${contractName}" is less than the chain start block. This is not supported yet.`,
158
+ `The start block for contract "${contract.name}" is less than the chain start block. This is not supported yet.`,
154
159
  )
155
160
  | _ => ()
156
161
  }
157
162
  })
158
163
 
159
- if notRegisteredEvents->Utils.Array.notEmpty {
160
- logger->Logging.childInfo(
161
- `The event${if notRegisteredEvents->Array.length > 1 {
162
- "s"
163
- } else {
164
- ""
165
- }} ${notRegisteredEvents
166
- ->Array.map(eventConfig => `${eventConfig.contractName}.${eventConfig.name}`)
167
- ->Array.joinUnsafe(", ")} don't have an event handler and skipped for indexing.`,
168
- )
169
- }
170
-
171
- let onBlockConfigs =
172
- registrations.onBlockByChainId->Utils.Dict.dangerouslyGetNonOption(chainConfig.id->Int.toString)
173
- switch onBlockConfigs {
174
- | Some(onBlockConfigs) =>
175
- // TODO: Move it to the HandlerRegister module
176
- // so the error is thrown with better stack trace
177
- onBlockConfigs->Array.forEach(onBlockConfig => {
178
- if onBlockConfig.startBlock->Option.getOr(startBlock) < startBlock {
179
- JsError.throwWithMessage(
180
- `The start block for onBlock handler "${onBlockConfig.name}" is less than the chain start block (${startBlock->Int.toString}). This is not supported yet.`,
181
- )
182
- }
183
- switch endBlock {
184
- | Some(chainEndBlock) =>
185
- if onBlockConfig.endBlock->Option.getOr(chainEndBlock) > chainEndBlock {
186
- JsError.throwWithMessage(
187
- `The end block for onBlock handler "${onBlockConfig.name}" is greater than the chain end block (${chainEndBlock->Int.toString}). This is not supported yet.`,
188
- )
189
- }
190
- | None => ()
191
- }
192
- })
193
- | None => ()
194
- }
164
+ let contractConfigs = IndexingAddresses.makeContractConfigs(~onEventRegistrations)
165
+ let indexingAddressIndex = IndexingAddresses.make(~contractConfigs, ~addresses=indexingAddresses)
195
166
 
196
167
  let fetchState = FetchState.make(
197
168
  ~maxAddrInPartition=config.maxAddrInPartition,
169
+ ~contractConfigs,
198
170
  ~addresses=indexingAddresses,
199
171
  ~progressBlockNumber,
200
172
  ~startBlock,
201
173
  ~endBlock,
202
- ~eventConfigs,
174
+ ~onEventRegistrations,
203
175
  ~maxOnBlockBufferSize=2 * config.batchSize,
204
176
  ~knownHeight,
205
177
  ~chainId=chainConfig.id,
@@ -208,7 +180,7 @@ let makeInternal = (
208
180
  !config.shouldRollbackOnReorg || isInReorgThreshold ? 0 : chainConfig.maxReorgDepth,
209
181
  chainConfig.blockLag,
210
182
  ),
211
- ~onBlockConfigs?,
183
+ ~onBlockRegistrations,
212
184
  ~firstEventBlock,
213
185
  )
214
186
 
@@ -225,29 +197,23 @@ let makeInternal = (
225
197
  let lowercaseAddresses = config.lowercaseAddresses
226
198
  let sources = switch chainConfig.sourceConfig {
227
199
  | Config.EvmSourceConfig({hypersync, rpcs}) =>
228
- // Build Internal.evmContractConfig from contracts for EvmChain.makeSources
229
- let evmContracts: array<Internal.evmContractConfig> = chainConfig.contracts->Array.map((
230
- contract
231
- ): Internal.evmContractConfig => {
232
- name: contract.name,
233
- abi: contract.abi,
234
- events: contract.events->(
235
- Utils.magic: array<Internal.eventConfig> => array<Internal.evmEventConfig>
236
- ),
237
- })
238
200
  let evmRpcs: array<EvmChain.rpc> = rpcs->Array.map((rpc): EvmChain.rpc => {
239
201
  let syncConfig = rpc.syncConfig
240
202
  let ws = rpc.ws
203
+ let headers = rpc.headers
241
204
  {
242
205
  url: rpc.url,
243
206
  sourceFor: rpc.sourceFor,
244
207
  ?syncConfig,
245
208
  ?ws,
209
+ ?headers,
246
210
  }
247
211
  })
248
212
  EvmChain.makeSources(
249
213
  ~chain,
250
- ~contracts=evmContracts,
214
+ ~onEventRegistrations=onEventRegistrations->(
215
+ Utils.magic: array<Internal.onEventRegistration> => array<Internal.evmOnEventRegistration>
216
+ ),
251
217
  ~hyperSync=hypersync,
252
218
  ~rpcs=evmRpcs,
253
219
  ~lowercaseAddresses,
@@ -265,17 +231,13 @@ let makeInternal = (
265
231
  | (Some(hypersyncUrl), _) =>
266
232
  // HyperSync drives instruction sync. A configured RPC is ignored for now
267
233
  // (RPC fallback isn't wired up yet).
268
- let svmEventConfigs =
269
- chainConfig.contracts
270
- ->Array.flatMap(contract => contract.events)
271
- ->(Utils.magic: array<Internal.eventConfig> => array<Internal.svmInstructionEventConfig>)
272
234
  let apiToken = Env.envioApiToken
273
235
  [
274
236
  SvmHyperSyncSource.make({
275
237
  chain,
276
238
  endpointUrl: hypersyncUrl,
277
239
  apiToken,
278
- eventConfigs: svmEventConfigs,
240
+ onEventRegistrations,
279
241
  clientTimeoutMillis: Env.hyperSyncClientTimeoutMillis,
280
242
  }),
281
243
  ]
@@ -284,9 +246,19 @@ let makeInternal = (
284
246
  | Config.CustomSources(sources) => sources
285
247
  }
286
248
 
249
+ // Seed chain density from whatever progress this chain already has (from a
250
+ // resumed DB state, or 0 on a fresh chain) — refined per-batch afterwards.
251
+ let chainDensity = switch fetchState.firstEventBlock {
252
+ | Some(firstEventBlock) if progressBlockNumber > firstEventBlock && numEventsProcessed > 0. =>
253
+ Some(numEventsProcessed /. (progressBlockNumber - firstEventBlock)->Int.toFloat)
254
+ | _ => None
255
+ }
256
+
287
257
  make(
288
258
  ~chainConfig,
289
259
  ~fetchState,
260
+ ~onEventRegistrations,
261
+ ~indexingAddresses=indexingAddressIndex,
290
262
  ~sourceManager=SourceManager.make(~sources, ~isRealtime, ~reducedPollingInterval?),
291
263
  ~reorgDetection=ReorgDetection.make(
292
264
  ~chainReorgCheckpoints,
@@ -301,17 +273,31 @@ let makeInternal = (
301
273
  ~committedProgressBlockNumber=progressBlockNumber,
302
274
  ~timestampCaughtUpToHeadOrEndblock,
303
275
  ~numEventsProcessed,
276
+ ~transactionStore=TransactionStore.make(
277
+ ~ecosystem=config.ecosystem.name,
278
+ ~shouldChecksum=!lowercaseAddresses,
279
+ ),
280
+ ~chainDensity,
281
+ ~blockStore=BlockStore.make(
282
+ ~ecosystem=config.ecosystem.name,
283
+ ~shouldChecksum=!lowercaseAddresses,
284
+ ),
304
285
  ~logger,
305
286
  )
306
287
  }
307
288
 
308
- let makeFromConfig = (chainConfig: Config.chain, ~config, ~registrations, ~knownHeight) => {
289
+ let makeFromConfig = (
290
+ chainConfig: Config.chain,
291
+ ~config,
292
+ ~registrationsByChainId,
293
+ ~knownHeight,
294
+ ) => {
309
295
  let logger = Logging.createChild(~params={"chainId": chainConfig.id})
310
296
 
311
297
  makeInternal(
312
298
  ~chainConfig,
313
299
  ~config,
314
- ~registrations,
300
+ ~registrationsByChainId,
315
301
  ~startBlock=chainConfig.startBlock,
316
302
  ~endBlock=chainConfig.endBlock,
317
303
  ~reorgCheckpoints=[],
@@ -337,7 +323,7 @@ let makeFromDbState = (
337
323
  ~isInReorgThreshold,
338
324
  ~isRealtime,
339
325
  ~config,
340
- ~registrations,
326
+ ~registrationsByChainId,
341
327
  ~reducedPollingInterval=?,
342
328
  ) => {
343
329
  let chainId = chainConfig.id
@@ -357,7 +343,7 @@ let makeFromDbState = (
357
343
  ~startBlock=resumedChainState.startBlock,
358
344
  ~endBlock=resumedChainState.endBlock,
359
345
  ~config,
360
- ~registrations,
346
+ ~registrationsByChainId,
361
347
  ~reorgCheckpoints,
362
348
  ~maxReorgDepth=resumedChainState.maxReorgDepth,
363
349
  ~firstEventBlock=resumedChainState.firstEventBlockNumber,
@@ -377,7 +363,6 @@ let makeFromDbState = (
377
363
  // --- Read accessors. ---
378
364
 
379
365
  let logger = (cs: t) => cs.logger
380
- let fetchState = (cs: t) => cs.fetchState
381
366
  let sourceManager = (cs: t) => cs.sourceManager
382
367
  let chainConfig = (cs: t) => cs.chainConfig
383
368
  let reorgDetection = (cs: t) => cs.reorgDetection
@@ -388,13 +373,27 @@ let numEventsProcessed = (cs: t) => cs.numEventsProcessed
388
373
  let pendingBudget = (cs: t) => cs.pendingBudget
389
374
  let timestampCaughtUpToHeadOrEndblock = (cs: t) => cs.timestampCaughtUpToHeadOrEndblock
390
375
 
376
+ // Fetch-frontier reads. The FetchState is owned here; callers go through these
377
+ // rather than reaching into it.
378
+ let knownHeight = (cs: t) => cs.fetchState.knownHeight
379
+ let contractAddresses = (cs: t, ~contractName) =>
380
+ cs.indexingAddresses->IndexingAddresses.getContractAddresses(~contractName)
381
+ let bufferSize = (cs: t) => cs.fetchState->FetchState.bufferSize
382
+ let bufferReadyCount = (cs: t) => cs.fetchState->FetchState.bufferReadyCount
383
+ let getProgressPercentage = (cs: t) => cs.fetchState->FetchState.getProgressPercentage
384
+ let chainDensity = (cs: t) => cs.chainDensity
385
+ let hasReadyItem = (cs: t) =>
386
+ cs.fetchState->FetchState.isActivelyIndexing && cs.fetchState->FetchState.hasReadyItem
387
+ let isReadyToEnterReorgThreshold = (cs: t) => cs.fetchState->FetchState.isReadyToEnterReorgThreshold
388
+
391
389
  // Mark queries as in flight and reserve their estimated size against the shared
392
390
  // buffer budget in one step, so the counter stays in sync with the pending
393
391
  // queries it tracks.
394
392
  let startFetchingQueries = (cs: t, ~queries: array<FetchState.query>) => {
395
393
  cs.fetchState->FetchState.startFetchingQueries(~queries)
396
394
  cs.pendingBudget =
397
- cs.pendingBudget +. queries->Array.reduce(0., (acc, query) => acc +. query.estResponseSize)
395
+ cs.pendingBudget +.
396
+ queries->Array.reduce(0., (acc, query) => acc +. query.itemsEst->Int.toFloat)
398
397
  }
399
398
 
400
399
  // Drop every in-flight query and release their reservations together, keeping
@@ -404,6 +403,157 @@ let resetPendingQueries = (cs: t) => {
404
403
  cs.pendingBudget = 0.
405
404
  }
406
405
 
406
+ let isReady = (cs: t) => cs.timestampCaughtUpToHeadOrEndblock !== None
407
+
408
+ // Block span over which a batch fully replaces the chain density estimate;
409
+ // smaller batches blend in proportionally, so a few sparse/dense blocks only
410
+ // nudge it.
411
+ let densityBlendWindow = 100.
412
+
413
+ // The last block this chain can fetch right now: the head, or endBlock when
414
+ // it's below the head.
415
+ let fetchCeiling = (cs: t) => {
416
+ let fetchState = cs.fetchState
417
+ switch fetchState.endBlock {
418
+ | Some(endBlock) => Pervasives.min(endBlock, fetchState.knownHeight)
419
+ | None => fetchState.knownHeight
420
+ }
421
+ }
422
+
423
+ // Events/block over the ready part of the buffer — a live signal that reacts
424
+ // to what fetching just found, unlike the processing EMA which only moves as
425
+ // batches commit.
426
+ let readyBufferDensity = (cs: t) => {
427
+ let readyCount = cs.fetchState->FetchState.bufferReadyCount
428
+ let span = cs.fetchState->FetchState.bufferBlockNumber - cs.processingBlockNumber
429
+ if readyCount > 0 && span > 0 {
430
+ Some(readyCount->Int.toFloat /. span->Int.toFloat)
431
+ } else {
432
+ None
433
+ }
434
+ }
435
+
436
+ // Density used for query sizing: the higher of the processing EMA and the
437
+ // ready-buffer density, so a stale-low EMA can't undersize queries while the
438
+ // buffer proves the range is dense. None means the chain is cold — no density
439
+ // signal at all.
440
+ let effectiveDensity = (cs: t) =>
441
+ switch (cs.chainDensity, cs->readyBufferDensity) {
442
+ | (Some(ema), Some(buffer)) => Some(Pervasives.max(ema, buffer))
443
+ | (Some(_) as density, None) | (None, Some(_) as density) => density
444
+ | (None, None) => None
445
+ }
446
+
447
+ // How far past the frontier a chain with no density signal targets while it
448
+ // takes its first measurements.
449
+ let coldTargetRange = 20_000
450
+
451
+ // This chain's share of the indexer-wide buffer budget turned into a soft
452
+ // target block: via density, or frontier + coldTargetRange when there's no
453
+ // density signal yet.
454
+ let targetBlock = (cs: t, ~chainTargetItems: float) => {
455
+ let fetchState = cs.fetchState
456
+ let fetchCeiling = cs->fetchCeiling
457
+ let bufferBlockNumber = fetchState->FetchState.bufferBlockNumber
458
+ switch cs->effectiveDensity {
459
+ | Some(density) if density > 0. =>
460
+ Pervasives.min(
461
+ fetchCeiling,
462
+ bufferBlockNumber + Math.ceil(chainTargetItems /. density)->Float.toInt,
463
+ )
464
+ | _ => Pervasives.min(bufferBlockNumber + coldTargetRange, fetchCeiling)
465
+ }
466
+ }
467
+
468
+ // Block range that cross-chain progress alignment maps fractions over: from
469
+ // the first block that can hold this chain's events to the last block it can
470
+ // fetch right now.
471
+ let progressRange = (cs: t) => {
472
+ let fetchState = cs.fetchState
473
+ let lower = fetchState.firstEventBlock->Option.getOr(fetchState.startBlock)
474
+ (lower, cs->fetchCeiling)
475
+ }
476
+
477
+ // A degenerate range (chain already at or past its last block) maps to 1 so it
478
+ // never constrains the other chains. Clamped at 0 for the initial -1 fetch
479
+ // frontier — the only possible blockNumber below the range's lower bound.
480
+ let progressAtBlock = (cs: t, ~blockNumber) => {
481
+ let (lower, upper) = cs->progressRange
482
+ upper <= lower
483
+ ? 1.
484
+ : Pervasives.max(
485
+ 0.,
486
+ Pervasives.min(1., (blockNumber - lower)->Int.toFloat /. (upper - lower)->Int.toFloat),
487
+ )
488
+ }
489
+
490
+ let blockAtProgress = (cs: t, ~progress) => {
491
+ let (lower, upper) = cs->progressRange
492
+ lower + Math.ceil(progress *. (upper - lower)->Int.toFloat)->Float.toInt
493
+ }
494
+
495
+ // Propose queries sized against this chain's target block. Called by
496
+ // CrossChainState's waterfall, furthest-behind chain first, with
497
+ // chainTargetItems set to whatever budget remains at that point and
498
+ // maxTargetBlock set to the most-behind chain's progress mapped onto this
499
+ // chain, so a chain with budget can't run further ahead than the chain the
500
+ // whole pool is prioritizing.
501
+ let getNextQuery = (
502
+ cs: t,
503
+ ~chainTargetItems: float,
504
+ ~chunkItemsMultiplier=1.,
505
+ ~maxTargetBlock=?,
506
+ ) => {
507
+ let chainTargetBlock = cs->targetBlock(~chainTargetItems)
508
+ let chainTargetBlock = switch maxTargetBlock {
509
+ | Some(maxTargetBlock) => Pervasives.min(chainTargetBlock, maxTargetBlock)
510
+ | None => chainTargetBlock
511
+ }
512
+ // When the target block is clamped (head/endBlock/cross-chain alignment) a
513
+ // known-density chain can't use the whole handed budget — cap the fresh part
514
+ // at what the clamped range actually costs (in-flight reservations stay on
515
+ // top: they're already accounted and shouldn't crowd out new partitions), so
516
+ // the waterfall's leftover flows to the next chain in the same tick instead
517
+ // of being held by an oversized probe.
518
+ let chainTargetItems = switch cs->effectiveDensity {
519
+ | Some(density) if density > 0. =>
520
+ // No extra headroom here: the budget is reserved in honest itemsEst units,
521
+ // and truncation safety lives in the itemsTarget server cap (sized with
522
+ // chunkItemsMultiplier at query creation) — multiplying the budget cap too
523
+ // would compound the two and hold budget away from other chains.
524
+ let rangeCost =
525
+ density *. (chainTargetBlock - cs.fetchState->FetchState.bufferBlockNumber)->Int.toFloat
526
+ Pervasives.min(chainTargetItems, Math.ceil(rangeCost) +. cs.pendingBudget)
527
+ // No density signal: the cross-chain waterfall already clamped the handed
528
+ // budget to the cold-chain cap, so it's used as-is.
529
+ | _ => chainTargetItems
530
+ }
531
+ cs.fetchState->FetchState.getNextQuery(
532
+ ~chainTargetBlock,
533
+ ~chainTargetItems,
534
+ ~chunkItemsMultiplier,
535
+ )
536
+ }
537
+
538
+ // Run a fetch tick for this chain against its sources, feeding the owned fetch
539
+ // frontier to the source manager.
540
+ let dispatch = (
541
+ cs: t,
542
+ ~executeQuery,
543
+ ~waitForNewBlock,
544
+ ~onNewBlock,
545
+ ~action: FetchState.nextQuery,
546
+ ~stateId,
547
+ ) =>
548
+ cs.sourceManager->SourceManager.dispatch(
549
+ ~fetchState=cs.fetchState,
550
+ ~executeQuery,
551
+ ~waitForNewBlock,
552
+ ~onNewBlock,
553
+ ~action,
554
+ ~stateId,
555
+ )
556
+
407
557
  // --- Derived (pure). ---
408
558
 
409
559
  let hasProcessedToEndblock = (cs: t) => {
@@ -421,16 +571,204 @@ let getHighestBlockBelowThreshold = (cs: t): int => {
421
571
 
422
572
  let isActivelyIndexing = (cs: t) => cs.fetchState->FetchState.isActivelyIndexing
423
573
 
424
- let isReady = (cs: t) => cs.timestampCaughtUpToHeadOrEndblock !== None
425
-
426
574
  // True once the fetch frontier has reached the head/endBlock for this chain.
427
575
  let isFetchingAtHead = (cs: t) => cs.fetchState->FetchState.isFetchingAtHead
428
576
 
577
+ // Reached head on a chain with no configured endBlock — used by auto-exit to
578
+ // detect that no events were found in the start..head range.
579
+ let isAtHeadWithoutEndBlock = (cs: t) =>
580
+ cs.isProgressAtHead && cs.fetchState.endBlock->Option.isNone
581
+
429
582
  // --- State transitions. The chain state is mutated only through these; each
430
583
  // owns a cohesive update so callers don't juggle individual fields. ---
431
584
 
432
- // Apply a fetch response: register any new dynamic contracts, append the items
433
- // to the buffer and advance the known head.
585
+ // Per-store grouping data a single pass over `items` produces (see
586
+ // `groupBatchItems`), fed into `TransactionStore`/`BlockStore`'s `materialize`.
587
+ type transactionGroups = {
588
+ txBlockNumbers: array<int>,
589
+ transactionIndices: array<int>,
590
+ transactionMasks: array<float>,
591
+ payloadGroups: array<array<Internal.eventPayload>>,
592
+ anyTransactionFieldSelected: bool,
593
+ }
594
+ type blockGroups = {
595
+ blockBlockNumbers: array<int>,
596
+ blockMasks: array<float>,
597
+ blockItemGroups: array<array<Internal.eventItem>>,
598
+ }
599
+
600
+ // Walk `items` once to build both the transaction-store and block-store
601
+ // grouping data, instead of `TransactionStore`/`BlockStore` each re-walking the
602
+ // same batch independently. Items already arrive in (blockNumber, logIndex)
603
+ // order, so a (blockNumber, transactionIndex) run and a blockNumber-only run
604
+ // each stay adjacent; every item is checked against both, in the same pass.
605
+ // `includeBlocks` skips the block side entirely for Fuel, which carries the
606
+ // block inline and has no store.
607
+ let groupBatchItems = (items: array<Internal.item>, ~includeBlocks: bool): (
608
+ transactionGroups,
609
+ blockGroups,
610
+ ) => {
611
+ let txBlockNumbers = []
612
+ let transactionIndices = []
613
+ let transactionMasks = []
614
+ let payloadGroups = []
615
+ let anyTransactionFieldSelected = ref(false)
616
+
617
+ let blockBlockNumbers = []
618
+ let blockMasks = []
619
+ let blockItemGroups = []
620
+
621
+ items->Array.forEach(item =>
622
+ switch item {
623
+ | Internal.Event(_) =>
624
+ let eventItem = item->Internal.castUnsafeEventItem
625
+ let {blockNumber} = eventItem
626
+
627
+ switch eventItem.payload->Internal.getPayloadTransaction->Nullable.toOption {
628
+ | Some(_) => () // RPC/simulate/Fuel carry the transaction inline.
629
+ | None =>
630
+ let {transactionIndex} = eventItem
631
+ let mask = eventItem.onEventRegistration.eventConfig.transactionFieldMask
632
+ if mask != 0. {
633
+ anyTransactionFieldSelected := true
634
+ }
635
+ let last = payloadGroups->Array.length - 1
636
+ if (
637
+ last >= 0 &&
638
+ txBlockNumbers->Array.getUnsafe(last) == blockNumber &&
639
+ transactionIndices->Array.getUnsafe(last) == transactionIndex
640
+ ) {
641
+ payloadGroups->Array.getUnsafe(last)->Array.push(eventItem.payload)
642
+ transactionMasks->Array.setUnsafe(
643
+ last,
644
+ FieldMask.orMask(transactionMasks->Array.getUnsafe(last), mask),
645
+ )
646
+ } else {
647
+ txBlockNumbers->Array.push(blockNumber)
648
+ transactionIndices->Array.push(transactionIndex)
649
+ transactionMasks->Array.push(mask)
650
+ payloadGroups->Array.push([eventItem.payload])
651
+ }
652
+ }
653
+
654
+ if includeBlocks {
655
+ switch eventItem.payload->Internal.getPayloadBlock->Nullable.toOption {
656
+ | Some(_) => () // RPC/simulate/Fuel carry the block inline.
657
+ | None =>
658
+ let mask = eventItem.onEventRegistration.eventConfig.blockFieldMask
659
+ let last = blockItemGroups->Array.length - 1
660
+ if last >= 0 && blockBlockNumbers->Array.getUnsafe(last) == blockNumber {
661
+ blockItemGroups->Array.getUnsafe(last)->Array.push(eventItem)
662
+ blockMasks->Array.setUnsafe(
663
+ last,
664
+ FieldMask.orMask(blockMasks->Array.getUnsafe(last), mask),
665
+ )
666
+ } else {
667
+ blockBlockNumbers->Array.push(blockNumber)
668
+ blockMasks->Array.push(mask)
669
+ blockItemGroups->Array.push([eventItem])
670
+ }
671
+ }
672
+ }
673
+ | Internal.Block(_) => ()
674
+ }
675
+ )
676
+
677
+ (
678
+ {
679
+ txBlockNumbers,
680
+ transactionIndices,
681
+ transactionMasks,
682
+ payloadGroups,
683
+ anyTransactionFieldSelected: anyTransactionFieldSelected.contents,
684
+ },
685
+ {blockBlockNumbers, blockMasks, blockItemGroups},
686
+ )
687
+ }
688
+
689
+ // Materialise a `TransactionStore` against precomputed groups (see
690
+ // `groupBatchItems`). Store-backed items always get a transaction object — the
691
+ // selected fields, or `{}` when nothing was selected — so `event.transaction`
692
+ // is never `undefined` (matching the inline sources).
693
+ let applyTransactionGroups = async (store: TransactionStore.t, g: transactionGroups) => {
694
+ if g.payloadGroups->Utils.Array.notEmpty {
695
+ if g.anyTransactionFieldSelected {
696
+ let txs = await store->TransactionStore.materialize(
697
+ ~blockNumbers=g.txBlockNumbers,
698
+ ~transactionIndices=g.transactionIndices,
699
+ ~masks=g.transactionMasks,
700
+ )
701
+ g.payloadGroups->Array.forEachWithIndex((payloads, i) => {
702
+ let tx = txs->Array.getUnsafe(i)
703
+ payloads->Array.forEach(payload => payload->Internal.setPayloadTransaction(tx))
704
+ })
705
+ } else {
706
+ g.payloadGroups->Array.forEach(payloads =>
707
+ payloads->Array.forEach(payload => payload->Internal.setPayloadTransaction(%raw(`{}`)))
708
+ )
709
+ }
710
+ }
711
+ }
712
+
713
+ // Materialise a `BlockStore` against precomputed groups (see `groupBatchItems`).
714
+ let applyBlockGroups = async (store: BlockStore.t, g: blockGroups) => {
715
+ if g.blockItemGroups->Utils.Array.notEmpty {
716
+ let blocks = await store->BlockStore.materialize(
717
+ ~blockNumbers=g.blockBlockNumbers,
718
+ ~masks=g.blockMasks,
719
+ )
720
+ g.blockItemGroups->Array.forEachWithIndex((group, i) => {
721
+ let block = blocks->Array.getUnsafe(i)
722
+ group->Array.forEach(ei => ei.payload->Internal.setPayloadBlock(block))
723
+ })
724
+ }
725
+ }
726
+
727
+ let includeBlocksForEcosystem = (ecosystem: Ecosystem.name) =>
728
+ switch ecosystem {
729
+ | Evm | Svm => true
730
+ | Fuel => false
731
+ }
732
+
733
+ // Materialise the chain stores' selected transaction and block fields onto a
734
+ // batch's items at batch prep (the persistent-store path). A single pass over
735
+ // `items` (`groupBatchItems`) builds both stores' selection masks before the
736
+ // two independent materialize calls run concurrently.
737
+ let materializeBatchItems = async (cs: t, ~items: array<Internal.item>, ~ecosystem) => {
738
+ let (txGroups, blockGroups) =
739
+ items->groupBatchItems(~includeBlocks=ecosystem->includeBlocksForEcosystem)
740
+ let _ = await Promise.all2((
741
+ cs.transactionStore->applyTransactionGroups(txGroups),
742
+ cs.blockStore->applyBlockGroups(blockGroups),
743
+ ))
744
+ }
745
+
746
+ // Materialise a fetch-response page's transactions and blocks onto its items
747
+ // before contract-register handlers read them. `None` pages (RPC/Fuel/Simulate
748
+ // keep them inline) are a no-op.
749
+ let materializePageItems = async (
750
+ ~items: array<Internal.item>,
751
+ ~transactionStore: option<TransactionStore.t>,
752
+ ~blockStore: option<BlockStore.t>,
753
+ ~ecosystem,
754
+ ) => {
755
+ let (txGroups, blockGroups) =
756
+ items->groupBatchItems(~includeBlocks=ecosystem->includeBlocksForEcosystem)
757
+ let _ = await Promise.all2((
758
+ switch transactionStore {
759
+ | Some(store) => store->applyTransactionGroups(txGroups)
760
+ | None => Promise.resolve()
761
+ },
762
+ switch blockStore {
763
+ | Some(store) => store->applyBlockGroups(blockGroups)
764
+ | None => Promise.resolve()
765
+ },
766
+ ))
767
+ }
768
+
769
+ let filterByClientAddress = (cs: t, items: array<Internal.item>): array<Internal.item> =>
770
+ items->FetchState.filterByClientAddress(~indexingAddresses=cs.indexingAddresses)
771
+
434
772
  let handleQueryResult = (
435
773
  cs: t,
436
774
  ~query: FetchState.query,
@@ -438,10 +776,27 @@ let handleQueryResult = (
438
776
  ~newItemsWithDcs,
439
777
  ~latestFetchedBlock,
440
778
  ~knownHeight,
779
+ ~transactionStore as txPage: option<TransactionStore.t>,
780
+ ~blockStore as blockPage: option<BlockStore.t>,
441
781
  ) => {
782
+ // Merge this response's pages into the chain stores in lockstep with appending
783
+ // its items to the buffer. Inline sources contribute no page.
784
+ switch txPage {
785
+ | Some(page) => cs.transactionStore->TransactionStore.merge(page)
786
+ | None => ()
787
+ }
788
+ switch blockPage {
789
+ | Some(page) => cs.blockStore->BlockStore.merge(page)
790
+ | None => ()
791
+ }
792
+
442
793
  let fs = switch newItemsWithDcs {
443
794
  | [] => cs.fetchState
444
- | _ => cs.fetchState->FetchState.registerDynamicContracts(newItemsWithDcs)
795
+ | _ =>
796
+ cs.fetchState->FetchState.registerDynamicContracts(
797
+ ~indexingAddresses=cs.indexingAddresses,
798
+ newItemsWithDcs,
799
+ )
445
800
  }
446
801
 
447
802
  cs.fetchState =
@@ -450,7 +805,7 @@ let handleQueryResult = (
450
805
  ->FetchState.updateKnownHeight(~knownHeight)
451
806
 
452
807
  // The query is no longer in flight, so release its reservation.
453
- cs.pendingBudget = Pervasives.max(0., cs.pendingBudget -. query.estResponseSize)
808
+ cs.pendingBudget = Pervasives.max(0., cs.pendingBudget -. query.itemsEst->Int.toFloat)
454
809
  }
455
810
 
456
811
  // Run reorg detection against a fetch response and commit the updated guard.
@@ -491,6 +846,56 @@ let setEndBlockToFirstEvent = (cs: t, ~blockNumber) =>
491
846
  let enterReorgThreshold = (cs: t) =>
492
847
  cs.fetchState = cs.fetchState->FetchState.updateInternal(~blockLag=cs.chainConfig.blockLag)
493
848
 
849
+ // Snapshot the chain's metadata fields for staging into the chains table.
850
+ let toChainMetadata = (cs: t): InternalTable.Chains.metaFields => {
851
+ firstEventBlockNumber: cs.fetchState.firstEventBlock->Null.fromOption,
852
+ isHyperSync: (cs.sourceManager->SourceManager.getActiveSource).poweredByHyperSync,
853
+ latestFetchedBlockNumber: cs.fetchState->FetchState.bufferBlockNumber,
854
+ timestampCaughtUpToHeadOrEndblock: cs.timestampCaughtUpToHeadOrEndblock->Null.fromOption,
855
+ }
856
+
857
+ // Snapshot the chain's view for the status API.
858
+ let toChainData = (cs: t): chainData => {
859
+ chainId: cs.chainConfig.id->Int.toFloat,
860
+ poweredByHyperSync: (cs.sourceManager->SourceManager.getActiveSource).poweredByHyperSync,
861
+ firstEventBlockNumber: cs.fetchState.firstEventBlock,
862
+ latestProcessedBlock: cs.committedProgressBlockNumber === -1
863
+ ? None
864
+ : Some(cs.committedProgressBlockNumber),
865
+ timestampCaughtUpToHeadOrEndblock: cs.timestampCaughtUpToHeadOrEndblock,
866
+ numEventsProcessed: cs.numEventsProcessed,
867
+ latestFetchedBlockNumber: Pervasives.max(cs.fetchState->FetchState.bufferBlockNumber, 0),
868
+ knownHeight: cs->hasProcessedToEndblock
869
+ ? cs.fetchState.endBlock->Option.getOr(cs.fetchState.knownHeight)
870
+ : cs.fetchState.knownHeight,
871
+ numBatchesFetched: 0,
872
+ startBlock: cs.fetchState.startBlock,
873
+ endBlock: cs.fetchState.endBlock,
874
+ numAddresses: cs.indexingAddresses->IndexingAddresses.size,
875
+ }
876
+
877
+ // Snapshot the inputs a batch build needs from this chain.
878
+ let toChainBeforeBatch = (cs: t): Batch.chainBeforeBatch => {
879
+ fetchState: cs.fetchState,
880
+ progressBlockNumber: cs.committedProgressBlockNumber,
881
+ totalEventsProcessed: cs.numEventsProcessed,
882
+ sourceBlockNumber: cs.fetchState.knownHeight,
883
+ reorgDetection: cs.reorgDetection,
884
+ chainConfig: cs.chainConfig,
885
+ }
886
+
887
+ // Whether the chain's post-batch fetch frontier is ready to cross into the reorg
888
+ // threshold, using the batch's progressed frontier when this chain advanced.
889
+ let isReadyToEnterReorgThresholdAfterBatch = (cs: t, ~batch: Batch.t) => {
890
+ let fetchState = switch batch.progressedChainsById->Utils.Dict.dangerouslyGetByIntNonOption(
891
+ cs.fetchState.chainId,
892
+ ) {
893
+ | Some(chainAfterBatch) => chainAfterBatch.fetchState
894
+ | None => cs.fetchState
895
+ }
896
+ fetchState->FetchState.isReadyToEnterReorgThreshold
897
+ }
898
+
494
899
  // Commit the post-batch fetch frontier for a chain that progressed in the batch,
495
900
  // applying blockLag when this batch also crosses into the reorg threshold.
496
901
  let advanceAfterBatch = (cs: t, ~batch: Batch.t, ~enteringReorgThreshold) =>
@@ -501,14 +906,19 @@ let advanceAfterBatch = (cs: t, ~batch: Batch.t, ~enteringReorgThreshold) =>
501
906
  cs.fetchState = enteringReorgThreshold
502
907
  ? chainAfterBatch.fetchState->FetchState.updateInternal(~blockLag=cs.chainConfig.blockLag)
503
908
  : chainAfterBatch.fetchState
909
+
910
+ // The batch's items just left the buffer, so the remaining buffer's span
911
+ // starts at the batch's progress.
912
+ cs.processingBlockNumber = chainAfterBatch.progressBlockNumber
504
913
  | None => ()
505
914
  }
506
915
 
507
916
  // Commit a processed batch's progress for this chain (progress block, events
508
917
  // processed, head/safe-checkpoint tracking, first event block). Emits the
509
918
  // per-chain progress metrics. Readiness is decided by CrossChainState once every
510
- // chain is caught up (see markReady).
511
- let applyBatchProgress = (cs: t, ~batch: Batch.t) => {
919
+ // chain is caught up (see markReady). `blockTimestampName` is the ecosystem's
920
+ // block-timestamp field, read off the payload block for the latency metric.
921
+ let applyBatchProgress = (cs: t, ~batch: Batch.t, ~blockTimestampName: string) => {
512
922
  let chainId = cs.chainConfig.id
513
923
 
514
924
  switch batch.progressedChainsById->Utils.Dict.dangerouslyGetByIntNonOption(chainId) {
@@ -526,15 +936,27 @@ let applyBatchProgress = (cs: t, ~batch: Batch.t) => {
526
936
  )
527
937
  }
528
938
 
529
- // Calculate and set latency metrics
530
- switch batch->Batch.findLastEventItem(~chainId) {
531
- | Some(eventItem) => {
532
- let blockTimestampMs = eventItem.timestamp * 1000
533
- Prometheus.ProgressLatency.set(
534
- ~latencyMs=Date.now()->Float.toInt - blockTimestampMs,
535
- ~chainId,
536
- )
537
- }
939
+ // Calculate and set latency metrics. The payload block is materialised or
940
+ // inline by processing time; its timestamp may still be absent (e.g. an
941
+ // SVM slot with no block row) the metric is skipped then.
942
+ switch batch
943
+ ->Batch.findLastEventItem(~chainId)
944
+ ->Option.flatMap(eventItem =>
945
+ eventItem.payload
946
+ ->Internal.getPayloadBlock
947
+ ->Nullable.toOption
948
+ )
949
+ ->Option.flatMap(block =>
950
+ block
951
+ ->(Utils.magic: Internal.eventBlock => dict<unknown>)
952
+ ->Utils.Dict.dangerouslyGetNonOption(blockTimestampName)
953
+ ) {
954
+ | Some(blockTimestamp) =>
955
+ let blockTimestampMs = blockTimestamp->(Utils.magic: unknown => int) * 1000
956
+ Prometheus.ProgressLatency.set(
957
+ ~latencyMs=Date.now()->Float.toInt - blockTimestampMs,
958
+ ~chainId,
959
+ )
538
960
  | None => ()
539
961
  }
540
962
 
@@ -549,8 +971,42 @@ let applyBatchProgress = (cs: t, ~batch: Batch.t) => {
549
971
  }
550
972
  }
551
973
 
974
+ // Chain-wide density update: seed with the batch's own events/block on
975
+ // the first update, then blend weighted by the batch's block span — a
976
+ // few sparse/dense blocks barely nudge the estimate, while a
977
+ // window-sized batch replaces it.
978
+ let deltaBlocks = chainAfterBatch.progressBlockNumber - cs.committedProgressBlockNumber
979
+ if deltaBlocks > 0 {
980
+ let deltaEvents = chainAfterBatch.totalEventsProcessed -. cs.numEventsProcessed
981
+ // Don't seed a density before the first event is seen — a progress-only
982
+ // batch would otherwise set it to 0, matching the resume-seed guard.
983
+ switch (cs.chainDensity, deltaEvents > 0.) {
984
+ | (None, false) => ()
985
+ | _ =>
986
+ let batchDensity = deltaEvents /. deltaBlocks->Int.toFloat
987
+ cs.chainDensity = Some(
988
+ switch cs.chainDensity {
989
+ | None => batchDensity
990
+ | Some(oldDensity) =>
991
+ let alpha = Pervasives.min(1., deltaBlocks->Int.toFloat /. densityBlendWindow)
992
+ oldDensity *. (1. -. alpha) +. batchDensity *. alpha
993
+ },
994
+ )
995
+ }
996
+ }
997
+
552
998
  cs.committedProgressBlockNumber = chainAfterBatch.progressBlockNumber
999
+
1000
+ // Normally already set by advanceAfterBatch at batch creation; catch up
1001
+ // here for paths that commit progress without it.
1002
+ cs.processingBlockNumber = Pervasives.max(
1003
+ cs.processingBlockNumber,
1004
+ chainAfterBatch.progressBlockNumber,
1005
+ )
553
1006
  cs.numEventsProcessed = chainAfterBatch.totalEventsProcessed
1007
+ // Processed blocks' transactions and blocks are no longer needed.
1008
+ cs.transactionStore->TransactionStore.prune(chainAfterBatch.progressBlockNumber)
1009
+ cs.blockStore->BlockStore.prune(chainAfterBatch.progressBlockNumber)
554
1010
  cs.isProgressAtHead = cs.isProgressAtHead || chainAfterBatch.isProgressAtHeadWhenBatchCreated
555
1011
  switch cs.safeCheckpointTracking {
556
1012
  | Some(safeCheckpointTracking) =>
@@ -623,8 +1079,15 @@ let rollback = (
623
1079
  )
624
1080
  | None => ()
625
1081
  }
626
- cs.fetchState = cs.fetchState->FetchState.rollback(~targetBlockNumber=newProgressBlockNumber)
1082
+ cs.fetchState =
1083
+ cs.fetchState->FetchState.rollback(
1084
+ ~indexingAddresses=cs.indexingAddresses,
1085
+ ~targetBlockNumber=newProgressBlockNumber,
1086
+ )
1087
+ cs.transactionStore->TransactionStore.rollback(newProgressBlockNumber)
1088
+ cs.blockStore->BlockStore.rollback(newProgressBlockNumber)
627
1089
  cs.committedProgressBlockNumber = newProgressBlockNumber
1090
+ cs.processingBlockNumber = newProgressBlockNumber
628
1091
  cs.numEventsProcessed = newTotalEventsProcessed
629
1092
  | None =>
630
1093
  if isReorgChain {
@@ -633,11 +1096,17 @@ let rollback = (
633
1096
  ~blockNumber=rollbackTargetBlockNumber,
634
1097
  )
635
1098
  cs.fetchState =
636
- cs.fetchState->FetchState.rollback(~targetBlockNumber=rollbackTargetBlockNumber)
1099
+ cs.fetchState->FetchState.rollback(
1100
+ ~indexingAddresses=cs.indexingAddresses,
1101
+ ~targetBlockNumber=rollbackTargetBlockNumber,
1102
+ )
1103
+ cs.transactionStore->TransactionStore.rollback(rollbackTargetBlockNumber)
1104
+ cs.blockStore->BlockStore.rollback(rollbackTargetBlockNumber)
637
1105
  cs.committedProgressBlockNumber = Pervasives.min(
638
1106
  cs.committedProgressBlockNumber,
639
1107
  rollbackTargetBlockNumber,
640
1108
  )
1109
+ cs.processingBlockNumber = Pervasives.min(cs.processingBlockNumber, rollbackTargetBlockNumber)
641
1110
  }
642
1111
  }
643
1112
  }