envio 3.0.1 → 3.0.2-svm-alpha.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 (41) hide show
  1. package/evm.schema.json +8 -8
  2. package/fuel.schema.json +12 -12
  3. package/index.d.ts +155 -1
  4. package/package.json +6 -7
  5. package/src/ChainFetcher.res +25 -1
  6. package/src/ChainFetcher.res.mjs +19 -1
  7. package/src/Config.res +156 -94
  8. package/src/Config.res.mjs +60 -97
  9. package/src/Core.res +32 -0
  10. package/src/Env.res.mjs +1 -2
  11. package/src/Envio.res +94 -0
  12. package/src/EventConfigBuilder.res +50 -0
  13. package/src/EventConfigBuilder.res.mjs +31 -0
  14. package/src/HandlerLoader.res +12 -1
  15. package/src/HandlerLoader.res.mjs +6 -1
  16. package/src/Internal.res +38 -0
  17. package/src/Main.res +53 -3
  18. package/src/Main.res.mjs +34 -2
  19. package/src/Persistence.res +2 -17
  20. package/src/Persistence.res.mjs +2 -14
  21. package/src/SimulateItems.res +23 -10
  22. package/src/SimulateItems.res.mjs +21 -6
  23. package/src/SvmTypes.res +9 -0
  24. package/src/SvmTypes.res.mjs +14 -0
  25. package/src/sources/EventRouter.res +65 -0
  26. package/src/sources/EventRouter.res.mjs +43 -0
  27. package/src/sources/HyperSyncClient.res +30 -157
  28. package/src/sources/HyperSyncClient.res.mjs +20 -6
  29. package/src/sources/HyperSyncSolanaClient.res +227 -0
  30. package/src/sources/HyperSyncSolanaClient.res.mjs +25 -0
  31. package/src/sources/HyperSyncSolanaSource.res +515 -0
  32. package/src/sources/HyperSyncSolanaSource.res.mjs +441 -0
  33. package/src/sources/HyperSyncSource.res +5 -8
  34. package/src/sources/HyperSyncSource.res.mjs +1 -8
  35. package/src/sources/RpcSource.res.mjs +1 -1
  36. package/src/sources/Svm.res +2 -2
  37. package/src/sources/Svm.res.mjs +3 -2
  38. package/src/tui/Tui.res +9 -2
  39. package/src/tui/Tui.res.mjs +19 -4
  40. package/src/tui/components/TuiData.res +3 -0
  41. package/svm.schema.json +345 -4
@@ -0,0 +1,515 @@
1
+ open Source
2
+
3
+ exception EventRoutingFailed
4
+
5
+ type options = {
6
+ chain: ChainMap.Chain.t,
7
+ endpointUrl: string,
8
+ apiToken: option<string>,
9
+ eventConfigs: array<Internal.svmInstructionEventConfig>,
10
+ clientMaxRetries: int,
11
+ clientTimeoutMillis: int,
12
+ }
13
+
14
+ // Build one HyperSync InstructionSelection per (programId, discriminator) pair.
15
+ // Empty programId means the config carries no real program (placeholder), in
16
+ // which case we skip — better to over-fetch nothing than ship a degenerate
17
+ // query.
18
+ let buildInstructionSelections = (
19
+ eventConfigs: array<Internal.svmInstructionEventConfig>,
20
+ ): array<HyperSyncSolanaClient.QueryTypes.instructionSelection> => {
21
+ eventConfigs->Belt.Array.keepMap(cfg => {
22
+ let programIdString = cfg.programId->SvmTypes.Pubkey.toString
23
+ if programIdString === "" {
24
+ None
25
+ } else {
26
+ // Each instruction owns exactly one dN field — the one matching its
27
+ // declared byte length. The server-side filter is `d{N} IN [..]`.
28
+ let (d1, d2, d4, d8) = switch (cfg.discriminator, cfg.discriminatorByteLen) {
29
+ | (Some(d), 1) => (Some([d]), None, None, None)
30
+ | (Some(d), 2) => (None, Some([d]), None, None)
31
+ | (Some(d), 4) => (None, None, Some([d]), None)
32
+ | (Some(d), 8) => (None, None, None, Some([d]))
33
+ | _ => (None, None, None, None)
34
+ }
35
+ let accountFilters = cfg.accountFilters
36
+ let a0 = accountFilters->Belt.Array.keepMap(f =>
37
+ f.position == 0 ? Some(f.values->SvmTypes.Pubkey.toStrings) : None
38
+ )->Belt.Array.get(0)
39
+ let a1 = accountFilters->Belt.Array.keepMap(f =>
40
+ f.position == 1 ? Some(f.values->SvmTypes.Pubkey.toStrings) : None
41
+ )->Belt.Array.get(0)
42
+ let a2 = accountFilters->Belt.Array.keepMap(f =>
43
+ f.position == 2 ? Some(f.values->SvmTypes.Pubkey.toStrings) : None
44
+ )->Belt.Array.get(0)
45
+ let a3 = accountFilters->Belt.Array.keepMap(f =>
46
+ f.position == 3 ? Some(f.values->SvmTypes.Pubkey.toStrings) : None
47
+ )->Belt.Array.get(0)
48
+ let a4 = accountFilters->Belt.Array.keepMap(f =>
49
+ f.position == 4 ? Some(f.values->SvmTypes.Pubkey.toStrings) : None
50
+ )->Belt.Array.get(0)
51
+ let a5 = accountFilters->Belt.Array.keepMap(f =>
52
+ f.position == 5 ? Some(f.values->SvmTypes.Pubkey.toStrings) : None
53
+ )->Belt.Array.get(0)
54
+ Some(
55
+ (
56
+ {
57
+ programId: [programIdString],
58
+ ?d1,
59
+ ?d2,
60
+ ?d4,
61
+ ?d8,
62
+ ?a0,
63
+ ?a1,
64
+ ?a2,
65
+ ?a3,
66
+ ?a4,
67
+ ?a5,
68
+ isInner: ?cfg.isInner,
69
+ includeTransaction: cfg.includeTransaction,
70
+ includeLogs: cfg.includeLogs,
71
+ }: HyperSyncSolanaClient.QueryTypes.instructionSelection
72
+ ),
73
+ )
74
+ }
75
+ })
76
+ }
77
+
78
+ // Synthesize a stable logIndex for an SVM instruction so the FetchState
79
+ // ordering machinery (which compares by `(blockNumber, logIndex)`) sorts
80
+ // instructions deterministically within a slot. The bit packing fits inside
81
+ // JS's 53-bit safe-integer range: transactionIndex ≤ ~10k per slot,
82
+ // instruction position ≤ 1000 per tx, depth ≤ ~10. Outer-only instructions
83
+ // land at `tx * 65536`; inner ones append depth-weighted offsets.
84
+ let synthLogIndex = (instr: HyperSyncSolanaClient.ResponseTypes.instruction) => {
85
+ let tx = instr.transactionIndex
86
+ let addrSum = instr.instructionAddress->Belt.Array.reduce(0, (acc, n) => acc * 1024 + n + 1)
87
+ tx * 65536 + addrSum
88
+ }
89
+
90
+ let serializeInstructionAddress = (addr: array<int>) =>
91
+ addr->Array.map(n => n->Int.toString)->Array.joinUnsafe(",")
92
+
93
+ // Build per-program schema descriptors by grouping eventConfigs by programId.
94
+ // One `addon.registerProgramSchema` call per program at startup; the returned
95
+ // handle goes into `schemaHandlesByProgram` and gets reused for every
96
+ // matching instruction we decode.
97
+ let buildSchemaHandles = (
98
+ eventConfigs: array<Internal.svmInstructionEventConfig>,
99
+ ): dict<int> => {
100
+ // Group by programId base58 string. Skip events that carry no schema
101
+ // (accounts == [] && args is JSON.Null && definedTypes is JSON.Null —
102
+ // the resolved-empty case from system_config.rs).
103
+ let descriptorsByProgram: dict<{
104
+ "programId": string,
105
+ "definedTypes": JSON.t,
106
+ "instructions": array<{
107
+ "name": string,
108
+ "discriminator": string,
109
+ "accounts": array<string>,
110
+ "args": JSON.t,
111
+ }>,
112
+ }> = Dict.make()
113
+
114
+ eventConfigs->Belt.Array.forEach(ec => {
115
+ let programIdString = ec.programId->SvmTypes.Pubkey.toString
116
+ if programIdString === "" {
117
+ // Stage 4 placeholder pattern: skip empty program ids.
118
+ ()
119
+ } else {
120
+ let hasSchema = ec.accounts->Array.length > 0 || ec.args !== JSON.Null
121
+ let discriminator = ec.discriminator->Option.getOr("")
122
+ if hasSchema && discriminator !== "" {
123
+ let existing = descriptorsByProgram->Dict.get(programIdString)
124
+ let descriptor = switch existing {
125
+ | Some(d) => d
126
+ | None => {
127
+ "programId": programIdString,
128
+ "definedTypes": ec.definedTypes,
129
+ "instructions": [],
130
+ }
131
+ }
132
+ let instruction = {
133
+ "name": ec.name,
134
+ "discriminator": discriminator,
135
+ "accounts": ec.accounts,
136
+ "args": ec.args,
137
+ }
138
+ descriptorsByProgram->Dict.set(
139
+ programIdString,
140
+ {
141
+ "programId": descriptor["programId"],
142
+ "definedTypes": descriptor["definedTypes"],
143
+ "instructions": descriptor["instructions"]->Array.concat([instruction]),
144
+ },
145
+ )
146
+ }
147
+ }
148
+ })
149
+
150
+ let handles = Dict.make()
151
+ descriptorsByProgram
152
+ ->Dict.toArray
153
+ ->Belt.Array.forEach(((programIdString, descriptor)) => {
154
+ let json = descriptor->(Utils.magic: _ => JSON.t)->JSON.stringify
155
+ let handle = Core.getAddon().registerProgramSchema(~descriptorJson=json)
156
+ handles->Dict.set(programIdString, handle)
157
+ })
158
+ handles
159
+ }
160
+
161
+ let decodeIfPossible = (
162
+ instr: HyperSyncSolanaClient.ResponseTypes.instruction,
163
+ schemaHandlesByProgram: dict<int>,
164
+ ): option<Envio.svmDecodedInstruction> => {
165
+ switch schemaHandlesByProgram->Dict.get(instr.programId) {
166
+ | None => None
167
+ | Some(handle) =>
168
+ let decoded = Core.getAddon().decodeInstruction(
169
+ ~schemaHandle=handle,
170
+ ~dataHex=instr.data,
171
+ ~accounts=instr.accounts,
172
+ )
173
+ switch decoded->Null.toOption {
174
+ | None => None
175
+ | Some(d) =>
176
+ let args = try JSON.parseOrThrow(d.argsJson) catch {
177
+ | _ => JSON.Object(Dict.make())
178
+ }
179
+ let accounts = try {
180
+ let parsed = JSON.parseOrThrow(d.accountsJson)
181
+ parsed->(Utils.magic: JSON.t => dict<string>)
182
+ } catch {
183
+ | _ => Dict.make()
184
+ }
185
+ Some({
186
+ name: d.name,
187
+ args,
188
+ accounts,
189
+ extraAccounts: d.extraAccounts,
190
+ })
191
+ }
192
+ }
193
+ }
194
+
195
+ let toSvmInstruction = (
196
+ instr: HyperSyncSolanaClient.ResponseTypes.instruction,
197
+ ~schemaHandlesByProgram: dict<int>,
198
+ ): Envio.svmInstruction => {
199
+ programId: instr.programId->SvmTypes.Pubkey.fromStringUnsafe,
200
+ data: instr.data,
201
+ accounts: instr.accounts->SvmTypes.Pubkey.fromStringsUnsafe,
202
+ instructionAddress: instr.instructionAddress,
203
+ isInner: instr.isInner,
204
+ d1: ?instr.d1,
205
+ d2: ?instr.d2,
206
+ d4: ?instr.d4,
207
+ d8: ?instr.d8,
208
+ decoded: ?decodeIfPossible(instr, schemaHandlesByProgram),
209
+ }
210
+
211
+ let toSvmTransaction = (
212
+ tx: HyperSyncSolanaClient.ResponseTypes.transaction,
213
+ ): Envio.svmTransaction => {
214
+ signatures: tx.signatures,
215
+ accountKeys: tx.accountKeys->SvmTypes.Pubkey.fromStringsUnsafe,
216
+ feePayer: ?tx.feePayer->Option.map(SvmTypes.Pubkey.fromStringUnsafe),
217
+ success: ?tx.success,
218
+ err: ?tx.err,
219
+ // u64 lamports / compute units arrive as `int` over napi. Convert to
220
+ // `bigint` so the public type stays defensible even for pathological values.
221
+ fee: ?tx.fee->Option.map(BigInt.fromInt),
222
+ computeUnitsConsumed: ?tx.computeUnitsConsumed->Option.map(BigInt.fromInt),
223
+ recentBlockhash: ?tx.recentBlockhash,
224
+ version: ?tx.version,
225
+ }
226
+
227
+ // Probe the discriminator byte-length ordering longest-first. Stops at the
228
+ // first router hit. Falls back to the `_none` key (program-wide handler) when
229
+ // no discriminator-keyed handler matches.
230
+ let probeRouter = (
231
+ router: EventRouter.t<Internal.svmInstructionEventConfig>,
232
+ programId: SvmTypes.Pubkey.t,
233
+ instr: HyperSyncSolanaClient.ResponseTypes.instruction,
234
+ byteLengthsDesc: array<int>,
235
+ ~contractAddress,
236
+ ~indexingAddresses,
237
+ ) => {
238
+ let probe = (dN: option<string>) => {
239
+ let tag = EventRouter.getSvmEventId(~programId, ~discriminator=dN)
240
+ router->EventRouter.get(
241
+ ~tag,
242
+ ~contractAddress,
243
+ ~blockNumber=instr.slot,
244
+ ~indexingAddresses,
245
+ )
246
+ }
247
+
248
+ let result = byteLengthsDesc->Belt.Array.reduce(None, (acc, len) =>
249
+ switch acc {
250
+ | Some(_) => acc
251
+ | None =>
252
+ let candidate = switch len {
253
+ | 8 => instr.d8
254
+ | 4 => instr.d4
255
+ | 2 => instr.d2
256
+ | 1 => instr.d1
257
+ | _ => None
258
+ }
259
+ switch candidate {
260
+ | Some(_) as d => probe(d)
261
+ | None => None
262
+ }
263
+ }
264
+ )
265
+
266
+ switch result {
267
+ | Some(_) as hit => hit
268
+ | None => probe(None) // program-wide fallback
269
+ }
270
+ }
271
+
272
+ let make = ({chain, endpointUrl, apiToken, eventConfigs, clientMaxRetries, clientTimeoutMillis}: options): t => {
273
+ let name = "HyperSyncSolana"
274
+ let chainId = chain->ChainMap.Chain.toChainId
275
+
276
+ let client = HyperSyncSolanaClient.make(
277
+ ~url=endpointUrl,
278
+ ~apiToken=?apiToken,
279
+ ~httpReqTimeoutMillis=clientTimeoutMillis,
280
+ ~maxNumRetries=clientMaxRetries,
281
+ )
282
+
283
+ let (eventRouter, programOrderings) =
284
+ EventRouter.fromSvmEventConfigsOrThrow(eventConfigs, ~chain)
285
+
286
+ // programId.toString -> sorted-desc byte lengths
287
+ let orderingByProgram = Dict.make()
288
+ programOrderings->Belt.Array.forEach(o =>
289
+ orderingByProgram->Dict.set(
290
+ o.programId->SvmTypes.Pubkey.toString,
291
+ o.byteLengthsDesc,
292
+ )
293
+ )
294
+
295
+ // programId.toString -> Rust-side schema registry handle. Built once at
296
+ // startup; reused on every decoded instruction.
297
+ let schemaHandlesByProgram = buildSchemaHandles(eventConfigs)
298
+
299
+ let getItemsOrThrow = async (
300
+ ~fromBlock,
301
+ ~toBlock,
302
+ ~addressesByContractName as _,
303
+ ~indexingAddresses,
304
+ ~knownHeight,
305
+ ~partitionId as _,
306
+ ~selection as _,
307
+ ~retry,
308
+ ~logger,
309
+ ) => {
310
+ let totalTimeRef = Hrtime.makeTimer()
311
+ let pageFetchRef = Hrtime.makeTimer()
312
+
313
+ let instructionSelections = buildInstructionSelections(eventConfigs)
314
+ let query: HyperSyncSolanaClient.query = {
315
+ fromSlot: fromBlock,
316
+ toSlot: ?toBlock,
317
+ instructions: instructionSelections,
318
+ }
319
+
320
+ Prometheus.SourceRequestCount.increment(
321
+ ~sourceName=name,
322
+ ~chainId,
323
+ ~method="getInstructions",
324
+ )
325
+
326
+ let resp = try await client.get(~query) catch {
327
+ | exn =>
328
+ throw(
329
+ Source.GetItemsError(
330
+ Source.FailedGettingItems({
331
+ exn,
332
+ attemptedToBlock: toBlock->Option.getOr(knownHeight),
333
+ retry: WithBackoff({
334
+ message: `Unexpected issue while fetching instructions from HyperSync Solana. Attempt a retry.`,
335
+ backoffMillis: switch retry {
336
+ | 0 => 500
337
+ | _ => 1000 * retry
338
+ },
339
+ }),
340
+ }),
341
+ ),
342
+ )
343
+ }
344
+ let pageFetchTime = pageFetchRef->Hrtime.timeSince->Hrtime.toSecondsFloat
345
+
346
+ let parsingRef = Hrtime.makeTimer()
347
+
348
+ // Per (slot, transaction_index) lookup for parent transactions.
349
+ let txByKey = Dict.make()
350
+ resp.data.transactions->Belt.Array.forEach(tx => {
351
+ let key =
352
+ tx.slot->Int.toString ++ ":" ++ tx.transactionIndex->Int.toString
353
+ txByKey->Dict.set(key, tx)
354
+ })
355
+
356
+ // Per (slot, transaction_index, instruction_address) lookup for logs
357
+ // scoped to a single instruction. `instructionAddress: None` logs are
358
+ // attached to no instruction (rare; usually only system messages).
359
+ let logsByKey = Dict.make()
360
+ resp.data.logs->Belt.Array.forEach(log => {
361
+ switch (log.transactionIndex, log.instructionAddress) {
362
+ | (Some(txIdx), Some(addr)) =>
363
+ let key =
364
+ log.slot->Int.toString ++
365
+ ":" ++
366
+ txIdx->Int.toString ++
367
+ ":" ++
368
+ serializeInstructionAddress(addr)
369
+ switch logsByKey->Dict.get(key) {
370
+ | Some(existing) => existing->Array.push(log)
371
+ | None => logsByKey->Dict.set(key, [log])
372
+ }
373
+ | _ => ()
374
+ }
375
+ })
376
+
377
+ let parsedQueueItems = []
378
+ resp.data.instructions->Belt.Array.forEach(instr => {
379
+ let programId = instr.programId->SvmTypes.Pubkey.fromStringUnsafe
380
+ let byteLengths =
381
+ orderingByProgram
382
+ ->Utils.Dict.dangerouslyGetNonOption(instr.programId)
383
+ ->Option.getOr([])
384
+
385
+ let contractAddress = instr.programId->Address.unsafeFromString
386
+ let maybeConfig = probeRouter(
387
+ eventRouter,
388
+ programId,
389
+ instr,
390
+ byteLengths,
391
+ ~contractAddress,
392
+ ~indexingAddresses,
393
+ )
394
+
395
+ switch maybeConfig {
396
+ | None => ()
397
+ | Some(eventConfig) =>
398
+ let txKey =
399
+ instr.slot->Int.toString ++ ":" ++ instr.transactionIndex->Int.toString
400
+ let maybeTx =
401
+ txByKey->Utils.Dict.dangerouslyGetNonOption(txKey)->Option.map(toSvmTransaction)
402
+ let logKey =
403
+ instr.slot->Int.toString ++
404
+ ":" ++
405
+ instr.transactionIndex->Int.toString ++
406
+ ":" ++
407
+ serializeInstructionAddress(instr.instructionAddress)
408
+ let maybeLogs =
409
+ logsByKey
410
+ ->Utils.Dict.dangerouslyGetNonOption(logKey)
411
+ ->Option.map(
412
+ logs =>
413
+ logs->Array.map((log): Envio.svmLog => {
414
+ kind: log.kind->Option.getOr(""),
415
+ message: log.message->Option.getOr(""),
416
+ }),
417
+ )
418
+
419
+ let payload: Envio.svmInstructionEvent = {
420
+ contractName: eventConfig.contractName,
421
+ eventName: eventConfig.name,
422
+ instruction: toSvmInstruction(instr, ~schemaHandlesByProgram),
423
+ transaction: eventConfig.includeTransaction ? maybeTx : None,
424
+ logs: eventConfig.includeLogs ? maybeLogs : None,
425
+ slot: instr.slot,
426
+ blockTime: None,
427
+ // Mirror EVM/Fuel: the shared ecosystem getter reads `block.height`
428
+ // / `block.time` / `block.hash`. C2 doesn't fetch block data, so
429
+ // `time` is 0 and `hash` is "" — populated by the future
430
+ // reorg-guard `queryBlockHash(slot)` route.
431
+ block: {
432
+ height: instr.slot,
433
+ time: 0,
434
+ hash: "",
435
+ },
436
+ }
437
+
438
+ parsedQueueItems
439
+ ->Array.push(
440
+ Internal.Event({
441
+ eventConfig: (eventConfig :> Internal.eventConfig),
442
+ timestamp: 0,
443
+ chain,
444
+ blockNumber: instr.slot,
445
+ logIndex: synthLogIndex(instr),
446
+ event: payload->(
447
+ Utils.magic: Envio.svmInstructionEvent => Internal.event
448
+ ),
449
+ }),
450
+ )
451
+ ->ignore
452
+ }
453
+
454
+ let _ = logger
455
+ })
456
+
457
+ let parsingTimeElapsed = parsingRef->Hrtime.timeSince->Hrtime.toSecondsFloat
458
+ let heighestSlot = resp.nextSlot - 1
459
+
460
+ // C2 ships a no-op reorg guard for SVM: finalized commitment + extremely
461
+ // rare reorgs at finality. C3 wires the extra `queryBlockHash(slot)`
462
+ // route per the Q3 answer.
463
+ let reorgGuard: ReorgDetection.reorgGuard = {
464
+ rangeLastBlock: (
465
+ {
466
+ blockNumber: heighestSlot,
467
+ blockTimestamp: 0,
468
+ blockHash: "",
469
+ }: ReorgDetection.blockDataWithTimestamp
470
+ )->ReorgDetection.generalizeBlockDataWithTimestamp,
471
+ prevRangeLastBlock: None,
472
+ }
473
+
474
+ let totalTimeElapsed = totalTimeRef->Hrtime.timeSince->Hrtime.toSecondsFloat
475
+
476
+ {
477
+ latestFetchedBlockTimestamp: 0,
478
+ parsedQueueItems,
479
+ latestFetchedBlockNumber: heighestSlot,
480
+ stats: {totalTimeElapsed, parsingTimeElapsed, pageFetchTime},
481
+ knownHeight,
482
+ reorgGuard,
483
+ fromBlockQueried: fromBlock,
484
+ }
485
+ }
486
+
487
+ {
488
+ name,
489
+ sourceFor: Sync,
490
+ chain,
491
+ pollingInterval: 1000,
492
+ poweredByHyperSync: true,
493
+ getBlockHashes: (~blockNumbers as _, ~logger as _) =>
494
+ // No-op reorg guard means callers never need block hashes for SVM. If a
495
+ // caller does ask, surface the limitation rather than fabricate empty
496
+ // data — C3 will replace this with a real lookup.
497
+ JsError.throwWithMessage(
498
+ "HyperSyncSolanaSource does not support getBlockHashes yet (reorg detection at finalized commitment is no-op in C2)",
499
+ ),
500
+ getHeightOrThrow: async () => {
501
+ let timer = Hrtime.makeTimer()
502
+ let h = await client.getHeight()
503
+ let seconds = timer->Hrtime.timeSince->Hrtime.toSecondsFloat
504
+ Prometheus.SourceRequestCount.increment(~sourceName=name, ~chainId, ~method="getHeight")
505
+ Prometheus.SourceRequestCount.addSeconds(
506
+ ~sourceName=name,
507
+ ~chainId,
508
+ ~method="getHeight",
509
+ ~seconds,
510
+ )
511
+ h
512
+ },
513
+ getItemsOrThrow,
514
+ }
515
+ }