envio 3.6.1 → 3.7.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 (80) hide show
  1. package/index.d.ts +197 -9
  2. package/package.json +6 -6
  3. package/src/Batch.res +64 -25
  4. package/src/Batch.res.mjs +69 -46
  5. package/src/ChainFetching.res +13 -17
  6. package/src/ChainFetching.res.mjs +9 -14
  7. package/src/ChainState.res +160 -119
  8. package/src/ChainState.res.mjs +75 -46
  9. package/src/ChainState.resi +15 -12
  10. package/src/Config.res +9 -2
  11. package/src/Config.res.mjs +4 -4
  12. package/src/Core.res +5 -0
  13. package/src/Ecosystem.res +0 -1
  14. package/src/Envio.res +16 -3
  15. package/src/EventConfigBuilder.res +155 -40
  16. package/src/EventConfigBuilder.res.mjs +76 -24
  17. package/src/EventProcessing.res +4 -8
  18. package/src/EventProcessing.res.mjs +4 -4
  19. package/src/EventUtils.res +3 -47
  20. package/src/FetchState.res +124 -104
  21. package/src/FetchState.res.mjs +116 -100
  22. package/src/HandlerRegister.res +108 -2
  23. package/src/HandlerRegister.res.mjs +59 -10
  24. package/src/HandlerRegister.resi +4 -0
  25. package/src/Internal.res +84 -24
  26. package/src/Internal.res.mjs +33 -2
  27. package/src/LazyLoader.res +12 -11
  28. package/src/LazyLoader.res.mjs +13 -7
  29. package/src/Main.res +22 -9
  30. package/src/Main.res.mjs +18 -5
  31. package/src/MemoryStorage.res +724 -0
  32. package/src/MemoryStorage.res.mjs +601 -0
  33. package/src/PgStorage.res +18 -7
  34. package/src/PgStorage.res.mjs +14 -11
  35. package/src/ReorgDetection.res +0 -222
  36. package/src/ReorgDetection.res.mjs +0 -163
  37. package/src/Rollback.res +14 -15
  38. package/src/Rollback.res.mjs +4 -5
  39. package/src/SimulateItems.res +2 -2
  40. package/src/Utils.res +3 -0
  41. package/src/bindings/ClickHouse.res +16 -11
  42. package/src/bindings/ClickHouse.res.mjs +10 -10
  43. package/src/bindings/Vitest.res +1 -1
  44. package/src/sources/BlockStore.res +115 -5
  45. package/src/sources/BlockStore.res.mjs +25 -0
  46. package/src/sources/Evm.res +0 -1
  47. package/src/sources/Evm.res.mjs +0 -1
  48. package/src/sources/EvmHyperSyncSource.res +19 -84
  49. package/src/sources/EvmHyperSyncSource.res.mjs +24 -60
  50. package/src/sources/Fuel.res +24 -4
  51. package/src/sources/Fuel.res.mjs +23 -3
  52. package/src/sources/FuelHyperSync.res +8 -3
  53. package/src/sources/FuelHyperSync.res.mjs +5 -4
  54. package/src/sources/FuelHyperSync.resi +3 -1
  55. package/src/sources/FuelHyperSyncClient.res +7 -10
  56. package/src/sources/FuelHyperSyncSource.res +18 -45
  57. package/src/sources/FuelHyperSyncSource.res.mjs +18 -35
  58. package/src/sources/HyperSync.res +49 -229
  59. package/src/sources/HyperSync.res.mjs +74 -191
  60. package/src/sources/HyperSync.resi +11 -35
  61. package/src/sources/HyperSyncClient.res +9 -79
  62. package/src/sources/HyperSyncClient.res.mjs +2 -6
  63. package/src/sources/RequestStat.res +5 -0
  64. package/src/sources/RequestStat.res.mjs +2 -0
  65. package/src/sources/RpcSource.res +149 -43
  66. package/src/sources/RpcSource.res.mjs +104 -41
  67. package/src/sources/SimulateSource.res +8 -5
  68. package/src/sources/SimulateSource.res.mjs +6 -6
  69. package/src/sources/Source.res +89 -16
  70. package/src/sources/Source.res.mjs +50 -1
  71. package/src/sources/SourceManager.res +255 -50
  72. package/src/sources/SourceManager.res.mjs +149 -55
  73. package/src/sources/SourceManager.resi +2 -6
  74. package/src/sources/Svm.res +0 -7
  75. package/src/sources/Svm.res.mjs +0 -8
  76. package/src/sources/SvmHyperSyncClient.res +21 -16
  77. package/src/sources/SvmHyperSyncClient.res.mjs +3 -4
  78. package/src/sources/SvmHyperSyncSource.res +25 -117
  79. package/src/sources/SvmHyperSyncSource.res.mjs +8 -121
  80. package/svm.schema.json +3 -2
@@ -15,7 +15,6 @@ type cfg = {
15
15
  /** Milliseconds to wait for a response before timing out. Default: 30000. */
16
16
  httpReqTimeoutMillis?: int,
17
17
  /** Number of retries to attempt before returning error. Default: 12. */
18
- maxNumRetries?: int,
19
18
  /** Milliseconds that would be used for retry backoff increasing. Default: 500. */
20
19
  retryBackoffMs?: int,
21
20
  /** Initial wait time for request backoff. Default: 200. */
@@ -169,66 +168,8 @@ module QueryTypes = {
169
168
  }
170
169
  }
171
170
 
172
- module ResponseTypes = {
173
- type withdrawal = {
174
- index?: string,
175
- validatorIndex?: string,
176
- address?: Address.t,
177
- amount?: string,
178
- }
179
-
180
- type block = {
181
- number?: int,
182
- hash?: string,
183
- parentHash?: string,
184
- nonce?: bigint,
185
- sha3Uncles?: string,
186
- logsBloom?: string,
187
- transactionsRoot?: string,
188
- stateRoot?: string,
189
- receiptsRoot?: string,
190
- miner?: Address.t,
191
- difficulty?: bigint,
192
- totalDifficulty?: bigint,
193
- extraData?: string,
194
- size?: bigint,
195
- gasLimit?: bigint,
196
- gasUsed?: bigint,
197
- timestamp?: int,
198
- uncles?: array<string>,
199
- baseFeePerGas?: bigint,
200
- blobGasUsed?: bigint,
201
- excessBlobGas?: bigint,
202
- parentBeaconBlockRoot?: string,
203
- withdrawalsRoot?: string,
204
- withdrawals?: array<withdrawal>,
205
- l1BlockNumber?: int,
206
- sendCount?: string,
207
- sendRoot?: string,
208
- mixHash?: string,
209
- }
210
-
211
- type rollbackGuard = {
212
- blockNumber: int,
213
- timestamp: int,
214
- hash: string,
215
- firstBlockNumber: int,
216
- firstParentHash: string,
217
- }
218
- }
219
-
220
171
  type query = QueryTypes.query
221
172
 
222
- type queryResponseData = {blocks: array<ResponseTypes.block>}
223
-
224
- type queryResponse = {
225
- archiveHeight: option<int>,
226
- nextBlock: int,
227
- totalExecutionTime: int,
228
- data: queryResponseData,
229
- rollbackGuard: option<ResponseTypes.rollbackGuard>,
230
- }
231
-
232
173
  module Registration = {
233
174
  // One topic position of the resolved `where`: static topic values, or
234
175
  // `None` — the "currently registered addresses of this contract" marker,
@@ -295,12 +236,12 @@ module Registration = {
295
236
  }),
296
237
  // Capitalized to match the Rust BlockField/TransactionField string
297
238
  // enums.
298
- blockFields: event.selectedBlockFields
239
+ blockFields: reg.fieldSelection.blockFields
299
240
  ->Utils.Set.toArray
300
- ->Array.map(name => (name :> string)->Utils.String.capitalize),
301
- transactionFields: event.selectedTransactionFields
241
+ ->Array.map(Utils.String.capitalize),
242
+ transactionFields: reg.fieldSelection.transactionFields
302
243
  ->Utils.Set.toArray
303
- ->Array.map(name => (name :> string)->Utils.String.capitalize),
244
+ ->Array.map(Utils.String.capitalize),
304
245
  }
305
246
  })
306
247
  }
@@ -338,28 +279,19 @@ module EventItems = {
338
279
  params: Internal.eventParams,
339
280
  }
340
281
 
341
- // The always-needed block fields, one per block number. The block's remaining
342
- // fields live raw in the block store and are materialised on demand.
343
- type blockHeader = {
344
- number: int,
345
- timestamp: int,
346
- hash: string,
347
- }
348
-
349
282
  type response = {
350
283
  archiveHeight: option<int>,
351
284
  nextBlock: int,
352
- // One header per returned block number, including blocks no item
353
- // references — reorg detection reads them all. The block store keeps only
354
- // the ones items reference.
355
- blocks: array<blockHeader>,
356
285
  items: array<item>,
357
- rollbackGuard: option<ResponseTypes.rollbackGuard>,
358
286
  }
359
287
  }
360
288
 
361
289
  type t = {
362
- get: (~query: query) => promise<queryResponse>,
290
+ // Block-hash query construction and pagination live in Rust; only the
291
+ // aggregate response store crosses the boundary.
292
+ getBlockHashes: (
293
+ ~blockNumbers: array<int>,
294
+ ) => promise<(BlockStore.t, array<RequestStat.t>)>,
363
295
  // Returns the response plus page stores owning this page's raw transactions
364
296
  // and blocks.
365
297
  getEventItems: (
@@ -414,8 +346,6 @@ let make = (
414
346
  enableChecksumAddresses,
415
347
  apiToken,
416
348
  httpReqTimeoutMillis,
417
- // Retries are handled internally by the indexer, not the binary client
418
- maxNumRetries: 0,
419
349
  ?serializationFormat,
420
350
  ?enableQueryCaching,
421
351
  ?retryBaseMs,
@@ -34,8 +34,6 @@ let QueryTypes = {
34
34
  makeLogSelection: makeLogSelection
35
35
  };
36
36
 
37
- let ResponseTypes = {};
38
-
39
37
  function toTopicFilterInput(filter) {
40
38
  if (filter.TAG === "Values") {
41
39
  return filter._0;
@@ -61,8 +59,8 @@ function fromOnEventRegistrations(onEventRegistrations) {
61
59
  topic2: toTopicFilterInput(ts.topic2),
62
60
  topic3: toTopicFilterInput(ts.topic3)
63
61
  })),
64
- blockFields: Array.from(event.selectedBlockFields).map(name => Utils.$$String.capitalize(name)),
65
- transactionFields: Array.from(event.selectedTransactionFields).map(name => Utils.$$String.capitalize(name))
62
+ blockFields: Array.from(reg.fieldSelection.blockFields).map(Utils.$$String.capitalize),
63
+ transactionFields: Array.from(reg.fieldSelection.transactionFields).map(Utils.$$String.capitalize)
66
64
  };
67
65
  });
68
66
  }
@@ -108,7 +106,6 @@ function make(url, apiToken, httpReqTimeoutMillis, eventRegistrations, enableChe
108
106
  url: url,
109
107
  apiToken: apiToken,
110
108
  httpReqTimeoutMillis: httpReqTimeoutMillis,
111
- maxNumRetries: 0,
112
109
  retryBackoffMs: retryBackoffMs,
113
110
  retryBaseMs: retryBaseMs,
114
111
  retryCeilingMs: retryCeilingMs,
@@ -122,7 +119,6 @@ function make(url, apiToken, httpReqTimeoutMillis, eventRegistrations, enableChe
122
119
  export {
123
120
  serializationFormatSchema,
124
121
  QueryTypes,
125
- ResponseTypes,
126
122
  Registration,
127
123
  EventItems,
128
124
  makeWithAgent,
@@ -0,0 +1,5 @@
1
+ // A single backend request a source method made, with the time it took. Kept
2
+ // in its own leaf module (rather than on `Source`) so the client FFI bindings
3
+ // can name it without importing `Source` — which would form a dependency cycle
4
+ // through `Env → HyperSyncClient → Source → FetchState → Env`.
5
+ type t = {method: string, seconds: float}
@@ -0,0 +1,2 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+ /* This output is empty. Its source's type definitions, externals and/or unused code got optimized away. */
@@ -463,6 +463,21 @@ let makeFieldRegistry = (addressSchema: S.t<JSON.t>): Utils.Record.t<
463
463
  let fieldRegistryLowercase = makeFieldRegistry(lowercaseAddressSchema)
464
464
  let fieldRegistryChecksum = makeFieldRegistry(checksumAddressSchema)
465
465
 
466
+ // Whether an RPC source can populate a transaction field. The getter skips a
467
+ // field it can't parse, so this is what separates "absent because the chain has
468
+ // none" from "absent because RPC never provides it". `hash` and
469
+ // `transactionIndex` come off the log itself, so they have no registry entry.
470
+ // Blocks need no equivalent: `eth_getBlockByNumber` carries every block field,
471
+ // which `RpcFieldSelection_test.res` holds the registry to.
472
+ let isRpcTransactionField = (name: string) =>
473
+ switch name {
474
+ | "transactionIndex" | "hash" => true
475
+ | _ =>
476
+ fieldRegistryChecksum
477
+ ->Utils.Record.get(name->(Utils.magic: string => Internal.evmTransactionField))
478
+ ->Option.isSome
479
+ }
480
+
466
481
  type fetchStrategy = NoRpc | TransactionOnly | ReceiptOnly | TransactionAndReceipt
467
482
 
468
483
  // Parse fields from a raw JSON object into a result dict.
@@ -487,6 +502,30 @@ let parseFieldsFromJson = (
487
502
  })
488
503
  }
489
504
 
505
+ let effectiveGasPriceKey = (Internal.EffectiveGasPrice: Internal.evmTransactionField :> string)
506
+
507
+ // Pre-EIP-1559 receipts carry no `effectiveGasPrice` — every Optimism block
508
+ // below the Bedrock migration at 105235063, for one. Those chains price every
509
+ // transaction the legacy way, so the transaction's `gasPrice` is the effective
510
+ // price, which is the same substitution HyperSync serves for those blocks.
511
+ // Reads `gasPrice` but stores under `effectiveGasPrice`.
512
+ let effectiveGasPriceFallbackDef = {
513
+ location: EffectiveGasPrice,
514
+ jsonKey: "gasPrice",
515
+ schema: Rpc.hexBigintSchema->toFieldSchema,
516
+ source: TransactionOnly,
517
+ }
518
+
519
+ let fillEffectiveGasPriceOrThrow = (mutTransactionAcc: dict<JSON.t>, txJson: JSON.t) => {
520
+ let gasPrice =
521
+ txJson->(Utils.magic: JSON.t => dict<JSON.t>)->Utils.Dict.dangerouslyGetNonOption("gasPrice")
522
+ switch gasPrice {
523
+ | None =>
524
+ JsError.throwWithMessage(`Neither "effectiveGasPrice" nor "gasPrice" is present in the RPC response for the transaction. Remove "effectiveGasPrice" from the field selection, or index this chain via HyperSync.`)
525
+ | Some(_) => parseFieldsFromJson(mutTransactionAcc, [effectiveGasPriceFallbackDef], txJson)
526
+ }
527
+ }
528
+
490
529
  let makeThrowingGetEventTransaction = (
491
530
  ~getTransactionJson: string => promise<JSON.t>,
492
531
  ~getReceiptJson: string => promise<JSON.t>,
@@ -507,6 +546,7 @@ let makeThrowingGetEventTransaction = (
507
546
  // Classify fields: log-derived vs RPC fields
508
547
  let hasTransactionIndex = ref(false)
509
548
  let hasHash = ref(false)
549
+ let hasEffectiveGasPrice = ref(false)
510
550
  let txFields: array<fieldDef> = []
511
551
  let receiptFields: array<fieldDef> = []
512
552
  let bothFields: array<fieldDef> = []
@@ -518,6 +558,15 @@ let makeThrowingGetEventTransaction = (
518
558
  | _ =>
519
559
  switch fieldRegistry->Utils.Record.get(fieldName) {
520
560
  | Some(def) =>
561
+ // Absent from the receipt means fall back to the transaction's
562
+ // `gasPrice`, so this one field parses leniently rather than
563
+ // throwing on a receipt that omits it.
564
+ let def = if fieldName === EffectiveGasPrice {
565
+ hasEffectiveGasPrice := true
566
+ {...def, schema: S.nullable(def.schema)->toFieldSchema}
567
+ } else {
568
+ def
569
+ }
521
570
  switch def.source {
522
571
  | TransactionOnly => txFields->Array.push(def)->ignore
523
572
  | ReceiptOnly => receiptFields->Array.push(def)->ignore
@@ -580,10 +629,8 @@ let makeThrowingGetEventTransaction = (
580
629
  | _ => Promise.resolve(None)
581
630
  }
582
631
 
583
- Promise.all2((txJsonPromise, receiptJsonPromise))->Promise.thenResolve(((
584
- txJson,
585
- receiptJson,
586
- )) => {
632
+ Promise.all2((txJsonPromise, receiptJsonPromise))
633
+ ->Promise.then(((txJson, receiptJson)) => {
587
634
  let mutTransactionAcc = Dict.make()
588
635
  setLogFields(mutTransactionAcc, log)
589
636
 
@@ -596,6 +643,30 @@ let makeThrowingGetEventTransaction = (
596
643
  | None => ()
597
644
  }
598
645
 
646
+ // Only the chains that omit it pay for the extra request, and
647
+ // only when the receipt has already come back without it.
648
+ if (
649
+ hasEffectiveGasPrice.contents &&
650
+ mutTransactionAcc
651
+ ->Utils.Dict.dangerouslyGetNonOption(effectiveGasPriceKey)
652
+ ->Option.isNone
653
+ ) {
654
+ switch txJson {
655
+ | Some(json) => {
656
+ fillEffectiveGasPriceOrThrow(mutTransactionAcc, json)
657
+ Promise.resolve(mutTransactionAcc)
658
+ }
659
+ | None =>
660
+ getTransactionJson(log.transactionHash)->Promise.thenResolve(json => {
661
+ fillEffectiveGasPriceOrThrow(mutTransactionAcc, json)
662
+ mutTransactionAcc
663
+ })
664
+ }
665
+ } else {
666
+ Promise.resolve(mutTransactionAcc)
667
+ }
668
+ })
669
+ ->Promise.thenResolve(mutTransactionAcc => {
599
670
  mutTransactionAcc->(Utils.magic: dict<JSON.t> => 'a)
600
671
  })
601
672
  }
@@ -768,6 +839,12 @@ let make = (
768
839
  let transactionLoader = ref(makeTransactionLoader())
769
840
  let receiptLoader = ref(makeReceiptLoader())
770
841
 
842
+ let resetCachedLoaders = () => {
843
+ blockLoader := makeBlockLoader()
844
+ transactionLoader := makeTransactionLoader()
845
+ receiptLoader := makeReceiptLoader()
846
+ }
847
+
771
848
  let getEventBlockOrThrow = makeThrowingGetEventBlock(
772
849
  ~getBlockJson=blockNumber => blockLoader.contents->LazyLoader.get(blockNumber),
773
850
  ~lowercaseAddresses,
@@ -815,10 +892,16 @@ let make = (
815
892
  | None => knownHeight
816
893
  }
817
894
 
818
- let firstBlockParentPromise =
819
- fromBlock > 0
895
+ // The seam block (`fromBlock - 1`) is the only block this range shares with
896
+ // what the store already scanned, so it is where a reorg at the boundary
897
+ // shows up. Reading it directly would answer from the block cache — it was
898
+ // the previous range's `toBlock`, so it is always cached, and always on the
899
+ // chain that range saw. Read `fromBlock` instead, which no earlier range
900
+ // touched, and take the seam's hash from its `parentHash`.
901
+ let firstBlockPromise =
902
+ fromBlock > 0 && fromBlock <= toBlock
820
903
  ? blockLoader.contents
821
- ->LazyLoader.get(fromBlock - 1)
904
+ ->LazyLoader.get(fromBlock)
822
905
  ->Promise.thenResolve(json => Some(parseBlockInfo(json)))
823
906
  : Promise.resolve(None)
824
907
 
@@ -885,9 +968,13 @@ let make = (
885
968
  (
886
969
  async () => {
887
970
  let (block, transaction) = try await Promise.all2((
888
- log->getEventBlockOrThrow(~selectedBlockFields=eventConfig.selectedBlockFields),
971
+ log->getEventBlockOrThrow(
972
+ ~selectedBlockFields=onEventRegistration.fieldSelection.blockFields->(
973
+ Utils.magic: Utils.Set.t<string> => Utils.Set.t<Internal.evmBlockField>
974
+ ),
975
+ ),
889
976
  log->getEventTransactionOrThrow(
890
- ~selectedTransactionFields=eventConfig.selectedTransactionFields->(
977
+ ~selectedTransactionFields=onEventRegistration.fieldSelection.transactionFields->(
891
978
  Utils.magic: Utils.Set.t<string> => Utils.Set.t<Internal.evmTransactionField>
892
979
  ),
893
980
  ),
@@ -931,7 +1018,7 @@ let make = (
931
1018
  payload: {
932
1019
  contractName: eventConfig.contractName,
933
1020
  eventName: eventConfig.name,
934
- chainId: chainId,
1021
+ chainId,
935
1022
  params: decoded,
936
1023
  block,
937
1024
  transaction,
@@ -944,55 +1031,66 @@ let make = (
944
1031
  })
945
1032
  ->Promise.all
946
1033
 
947
- let optFirstBlockParent = await firstBlockParentPromise
1034
+ let optFirstBlock = await firstBlockPromise
948
1035
 
949
1036
  let totalTimeElapsed = startFetchingBatchTimeRef->Performance.secondsSince
950
1037
 
951
1038
  // Every fetched block carries `hash` and `parentHash`, so each one yields
952
1039
  // two confirmed (number, hash) pairs for reorg detection at no extra cost.
953
- let blockHashes = []
1040
+ // Both these blocks and the logs' own `blockHash` come from this range's
1041
+ // responses, never from the block cache's older view of the chain. They go
1042
+ // into a hash-only page store merged into the chain store, where hash
1043
+ // comparison happens. The block data itself stays inline on the payload.
1044
+ let observedBlocks: array<BlockStore.inputBlock> = []
954
1045
  let pushBlockInfo = (b: blockInfo) => {
955
- blockHashes->Array.push({ReorgDetection.blockNumber: b.number, blockHash: b.hash})->ignore
1046
+ observedBlocks
1047
+ ->Array.push({
1048
+ BlockStore.blockNumber: b.number,
1049
+ blockHash: b.hash,
1050
+ blockTimestamp: b.timestamp,
1051
+ })
1052
+ ->ignore
956
1053
  if b.number > 0 {
957
- blockHashes
958
- ->Array.push({ReorgDetection.blockNumber: b.number - 1, blockHash: b.parentHash})
1054
+ observedBlocks
1055
+ ->Array.push({BlockStore.blockNumber: b.number - 1, blockHash: b.parentHash})
959
1056
  ->ignore
960
1057
  }
961
1058
  }
962
1059
  pushBlockInfo(latestFetchedBlockInfo)
963
- switch optFirstBlockParent {
1060
+ switch optFirstBlock {
964
1061
  | Some(b) => pushBlockInfo(b)
965
1062
  | None => ()
966
1063
  }
967
1064
  items->Array.forEach(({log}) =>
968
- blockHashes
969
- ->Array.push({ReorgDetection.blockNumber: log.blockNumber, blockHash: log.blockHash})
1065
+ observedBlocks
1066
+ ->Array.push({BlockStore.blockNumber: log.blockNumber, blockHash: log.blockHash})
970
1067
  ->ignore
971
1068
  )
972
1069
 
973
1070
  {
974
- latestFetchedBlockTimestamp: latestFetchedBlockInfo.timestamp,
975
1071
  latestFetchedBlockNumber: latestFetchedBlockInfo.number,
976
1072
  parsedQueueItems,
977
- // RPC keeps the transaction and block inline on the payload; no store pages.
1073
+ // RPC keeps the transaction and block inline on the payload; no
1074
+ // transaction page, and the block page carries only observed hashes.
978
1075
  transactionStore: None,
979
- blockStore: None,
1076
+ blockStore: BlockStore.fromJs(
1077
+ observedBlocks,
1078
+ ~ecosystem=Evm,
1079
+ ~shouldChecksum=!lowercaseAddresses,
1080
+ ),
980
1081
  stats: {
981
1082
  totalTimeElapsed: totalTimeElapsed,
982
1083
  },
983
1084
  knownHeight,
984
- blockHashes,
985
1085
  fromBlockQueried: fromBlock,
986
1086
  requestStats: drainRequestStats(),
987
1087
  }
988
1088
  }
989
1089
 
990
- let onReorg = (~rollbackTargetBlock as _) => {
991
- // Drop cached block/transaction/receipt data — after a reorg the cached
992
- // entries may refer to orphaned-chain values.
993
- blockLoader := makeBlockLoader()
994
- transactionLoader := makeTransactionLoader()
995
- receiptLoader := makeReceiptLoader()
1090
+ let onReorg = () => {
1091
+ // Drop cached block/transaction/receipt data — after a reorg or an
1092
+ // internally inconsistent response these may refer to orphaned-chain values.
1093
+ resetCachedLoaders()
996
1094
  }
997
1095
 
998
1096
  let getBlockHashes = (~blockNumbers, ~logger as _currentlyUnusedLogger) => {
@@ -1000,21 +1098,29 @@ let make = (
1000
1098
  ->Array.map(blockNum => blockLoader.contents->LazyLoader.get(blockNum))
1001
1099
  ->Promise.all
1002
1100
  ->Promise.thenResolve(rawBlocks => {
1003
- let result =
1004
- rawBlocks
1005
- ->Array.map(json => {
1006
- let b = parseBlockInfo(json)
1007
-
1008
- (
1009
- {
1010
- blockNumber: b.number,
1011
- blockHash: b.hash,
1012
- blockTimestamp: b.timestamp,
1013
- }: ReorgDetection.blockDataWithTimestamp
1014
- )
1101
+ // Each block is fetched in its own request, so responses can mix forks.
1102
+ // The parent hash becomes a minimal extra row: when consecutive blocks
1103
+ // are requested, the page's own hash-collision check cross-validates the
1104
+ // separately fetched responses.
1105
+ let observedBlocks: array<BlockStore.inputBlock> = []
1106
+ rawBlocks->Array.forEach(json => {
1107
+ let b = parseBlockInfo(json)
1108
+ observedBlocks
1109
+ ->Array.push({
1110
+ BlockStore.blockNumber: b.number,
1111
+ blockHash: b.hash,
1112
+ blockTimestamp: b.timestamp,
1015
1113
  })
1016
- ->Ok
1017
- {Source.result, requestStats: drainRequestStats()}
1114
+ ->ignore
1115
+ if b.number > 0 {
1116
+ observedBlocks
1117
+ ->Array.push({BlockStore.blockNumber: b.number - 1, blockHash: b.parentHash})
1118
+ ->ignore
1119
+ }
1120
+ })
1121
+ let blockStore =
1122
+ observedBlocks->BlockStore.fromJs(~ecosystem=Evm, ~shouldChecksum=!lowercaseAddresses)
1123
+ {Source.result: Ok(blockStore), requestStats: drainRequestStats()}
1018
1124
  })
1019
1125
  ->Promise.catch(exn =>
1020
1126
  {Source.result: Error(exn), requestStats: drainRequestStats()}->Promise.resolve
@@ -1023,7 +1129,7 @@ let make = (
1023
1129
 
1024
1130
  let createHeightSubscription =
1025
1131
  ws->Option.map(wsUrl =>
1026
- (~onHeight) => RpcWebSocketHeightStream.subscribe(~wsUrl, ~chainId=chainId, ~onHeight)
1132
+ (~onHeight) => RpcWebSocketHeightStream.subscribe(~wsUrl, ~chainId, ~onHeight)
1027
1133
  )
1028
1134
 
1029
1135
  {