envio 3.1.1 → 3.2.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 (134) hide show
  1. package/evm.schema.json +83 -11
  2. package/fuel.schema.json +83 -11
  3. package/index.d.ts +184 -3
  4. package/package.json +6 -6
  5. package/src/Batch.res +2 -2
  6. package/src/ChainFetcher.res +27 -3
  7. package/src/ChainFetcher.res.mjs +17 -3
  8. package/src/ChainManager.res +163 -0
  9. package/src/ChainManager.res.mjs +136 -0
  10. package/src/Config.res +213 -30
  11. package/src/Config.res.mjs +102 -41
  12. package/src/Core.res +16 -10
  13. package/src/Ecosystem.res +0 -3
  14. package/src/Env.res +2 -2
  15. package/src/Env.res.mjs +2 -2
  16. package/src/Envio.res +101 -2
  17. package/src/Envio.res.mjs +2 -3
  18. package/src/EventConfigBuilder.res +52 -0
  19. package/src/EventConfigBuilder.res.mjs +32 -0
  20. package/src/EventUtils.res +2 -2
  21. package/src/FetchState.res +126 -71
  22. package/src/FetchState.res.mjs +73 -51
  23. package/src/GlobalState.res +219 -363
  24. package/src/GlobalState.res.mjs +314 -491
  25. package/src/GlobalStateManager.res +49 -59
  26. package/src/GlobalStateManager.res.mjs +5 -4
  27. package/src/GlobalStateManager.resi +1 -1
  28. package/src/HandlerLoader.res +12 -1
  29. package/src/HandlerLoader.res.mjs +6 -1
  30. package/src/HandlerRegister.res +9 -9
  31. package/src/HandlerRegister.res.mjs +9 -9
  32. package/src/Hasura.res +102 -32
  33. package/src/Hasura.res.mjs +88 -34
  34. package/src/InMemoryStore.res +10 -1
  35. package/src/InMemoryStore.res.mjs +4 -1
  36. package/src/InMemoryTable.res +83 -136
  37. package/src/InMemoryTable.res.mjs +57 -86
  38. package/src/Internal.res +54 -5
  39. package/src/Internal.res.mjs +2 -8
  40. package/src/LazyLoader.res +2 -2
  41. package/src/LazyLoader.res.mjs +3 -3
  42. package/src/LoadLayer.res +47 -60
  43. package/src/LoadLayer.res.mjs +28 -50
  44. package/src/LoadLayer.resi +2 -5
  45. package/src/LogSelection.res +4 -4
  46. package/src/LogSelection.res.mjs +5 -7
  47. package/src/Logging.res +1 -1
  48. package/src/Main.res +61 -2
  49. package/src/Main.res.mjs +37 -1
  50. package/src/Persistence.res +3 -16
  51. package/src/PgStorage.res +125 -114
  52. package/src/PgStorage.res.mjs +112 -95
  53. package/src/Ports.res +5 -0
  54. package/src/Ports.res.mjs +9 -0
  55. package/src/Prometheus.res +3 -3
  56. package/src/Prometheus.res.mjs +4 -4
  57. package/src/ReorgDetection.res +4 -4
  58. package/src/ReorgDetection.res.mjs +4 -5
  59. package/src/SafeCheckpointTracking.res +16 -16
  60. package/src/SafeCheckpointTracking.res.mjs +2 -2
  61. package/src/SimulateItems.res +10 -14
  62. package/src/SimulateItems.res.mjs +5 -2
  63. package/src/Sink.res +1 -1
  64. package/src/Sink.res.mjs +1 -2
  65. package/src/SvmTypes.res +9 -0
  66. package/src/SvmTypes.res.mjs +14 -0
  67. package/src/TestIndexer.res +17 -57
  68. package/src/TestIndexer.res.mjs +14 -48
  69. package/src/TestIndexerProxyStorage.res +23 -23
  70. package/src/TestIndexerProxyStorage.res.mjs +12 -15
  71. package/src/Throttler.res +2 -2
  72. package/src/Time.res +2 -2
  73. package/src/Time.res.mjs +2 -2
  74. package/src/UserContext.res +19 -118
  75. package/src/UserContext.res.mjs +10 -66
  76. package/src/Utils.res +15 -15
  77. package/src/Utils.res.mjs +7 -8
  78. package/src/adapters/MarkBatchProcessedAdapter.res +5 -0
  79. package/src/adapters/MarkBatchProcessedAdapter.res.mjs +14 -0
  80. package/src/bindings/BigDecimal.res +1 -1
  81. package/src/bindings/BigDecimal.res.mjs +2 -2
  82. package/src/bindings/ClickHouse.res +8 -6
  83. package/src/bindings/ClickHouse.res.mjs +5 -5
  84. package/src/bindings/Hrtime.res +1 -1
  85. package/src/bindings/Pino.res +2 -2
  86. package/src/bindings/Pino.res.mjs +3 -4
  87. package/src/db/EntityFilter.res +410 -0
  88. package/src/db/EntityFilter.res.mjs +424 -0
  89. package/src/db/EntityHistory.res +1 -1
  90. package/src/db/EntityHistory.res.mjs +1 -1
  91. package/src/db/InternalTable.res +10 -10
  92. package/src/db/InternalTable.res.mjs +41 -45
  93. package/src/db/Schema.res +2 -2
  94. package/src/db/Schema.res.mjs +3 -3
  95. package/src/db/Table.res +106 -22
  96. package/src/db/Table.res.mjs +84 -35
  97. package/src/sources/EventRouter.res +67 -2
  98. package/src/sources/EventRouter.res.mjs +45 -3
  99. package/src/sources/Evm.res +0 -7
  100. package/src/sources/Evm.res.mjs +0 -15
  101. package/src/sources/EvmChain.res +1 -1
  102. package/src/sources/EvmChain.res.mjs +1 -2
  103. package/src/sources/EvmRpcClient.res +42 -0
  104. package/src/sources/EvmRpcClient.res.mjs +64 -0
  105. package/src/sources/Fuel.res +0 -7
  106. package/src/sources/Fuel.res.mjs +0 -15
  107. package/src/sources/HyperFuelSource.res +5 -4
  108. package/src/sources/HyperFuelSource.res.mjs +2 -2
  109. package/src/sources/HyperSyncClient.res +9 -5
  110. package/src/sources/HyperSyncClient.res.mjs +2 -2
  111. package/src/sources/HyperSyncHeightStream.res +2 -2
  112. package/src/sources/HyperSyncHeightStream.res.mjs +2 -2
  113. package/src/sources/HyperSyncSource.res +10 -9
  114. package/src/sources/HyperSyncSource.res.mjs +4 -4
  115. package/src/sources/Rpc.res +1 -5
  116. package/src/sources/Rpc.res.mjs +1 -9
  117. package/src/sources/RpcSource.res +57 -21
  118. package/src/sources/RpcSource.res.mjs +47 -20
  119. package/src/sources/RpcWebSocketHeightStream.res +1 -1
  120. package/src/sources/SourceManager.res +3 -2
  121. package/src/sources/SourceManager.res.mjs +1 -1
  122. package/src/sources/Svm.res +3 -10
  123. package/src/sources/Svm.res.mjs +4 -18
  124. package/src/sources/SvmHyperSyncClient.res +265 -0
  125. package/src/sources/SvmHyperSyncClient.res.mjs +28 -0
  126. package/src/sources/SvmHyperSyncSource.res +638 -0
  127. package/src/sources/SvmHyperSyncSource.res.mjs +557 -0
  128. package/src/tui/Tui.res +9 -2
  129. package/src/tui/Tui.res.mjs +18 -3
  130. package/src/tui/components/BufferedProgressBar.res +2 -2
  131. package/src/tui/components/TuiData.res +3 -0
  132. package/svm.schema.json +523 -14
  133. package/src/TableIndices.res +0 -115
  134. package/src/TableIndices.res.mjs +0 -144
@@ -0,0 +1,638 @@
1
+ open Source
2
+
3
+ type options = {
4
+ chain: ChainMap.Chain.t,
5
+ endpointUrl: string,
6
+ apiToken: option<string>,
7
+ eventConfigs: array<Internal.svmInstructionEventConfig>,
8
+ clientTimeoutMillis: int,
9
+ }
10
+
11
+ // Build HyperSync InstructionSelections from event configs. Each AND-group in
12
+ // `cfg.accountFilters` becomes its own selection; selections sharing the same
13
+ // `(programId, dN)` are OR-ed by the wire protocol. Empty outer array emits
14
+ // one selection with no `aN` set (no account filtering).
15
+ //
16
+ // Empty programId means the config carries no real program (placeholder), in
17
+ // which case we skip — better to over-fetch nothing than ship a degenerate
18
+ // query.
19
+ let buildInstructionSelections = (eventConfigs: array<Internal.svmInstructionEventConfig>): array<
20
+ SvmHyperSyncClient.QueryTypes.instructionSelection,
21
+ > => {
22
+ eventConfigs->Array.flatMap(cfg => {
23
+ let programIdString = cfg.programId->SvmTypes.Pubkey.toString
24
+ if programIdString === "" {
25
+ []
26
+ } else {
27
+ // Each instruction owns exactly one dN field — the one matching its
28
+ // declared byte length. The server-side filter is `d{N} IN [..]`.
29
+ let (d1, d2, d4, d8) = switch (cfg.discriminator, cfg.discriminatorByteLen) {
30
+ | (Some(d), 1) => (Some([d]), None, None, None)
31
+ | (Some(d), 2) => (None, Some([d]), None, None)
32
+ | (Some(d), 4) => (None, None, Some([d]), None)
33
+ | (Some(d), 8) => (None, None, None, Some([d]))
34
+ | _ => (None, None, None, None)
35
+ }
36
+ let groups = switch cfg.accountFilters {
37
+ | [] => [[]]
38
+ | gs => gs
39
+ }
40
+ groups->Array.map(group => {
41
+ let pick = position =>
42
+ group
43
+ ->Array.filterMap(
44
+ f => f.position == position ? Some(f.values->SvmTypes.Pubkey.toStrings) : None,
45
+ )
46
+ ->Array.get(0)
47
+
48
+ (
49
+ {
50
+ programId: [programIdString],
51
+ ?d1,
52
+ ?d2,
53
+ ?d4,
54
+ ?d8,
55
+ a0: ?pick(0),
56
+ a1: ?pick(1),
57
+ a2: ?pick(2),
58
+ a3: ?pick(3),
59
+ a4: ?pick(4),
60
+ a5: ?pick(5),
61
+ isInner: ?cfg.isInner,
62
+ }: SvmHyperSyncClient.QueryTypes.instructionSelection
63
+ )
64
+ })
65
+ }
66
+ })
67
+ }
68
+
69
+ // Synthesize a stable logIndex for an SVM instruction so the FetchState
70
+ // ordering machinery (which compares by `(blockNumber, logIndex)`) sorts
71
+ // instructions deterministically within a slot. The bit packing fits inside
72
+ // JS's 53-bit safe-integer range: transactionIndex ≤ ~10k per slot,
73
+ // instruction position ≤ 1000 per tx, depth ≤ ~10. Outer-only instructions
74
+ // land at `tx * 65536`; inner ones append depth-weighted offsets.
75
+ let synthLogIndex = (instr: SvmHyperSyncClient.ResponseTypes.instruction) => {
76
+ let tx = instr.transactionIndex
77
+ let addrSum = instr.instructionAddress->Array.reduce(0, (acc, n) => acc * 1024 + n + 1)
78
+ tx * 65536 + addrSum
79
+ }
80
+
81
+ let serializeInstructionAddress = (addr: array<int>) =>
82
+ addr->Array.map(n => n->Int.toString)->Array.joinUnsafe(",")
83
+
84
+ // Build per-program schema descriptors by grouping eventConfigs by programId,
85
+ // returning one descriptor JSON per program. These are handed to the Solana
86
+ // client at creation; it builds them into decoders and decodes matching
87
+ // instructions inline on `get`.
88
+ let buildProgramSchemas = (eventConfigs: array<Internal.svmInstructionEventConfig>): array<
89
+ string,
90
+ > => {
91
+ // Group by programId base58 string. Skip events that carry no schema
92
+ // (accounts == [] && args is JSON.Null && definedTypes is JSON.Null —
93
+ // the resolved-empty case from system_config.rs).
94
+ let descriptorsByProgram: dict<{
95
+ "programId": string,
96
+ "definedTypes": JSON.t,
97
+ "instructions": array<{
98
+ "name": string,
99
+ "discriminator": string,
100
+ "accounts": array<string>,
101
+ "args": JSON.t,
102
+ }>,
103
+ }> = Dict.make()
104
+
105
+ eventConfigs->Array.forEach(ec => {
106
+ let programIdString = ec.programId->SvmTypes.Pubkey.toString
107
+ if programIdString === "" {
108
+ // Stage 4 placeholder pattern: skip empty program ids.
109
+ ()
110
+ } else {
111
+ let hasSchema = ec.accounts->Array.length > 0 || ec.args !== JSON.Null
112
+ let discriminator = ec.discriminator->Option.getOr("")
113
+ if hasSchema && discriminator !== "" {
114
+ // Inline-schema programs declare no custom types, so `definedTypes`
115
+ // arrives as JSON.Null; the Rust descriptor's `#[serde(default)]` only
116
+ // covers an absent field, not an explicit null, so coalesce here.
117
+ let definedTypes = switch ec.definedTypes {
118
+ | JSON.Null => JSON.Object(Dict.make())
119
+ | other => other
120
+ }
121
+ let existing = descriptorsByProgram->Dict.get(programIdString)
122
+ let descriptor = switch existing {
123
+ | Some(d) => d
124
+ | None => {
125
+ "programId": programIdString,
126
+ "definedTypes": definedTypes,
127
+ "instructions": [],
128
+ }
129
+ }
130
+ let instruction = {
131
+ "name": ec.name,
132
+ "discriminator": discriminator,
133
+ "accounts": ec.accounts,
134
+ "args": ec.args,
135
+ }
136
+ descriptorsByProgram->Dict.set(
137
+ programIdString,
138
+ {
139
+ "programId": descriptor["programId"],
140
+ "definedTypes": descriptor["definedTypes"],
141
+ "instructions": descriptor["instructions"]->Array.concat([instruction]),
142
+ },
143
+ )
144
+ }
145
+ }
146
+ })
147
+
148
+ descriptorsByProgram
149
+ ->Dict.valuesToArray
150
+ ->Array.map(descriptor =>
151
+ descriptor
152
+ ->(Utils.magic: {
153
+ "programId": string,
154
+ "definedTypes": JSON.t,
155
+ "instructions": array<{
156
+ "name": string,
157
+ "discriminator": string,
158
+ "accounts": array<string>,
159
+ "args": JSON.t,
160
+ }>,
161
+ } => JSON.t)
162
+ ->JSON.stringify
163
+ )
164
+ }
165
+
166
+ // Parse the Rust-decoded instruction (args/accounts arrive as JSON strings to
167
+ // side-step napi-rs's lack of native JSON passthrough) into the public shape.
168
+ let parseDecoded = (
169
+ d: SvmHyperSyncClient.ResponseTypes.decodedInstruction,
170
+ ): Envio.svmInstructionParams => {
171
+ let args = try JSON.parseOrThrow(d.argsJson) catch {
172
+ | _ => JSON.Object(Dict.make())
173
+ }
174
+ let accounts = try {
175
+ JSON.parseOrThrow(d.accountsJson)->(Utils.magic: JSON.t => dict<string>)
176
+ } catch {
177
+ | _ => Dict.make()
178
+ }
179
+ {
180
+ name: d.name,
181
+ args,
182
+ accounts,
183
+ extraAccounts: d.extraAccounts,
184
+ }
185
+ }
186
+
187
+ let toSvmInstruction = (
188
+ instr: SvmHyperSyncClient.ResponseTypes.instruction,
189
+ ~programName,
190
+ ~instructionName,
191
+ ~transaction,
192
+ ~logs,
193
+ ~block,
194
+ ): Envio.svmInstruction => {
195
+ programName,
196
+ instructionName,
197
+ programId: instr.programId->SvmTypes.Pubkey.fromStringUnsafe,
198
+ data: instr.data,
199
+ accounts: instr.accounts->SvmTypes.Pubkey.fromStringsUnsafe,
200
+ instructionAddress: instr.instructionAddress,
201
+ isInner: instr.isInner,
202
+ d1: ?instr.d1,
203
+ d2: ?instr.d2,
204
+ d4: ?instr.d4,
205
+ d8: ?instr.d8,
206
+ params: ?(instr.decoded->Option.map(parseDecoded)),
207
+ ?transaction,
208
+ ?logs,
209
+ block,
210
+ }
211
+
212
+ let toSvmTransaction = (tx: SvmHyperSyncClient.ResponseTypes.transaction): Envio.svmTransaction => {
213
+ signatures: tx.signatures,
214
+ accountKeys: tx.accountKeys->SvmTypes.Pubkey.fromStringsUnsafe,
215
+ feePayer: ?(tx.feePayer->Option.map(SvmTypes.Pubkey.fromStringUnsafe)),
216
+ success: ?tx.success,
217
+ err: ?tx.err,
218
+ // u64 lamports / compute units arrive as `int` over napi. Convert to
219
+ // `bigint` so the public type stays defensible even for pathological values.
220
+ fee: ?(tx.fee->Option.map(BigInt.fromInt)),
221
+ computeUnitsConsumed: ?(tx.computeUnitsConsumed->Option.map(BigInt.fromInt)),
222
+ recentBlockhash: ?tx.recentBlockhash,
223
+ version: ?tx.version,
224
+ }
225
+
226
+ let toSvmTokenBalance = (
227
+ tb: SvmHyperSyncClient.ResponseTypes.tokenBalance,
228
+ ): Envio.svmTokenBalance => {
229
+ account: ?(tb.account->Option.map(SvmTypes.Pubkey.fromStringUnsafe)),
230
+ mint: ?(tb.mint->Option.map(SvmTypes.Pubkey.fromStringUnsafe)),
231
+ owner: ?(tb.owner->Option.map(SvmTypes.Pubkey.fromStringUnsafe)),
232
+ preAmount: ?tb.preAmount,
233
+ postAmount: ?tb.postAmount,
234
+ }
235
+
236
+ // Probe the discriminator byte-length ordering longest-first. Stops at the
237
+ // first router hit. Falls back to the `_none` key (program-wide handler) when
238
+ // no discriminator-keyed handler matches.
239
+ let probeRouter = (
240
+ router: EventRouter.t<Internal.svmInstructionEventConfig>,
241
+ programId: SvmTypes.Pubkey.t,
242
+ instr: SvmHyperSyncClient.ResponseTypes.instruction,
243
+ byteLengthsDesc: array<int>,
244
+ ~contractAddress,
245
+ ~indexingAddresses,
246
+ ) => {
247
+ let probe = (dN: option<string>) => {
248
+ let tag = EventRouter.getSvmEventId(~programId, ~discriminator=dN)
249
+ router->EventRouter.get(~tag, ~contractAddress, ~blockNumber=instr.slot, ~indexingAddresses)
250
+ }
251
+
252
+ let result = byteLengthsDesc->Array.reduce(None, (acc, len) =>
253
+ switch acc {
254
+ | Some(_) => acc
255
+ | None =>
256
+ let candidate = switch len {
257
+ | 8 => instr.d8
258
+ | 4 => instr.d4
259
+ | 2 => instr.d2
260
+ | 1 => instr.d1
261
+ | _ => None
262
+ }
263
+ switch candidate {
264
+ | Some(_) as d => probe(d)
265
+ | None => None
266
+ }
267
+ }
268
+ )
269
+
270
+ switch result {
271
+ | Some(_) as hit => hit
272
+ | None => probe(None) // program-wide fallback
273
+ }
274
+ }
275
+
276
+ let make = ({chain, endpointUrl, apiToken, eventConfigs, clientTimeoutMillis}: options): t => {
277
+ let name = "SvmHyperSync"
278
+ let chainId = chain->ChainMap.Chain.toChainId
279
+
280
+ // Built once at startup and handed to the client so `get` decodes matching
281
+ // instructions in Rust rather than per-instruction over the napi boundary.
282
+ let programSchemas = buildProgramSchemas(eventConfigs)
283
+ let client = SvmHyperSyncClient.make(
284
+ ~url=endpointUrl,
285
+ ~apiToken?,
286
+ ~httpReqTimeoutMillis=clientTimeoutMillis,
287
+ ~programSchemas=?switch programSchemas {
288
+ | [] => None
289
+ | arr => Some(arr)
290
+ },
291
+ )
292
+
293
+ let (eventRouter, programOrderings) = EventRouter.fromSvmEventConfigsOrThrow(eventConfigs, ~chain)
294
+
295
+ // programId.toString -> sorted-desc byte lengths
296
+ let orderingByProgram = Dict.make()
297
+ programOrderings->Array.forEach(o =>
298
+ orderingByProgram->Dict.set(o.programId->SvmTypes.Pubkey.toString, o.byteLengthsDesc)
299
+ )
300
+
301
+ let needsTransactions = eventConfigs->Array.some(cfg => cfg.includeTransaction)
302
+ let needsLogs = eventConfigs->Array.some(cfg => cfg.includeLogs)
303
+ let needsTokenBalances = eventConfigs->Array.some(cfg => cfg.includeTokenBalances)
304
+
305
+ let getItemsOrThrow = async (
306
+ ~fromBlock,
307
+ ~toBlock,
308
+ ~addressesByContractName as _,
309
+ ~indexingAddresses,
310
+ ~knownHeight,
311
+ ~partitionId as _,
312
+ ~selection as _,
313
+ ~retry,
314
+ ~logger,
315
+ ) => {
316
+ let totalTimeRef = Hrtime.makeTimer()
317
+ let pageFetchRef = Hrtime.makeTimer()
318
+
319
+ let instructionSelections = buildInstructionSelections(eventConfigs)
320
+ // Under the server's default merge mode, requesting a table's columns is
321
+ // what opts the matched result set into that join — a table with an empty
322
+ // field list returns no rows (instructions and blocks are exempt), so each
323
+ // opted-into table needs its columns spelled out here.
324
+ let fields: SvmHyperSyncClient.QueryTypes.fieldSelection = {
325
+ block: [Slot, Blockhash, BlockTime],
326
+ transaction: ?(
327
+ needsTransactions
328
+ ? Some([
329
+ Slot,
330
+ TransactionIndex,
331
+ Signatures,
332
+ FeePayer,
333
+ Success,
334
+ Err,
335
+ Fee,
336
+ ComputeUnitsConsumed,
337
+ AccountKeys,
338
+ RecentBlockhash,
339
+ Version,
340
+ ])
341
+ : None
342
+ ),
343
+ log: ?(needsLogs ? Some([Slot, TransactionIndex, InstructionAddress, Kind, Message]) : None),
344
+ tokenBalance: ?(
345
+ needsTokenBalances
346
+ ? Some([Slot, TransactionIndex, Account, Mint, Owner, PreAmount, PostAmount])
347
+ : None
348
+ ),
349
+ }
350
+ // `toBlock` is inclusive, but `toSlot` is exclusive on the wire — without
351
+ // the +1 a bounded range stalls one slot short of its end.
352
+ let query: SvmHyperSyncClient.query = {
353
+ fromSlot: fromBlock,
354
+ toSlot: ?(toBlock->Option.map(toBlock => toBlock + 1)),
355
+ instructions: instructionSelections,
356
+ fields,
357
+ }
358
+
359
+ Prometheus.SourceRequestCount.increment(~sourceName=name, ~chainId, ~method="getInstructions")
360
+
361
+ let resp = try await client.get(~query) catch {
362
+ | exn =>
363
+ throw(
364
+ Source.GetItemsError(
365
+ Source.FailedGettingItems({
366
+ exn,
367
+ attemptedToBlock: toBlock->Option.getOr(knownHeight),
368
+ retry: WithBackoff({
369
+ message: `Unexpected issue while fetching instructions from SVM HyperSync. Attempt a retry.`,
370
+ backoffMillis: switch retry {
371
+ | 0 => 500
372
+ | _ => 1000 * retry
373
+ },
374
+ }),
375
+ }),
376
+ ),
377
+ )
378
+ }
379
+ let pageFetchTime = pageFetchRef->Hrtime.timeSince->Hrtime.toSecondsFloat
380
+
381
+ let parsingRef = Hrtime.makeTimer()
382
+
383
+ // Per-slot unix timestamp lookup from the response's `blocks` table. Slots
384
+ // without a block row (rare; usually skipped slots) fall back to `None`.
385
+ let blockTimeBySlot = Dict.make()
386
+ resp.data.blocks->Array.forEach(b => {
387
+ switch b.blockTime {
388
+ | Some(t) => blockTimeBySlot->Dict.set(b.slot->Int.toString, t)
389
+ | None => ()
390
+ }
391
+ })
392
+
393
+ // Per (slot, transaction_index) lookup for parent transactions.
394
+ let txByKey = Dict.make()
395
+ resp.data.transactions->Array.forEach(tx => {
396
+ let key = tx.slot->Int.toString ++ ":" ++ tx.transactionIndex->Int.toString
397
+ txByKey->Dict.set(key, tx)
398
+ })
399
+
400
+ // Per (slot, transaction_index, instruction_address) lookup for logs
401
+ // scoped to a single instruction. `instructionAddress: None` logs are
402
+ // attached to no instruction (rare; usually only system messages).
403
+ let logsByKey = Dict.make()
404
+ resp.data.logs->Array.forEach(log => {
405
+ switch (log.transactionIndex, log.instructionAddress) {
406
+ | (Some(txIdx), Some(addr)) =>
407
+ let key =
408
+ log.slot->Int.toString ++
409
+ ":" ++
410
+ txIdx->Int.toString ++
411
+ ":" ++
412
+ serializeInstructionAddress(addr)
413
+ switch logsByKey->Dict.get(key) {
414
+ | Some(existing) => existing->Array.push(log)
415
+ | None => logsByKey->Dict.set(key, [log])
416
+ }
417
+ | _ => ()
418
+ }
419
+ })
420
+
421
+ let tokenBalancesByTx = Dict.make()
422
+ if needsTokenBalances {
423
+ resp.data.tokenBalances->Array.forEach(tb => {
424
+ switch tb.transactionIndex {
425
+ | Some(txIdx) =>
426
+ let key = tb.slot->Int.toString ++ ":" ++ txIdx->Int.toString
427
+ switch tokenBalancesByTx->Dict.get(key) {
428
+ | Some(existing) => existing->Array.push(tb)
429
+ | None => tokenBalancesByTx->Dict.set(key, [tb])
430
+ }
431
+ | None => ()
432
+ }
433
+ })
434
+ }
435
+
436
+ let parsedQueueItems = []
437
+ resp.data.instructions->Array.forEach(instr => {
438
+ let programId = instr.programId->SvmTypes.Pubkey.fromStringUnsafe
439
+ let byteLengths =
440
+ orderingByProgram
441
+ ->Utils.Dict.dangerouslyGetNonOption(instr.programId)
442
+ ->Option.getOr([])
443
+
444
+ let contractAddress = instr.programId->Address.unsafeFromString
445
+ let maybeConfig = probeRouter(
446
+ eventRouter,
447
+ programId,
448
+ instr,
449
+ byteLengths,
450
+ ~contractAddress,
451
+ ~indexingAddresses,
452
+ )
453
+
454
+ switch maybeConfig {
455
+ | None => ()
456
+ | Some(eventConfig) =>
457
+ let txKey = instr.slot->Int.toString ++ ":" ++ instr.transactionIndex->Int.toString
458
+ let maybeTx =
459
+ txByKey->Utils.Dict.dangerouslyGetNonOption(txKey)->Option.map(toSvmTransaction)
460
+ let maybeTx = if eventConfig.includeTokenBalances {
461
+ maybeTx->Option.map(tx => {
462
+ let maybeBalances =
463
+ tokenBalancesByTx
464
+ ->Utils.Dict.dangerouslyGetNonOption(txKey)
465
+ ->Option.map(bals => bals->Array.map(toSvmTokenBalance))
466
+ {...tx, tokenBalances: ?maybeBalances}
467
+ })
468
+ } else {
469
+ maybeTx
470
+ }
471
+ let logKey =
472
+ instr.slot->Int.toString ++
473
+ ":" ++
474
+ instr.transactionIndex->Int.toString ++
475
+ ":" ++
476
+ serializeInstructionAddress(instr.instructionAddress)
477
+ let maybeLogs =
478
+ logsByKey
479
+ ->Utils.Dict.dangerouslyGetNonOption(logKey)
480
+ ->Option.map(logs =>
481
+ logs->Array.map(
482
+ (log): Envio.svmLog => {
483
+ kind: log.kind->Option.getOr(""),
484
+ message: log.message->Option.getOr(""),
485
+ },
486
+ )
487
+ )
488
+
489
+ let slotKey = instr.slot->Int.toString
490
+ let blockTime = blockTimeBySlot->Utils.Dict.dangerouslyGetNonOption(slotKey)
491
+ let payload = toSvmInstruction(
492
+ instr,
493
+ ~programName=eventConfig.contractName,
494
+ ~instructionName=eventConfig.name,
495
+ ~transaction=eventConfig.includeTransaction ? maybeTx : None,
496
+ ~logs=eventConfig.includeLogs ? maybeLogs : None,
497
+ ~block={
498
+ slot: instr.slot,
499
+ time: blockTime->Option.getOr(0),
500
+ hash: "",
501
+ },
502
+ )
503
+
504
+ parsedQueueItems
505
+ ->Array.push(
506
+ Internal.Event({
507
+ eventConfig: (eventConfig :> Internal.eventConfig),
508
+ timestamp: blockTime->Option.getOr(0),
509
+ chain,
510
+ blockNumber: instr.slot,
511
+ blockHash: "",
512
+ logIndex: synthLogIndex(instr),
513
+ event: payload->(Utils.magic: Envio.svmInstruction => Internal.event),
514
+ }),
515
+ )
516
+ ->ignore
517
+ }
518
+
519
+ let _ = logger
520
+ })
521
+
522
+ let parsingTimeElapsed = parsingRef->Hrtime.timeSince->Hrtime.toSecondsFloat
523
+ let highestSlot = resp.nextSlot - 1
524
+ let latestBlockTime =
525
+ blockTimeBySlot
526
+ ->Utils.Dict.dangerouslyGetNonOption(highestSlot->Int.toString)
527
+ ->Option.getOr(0)
528
+
529
+ // Best-effort (slot, blockhash) pairs from the blocks the server returned
530
+ // for this range. Gaps (skipped slots, or slots without matched data) are
531
+ // fine — reorg detection only compares hashes for slots it has observed.
532
+ let blockHashes = resp.data.blocks->Array.map((b): ReorgDetection.blockData => {
533
+ blockNumber: b.slot,
534
+ blockHash: b.blockhash,
535
+ })
536
+
537
+ let totalTimeElapsed = totalTimeRef->Hrtime.timeSince->Hrtime.toSecondsFloat
538
+
539
+ {
540
+ latestFetchedBlockTimestamp: latestBlockTime,
541
+ parsedQueueItems,
542
+ latestFetchedBlockNumber: highestSlot,
543
+ stats: {totalTimeElapsed, parsingTimeElapsed, pageFetchTime},
544
+ knownHeight,
545
+ blockHashes,
546
+ fromBlockQueried: fromBlock,
547
+ }
548
+ }
549
+
550
+ // Fetch (slot, blockhash, blockTime) for blocks in an inclusive slot range,
551
+ // paginating on the server's `nextSlot` cursor. `toSlot` is exclusive on the
552
+ // wire, so we request `maxSlot + 1`; the caller filters to the exact slots.
553
+ let queryBlockDataRange = async (~fromSlot, ~toSlot) => {
554
+ let blockDatas = []
555
+ let fromRef = ref(fromSlot)
556
+ let keepGoing = ref(true)
557
+ while keepGoing.contents {
558
+ let query: SvmHyperSyncClient.query = {
559
+ fromSlot: fromRef.contents,
560
+ toSlot: toSlot + 1,
561
+ includeAllBlocks: true,
562
+ fields: {block: [Slot, Blockhash, BlockTime]},
563
+ maxNumBlocks: 1000,
564
+ }
565
+ Prometheus.SourceRequestCount.increment(~sourceName=name, ~chainId, ~method="getBlockHashes")
566
+ let resp = await client.get(~query)
567
+ resp.data.blocks->Array.forEach(b =>
568
+ blockDatas
569
+ ->Array.push({
570
+ ReorgDetection.blockNumber: b.slot,
571
+ blockHash: b.blockhash,
572
+ blockTimestamp: b.blockTime->Option.getOr(0),
573
+ })
574
+ ->ignore
575
+ )
576
+
577
+ // `nextSlot` is the (exclusive) resume cursor. Stop once it passes the
578
+ // range, or fails to advance — the latter guards against an infinite loop.
579
+ if resp.nextSlot > toSlot || resp.nextSlot <= fromRef.contents {
580
+ keepGoing := false
581
+ } else {
582
+ fromRef := resp.nextSlot
583
+ }
584
+ }
585
+ blockDatas
586
+ }
587
+
588
+ let getBlockHashes = async (~blockNumbers, ~logger as _) =>
589
+ switch blockNumbers->Array.get(0) {
590
+ | None => Ok([])
591
+ | Some(firstSlot) =>
592
+ try {
593
+ let minSlot = ref(firstSlot)
594
+ let maxSlot = ref(firstSlot)
595
+ let requested = Utils.Set.make()
596
+ blockNumbers->Array.forEach(slot => {
597
+ if slot < minSlot.contents {
598
+ minSlot := slot
599
+ }
600
+ if slot > maxSlot.contents {
601
+ maxSlot := slot
602
+ }
603
+ requested->Utils.Set.add(slot)->ignore
604
+ })
605
+ let blockDatas = await queryBlockDataRange(
606
+ ~fromSlot=minSlot.contents,
607
+ ~toSlot=maxSlot.contents,
608
+ )
609
+ // Keep one entry per requested slot; drop duplicates and unrelated slots.
610
+ Ok(blockDatas->Array.filter(data => requested->Utils.Set.delete(data.blockNumber)))
611
+ } catch {
612
+ | exn => Error(exn)
613
+ }
614
+ }
615
+
616
+ {
617
+ name,
618
+ sourceFor: Sync,
619
+ chain,
620
+ pollingInterval: 1000,
621
+ poweredByHyperSync: true,
622
+ getBlockHashes,
623
+ getHeightOrThrow: async () => {
624
+ let timer = Hrtime.makeTimer()
625
+ let h = await client.getHeight()
626
+ let seconds = timer->Hrtime.timeSince->Hrtime.toSecondsFloat
627
+ Prometheus.SourceRequestCount.increment(~sourceName=name, ~chainId, ~method="getHeight")
628
+ Prometheus.SourceRequestCount.addSeconds(
629
+ ~sourceName=name,
630
+ ~chainId,
631
+ ~method="getHeight",
632
+ ~seconds,
633
+ )
634
+ h
635
+ },
636
+ getItemsOrThrow,
637
+ }
638
+ }