Skip to content
10 changes: 10 additions & 0 deletions codegenerator/cli/npm/envio/src/InternalConfig.res
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,13 @@ type chain = {
contracts: array<contract>,
sources: array<Source.t>,
}

type sourceSync = {
initialBlockInterval: int,
backoffMultiplicative: float,
accelerationAdditive: int,
intervalCeiling: int,
backoffMillis: int,
queryTimeoutMillis: int,
fallbackStallTimeout: int,
}
22 changes: 15 additions & 7 deletions codegenerator/cli/npm/envio/src/sources/HyperSync.res
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,23 @@ module GetLogs = {
fieldSelection,
}

@inline
let addMissingParams = (acc, fieldNames, returnedObj, ~prefix) => {
fieldNames->Array.forEach(fieldName => {
switch returnedObj
->(Utils.magic: 'a => Js.Dict.t<unknown>)
->Utils.Dict.dangerouslyGetNonOption(fieldName) {
| Some(_) => ()
| None => acc->Array.push(prefix ++ "." ++ fieldName)->ignore
if fieldNames->Utils.Array.notEmpty {
if !(returnedObj->Obj.magic) {
acc->Array.push(prefix)->ignore
} else {
for idx in 0 to fieldNames->Array.length - 1 {
let fieldName = fieldNames->Array.getUnsafe(idx)
switch returnedObj
->(Utils.magic: 'a => Js.Dict.t<unknown>)
->Utils.Dict.dangerouslyGetNonOption(fieldName) {
| Some(_) => ()
| None => acc->Array.push(prefix ++ "." ++ fieldName)->ignore
}
}
}
})
}
Comment on lines +79 to +95
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Always report missing object; avoid truthiness + magic casts.

The non-object detection is gated by fieldNames->notEmpty. If nonOptionalTransactionFieldNames is empty, a missing transaction object won’t be reported—contrary to the PR objective. Also, relying on JS truthiness via Obj.magic is brittle. Replace with explicit JSON classification and hoist the dict cast.

Apply this refactor:

-@inline
-let addMissingParams = (acc, fieldNames, returnedObj, ~prefix) => {
-  if fieldNames->Utils.Array.notEmpty {
-    if !(returnedObj->Obj.magic) {
-      acc->Array.push(prefix)->ignore
-    } else {
-      for idx in 0 to fieldNames->Array.length - 1 {
-        let fieldName = fieldNames->Array.getUnsafe(idx)
-        switch returnedObj
-        ->(Utils.magic: 'a => Js.Dict.t<unknown>)
-        ->Utils.Dict.dangerouslyGetNonOption(fieldName) {
-        | Some(_) => ()
-        | None => acc->Array.push(prefix ++ "." ++ fieldName)->ignore
-        }
-      }
-    }
-  }
-}
+@inline
+let addMissingParams = (acc, fieldNames, returnedObj: Js.Json.t, ~prefix) => {
+  switch Js.Json.classify(returnedObj) {
+  | Js.Json.JSONObject(obj) => {
+      if fieldNames->Utils.Array.notEmpty {
+        let dict = obj
+        for idx in 0 to fieldNames->Array.length - 1 {
+          let fieldName = fieldNames->Array.getUnsafe(idx)
+          switch Js.Dict.get(dict, fieldName) {
+          | Some(_) => ()
+          | None => acc->Array.push(prefix ++ "." ++ fieldName)->ignore
+          }
+        }
+      }
+    }
+  | _ =>
+    /* Missing or non-object => record the whole prefix */
+    acc->Array.push(prefix)->ignore
+  }
+}

Update call sites accordingly (outside the changed hunk):

/* in convertEvent */
missingParams->addMissingParams(Log.fieldNames, (event.log: Js.Json.t), ~prefix="log")
missingParams->addMissingParams(nonOptionalBlockFieldNames, (event.block: Js.Json.t), ~prefix="block")
missingParams->addMissingParams(nonOptionalTransactionFieldNames, (event.transaction: Js.Json.t), ~prefix="transaction")
🤖 Prompt for AI Agents
In codegenerator/cli/npm/envio/src/sources/HyperSync.res around lines 79–95, the
non-object detection is currently gated by fieldNames->Utils.Array.notEmpty and
uses Obj.magic truthiness; change it to always detect non-object by classifying
returnedObj with Js.Json.classify, and if it's not an object push the prefix
once (so missing object is always reported regardless of fieldNames) and return;
if it is an object hoist a single Js.Dict.t<unknown> cast for reuse then, only
if fieldNames is non-empty, iterate fieldNames and use
Utils.Dict.dangerouslyGetNonOption against the hoisted dict to push missing
field names as prefix ++ "." ++ fieldName; also update the convertEvent call
sites as suggested to pass Js.Json.t for event.log/event.block/event.transaction
with ~prefix values.

}

//Note this function can throw an error
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ let getKnownBlock = (provider, blockNumber) =>
}
)

let rec getKnownBlockWithBackoff = async (~provider, ~sourceName, ~chain, ~blockNumber, ~backoffMsOnFailure) =>
let rec getKnownBlockWithBackoff = async (
~provider,
~sourceName,
~chain,
~blockNumber,
~backoffMsOnFailure,
) =>
switch await getKnownBlock(provider, blockNumber) {
| exception err =>
Logging.warn({
Expand Down Expand Up @@ -88,19 +94,24 @@ let getSuggestedBlockIntervalFromExn = {
// - Optimism: "backend response too large" or "Block range is too large"
// - Arbitrum: "logs matched by query exceeds limit of 10000"

exn =>
(exn): option<(
// The suggested block range
int,
// Whether it's the max range that the provider allows
bool,
)> =>
switch exn {
| Js.Exn.Error(error) =>
try {
let message: string = (error->Obj.magic)["error"]["message"]
message->S.assertOrThrow(S.string)

// Helper to extract block range from regex match
let extractBlockRange = execResult =>
let extractBlockRange = (execResult, ~isMaxRange) =>
switch execResult->Js.Re.captures {
| [_, Js.Nullable.Value(blockRangeLimit)] =>
switch blockRangeLimit->Int.fromString {
| Some(blockRangeLimit) if blockRangeLimit > 0 => Some(blockRangeLimit)
| Some(blockRangeLimit) if blockRangeLimit > 0 => Some(blockRangeLimit, isMaxRange)
| _ => None
}
Comment on lines +110 to 116
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Bug: incorrect Option tuple construction (Some needs a single tuple argument).

In ReScript, option<(int, bool)> must be constructed as Some((x, y)), not Some(x, y). Current code won’t type-check and will fail at build time.

Apply:

- | Some(blockRangeLimit) if blockRangeLimit > 0 => Some(blockRangeLimit, isMaxRange)
+ | Some(blockRangeLimit) if blockRangeLimit > 0 => Some((blockRangeLimit, isMaxRange))

-             | (Some(fromBlock), Some(toBlock)) if toBlock >= fromBlock =>
-              Some(toBlock - fromBlock + 1, false)
+             | (Some(fromBlock), Some(toBlock)) if toBlock >= fromBlock =>
+              Some((toBlock - fromBlock + 1, false))

-                      | Some(_) => Some(2000, true)
+                      | Some(_) => Some((2000, true))

-                          | Some(_) => Some(10000, true)
+                          | Some(_) => Some((10000, true))

Also applies to: 127-128, 153-154, 159-160

🤖 Prompt for AI Agents
In codegenerator/cli/npm/envio/src/sources/RpcSource.res around lines 110-116,
the code constructs options incorrectly using multiple arguments for Some;
change those to pass a single tuple value (e.g., Some((blockRangeLimit,
isMaxRange))) so option<(int,bool)> is built correctly; apply the same fix at
the other occurrences reported (lines 127-128, 153-154, 159-160) replacing
Some(x, y) with Some((x, y)).

| _ => None
Expand All @@ -113,48 +124,49 @@ let getSuggestedBlockIntervalFromExn = {
| [_, Js.Nullable.Value(fromBlock), Js.Nullable.Value(toBlock)] =>
switch (fromBlock->Int.fromString, toBlock->Int.fromString) {
| (Some(fromBlock), Some(toBlock)) if toBlock >= fromBlock =>
Some(toBlock - fromBlock + 1)
Some(toBlock - fromBlock + 1, false)
| _ => None
}
| _ => None
}
| None =>
// Try each provider's specific error pattern
switch blockRangeLimitRegExp->Js.Re.exec_(message) {
| Some(execResult) => extractBlockRange(execResult)
| Some(execResult) => extractBlockRange(execResult, ~isMaxRange=true)
| None =>
switch alchemyRangeRegExp->Js.Re.exec_(message) {
| Some(execResult) => extractBlockRange(execResult)
| Some(execResult) => extractBlockRange(execResult, ~isMaxRange=true)
| None =>
switch cloudflareRangeRegExp->Js.Re.exec_(message) {
| Some(execResult) => extractBlockRange(execResult)
| Some(execResult) => extractBlockRange(execResult, ~isMaxRange=true)
| None =>
switch thirdwebRangeRegExp->Js.Re.exec_(message) {
| Some(execResult) => extractBlockRange(execResult)
| Some(execResult) => extractBlockRange(execResult, ~isMaxRange=true)
| None =>
switch blockpiRangeRegExp->Js.Re.exec_(message) {
| Some(execResult) => extractBlockRange(execResult)
| Some(execResult) => extractBlockRange(execResult, ~isMaxRange=true)
| None =>
switch maxAllowedBlocksRegExp->Js.Re.exec_(message) {
| Some(execResult) => extractBlockRange(execResult)
| Some(execResult) => extractBlockRange(execResult, ~isMaxRange=true)
| None =>
switch baseRangeRegExp->Js.Re.exec_(message) {
| Some(_) => Some(2000)
| Some(_) => Some(2000, true)
| None =>
switch blastPaidRegExp->Js.Re.exec_(message) {
| Some(execResult) => extractBlockRange(execResult)
switch blastPaidRegExp->Js.Re.exec_(message) {
| Some(execResult) => extractBlockRange(execResult, ~isMaxRange=true)
| None =>
switch chainstackRegExp->Js.Re.exec_(message) {
| Some(_) => Some(10000)
| Some(_) => Some(10000, true)
| None =>
switch coinbaseRegExp->Js.Re.exec_(message) {
| Some(execResult) => extractBlockRange(execResult)
| Some(execResult) => extractBlockRange(execResult, ~isMaxRange=true)
| None =>
switch publicNodeRegExp->Js.Re.exec_(message) {
| Some(execResult) => extractBlockRange(execResult)
| Some(execResult) => extractBlockRange(execResult, ~isMaxRange=true)
| None =>
switch hyperliquidRegExp->Js.Re.exec_(message) {
| Some(execResult) => extractBlockRange(execResult)
| Some(execResult) =>
extractBlockRange(execResult, ~isMaxRange=true)
| None => None
}
}
Expand All @@ -181,15 +193,17 @@ type eventBatchQuery = {
latestFetchedBlock: Ethers.JsonRpcProvider.block,
}

let maxSuggestedBlockIntervalKey = "max"

let getNextPage = (
~fromBlock,
~toBlock,
~addresses,
~topicQuery,
~loadBlock,
~syncConfig as sc: Config.syncConfig,
~syncConfig as sc: InternalConfig.sourceSync,
~provider,
~suggestedBlockIntervals,
~mutSuggestedBlockIntervals,
~partitionId,
): promise<eventBatchQuery> => {
//If the query hangs for longer than this, reject this promise to reduce the block interval
Expand Down Expand Up @@ -224,8 +238,11 @@ let getNextPage = (
->Promise.race
->Promise.catch(err => {
switch getSuggestedBlockIntervalFromExn(err) {
| Some(nextBlockIntervalTry) =>
suggestedBlockIntervals->Js.Dict.set(partitionId, nextBlockIntervalTry)
| Some((nextBlockIntervalTry, isMaxRange)) =>
mutSuggestedBlockIntervals->Js.Dict.set(
isMaxRange ? maxSuggestedBlockIntervalKey : partitionId,
nextBlockIntervalTry,
)
raise(
Source.GetItemsError(
FailedGettingItems({
Expand All @@ -241,7 +258,7 @@ let getNextPage = (
let executedBlockInterval = toBlock - fromBlock + 1
let nextBlockIntervalTry =
(executedBlockInterval->Belt.Int.toFloat *. sc.backoffMultiplicative)->Belt.Int.fromFloat
suggestedBlockIntervals->Js.Dict.set(partitionId, nextBlockIntervalTry)
mutSuggestedBlockIntervals->Js.Dict.set(partitionId, nextBlockIntervalTry)
raise(
Source.GetItemsError(
Source.FailedGettingItems({
Expand Down Expand Up @@ -351,11 +368,8 @@ let memoGetSelectionConfig = (~chain) => {
}

let makeThrowingGetEventBlock = (~getBlock) => {
// The block fields type is a subset of Ethers.JsonRpcProvider.block so we can safely cast
let blockFieldsFromBlock: Ethers.JsonRpcProvider.block => Internal.eventBlock = Utils.magic

async (log: Ethers.log): Internal.eventBlock => {
(await getBlock(log.blockNumber))->blockFieldsFromBlock
async (log: Ethers.log) => {
await getBlock(log.blockNumber)
}
}

Expand Down Expand Up @@ -434,7 +448,7 @@ let sanitizeUrl = (url: string) => {

type options = {
sourceFor: Source.sourceFor,
syncConfig: Config.syncConfig,
syncConfig: InternalConfig.sourceSync,
url: string,
chain: ChainMap.Chain.t,
contracts: array<Internal.evmContractConfig>,
Expand All @@ -455,7 +469,7 @@ let make = ({sourceFor, syncConfig, url, chain, contracts, eventRouter}: options

let getSelectionConfig = memoGetSelectionConfig(~chain)

let suggestedBlockIntervals = Js.Dict.empty()
let mutSuggestedBlockIntervals = Js.Dict.empty()

let transactionLoader = LazyLoader.make(
~loaderFn=transactionHash => provider->Ethers.JsonRpcProvider.getTransaction(~transactionHash),
Expand All @@ -478,7 +492,13 @@ let make = ({sourceFor, syncConfig, url, chain, contracts, eventRouter}: options

let blockLoader = LazyLoader.make(
~loaderFn=blockNumber =>
getKnownBlockWithBackoff(~provider, ~sourceName=name, ~chain, ~backoffMsOnFailure=1000, ~blockNumber),
getKnownBlockWithBackoff(
~provider,
~sourceName=name,
~chain,
~backoffMsOnFailure=1000,
~blockNumber,
),
~onError=(am, ~exn) => {
Logging.error({
"err": exn,
Expand Down Expand Up @@ -523,10 +543,15 @@ let make = ({sourceFor, syncConfig, url, chain, contracts, eventRouter}: options
) => {
let startFetchingBatchTimeRef = Hrtime.makeTimer()

let suggestedBlockInterval =
suggestedBlockIntervals
let suggestedBlockInterval = switch mutSuggestedBlockIntervals->Utils.Dict.dangerouslyGetNonOption(
maxSuggestedBlockIntervalKey,
) {
| Some(maxSuggestedBlockInterval) => maxSuggestedBlockInterval
| None =>
mutSuggestedBlockIntervals
->Utils.Dict.dangerouslyGetNonOption(partitionId)
->Belt.Option.getWithDefault(syncConfig.initialBlockInterval)
}

// Always have a toBlock for an RPC worker
let toBlock = switch toBlock {
Expand Down Expand Up @@ -554,18 +579,22 @@ let make = ({sourceFor, syncConfig, url, chain, contracts, eventRouter}: options
~loadBlock=blockNumber => blockLoader->LazyLoader.get(blockNumber),
~syncConfig,
~provider,
~suggestedBlockIntervals,
~mutSuggestedBlockIntervals,
~partitionId,
)

let executedBlockInterval = suggestedToBlock - fromBlock + 1

// Increase the suggested block interval only when it was actually applied
// and we didn't query to a hard toBlock
if executedBlockInterval >= suggestedBlockInterval {
// We also don't care about it when we have a hard max block interval
if (
executedBlockInterval >= suggestedBlockInterval &&
!(mutSuggestedBlockIntervals->Utils.Dict.has(maxSuggestedBlockIntervalKey))
) {
// Increase batch size going forward, but do not increase past a configured maximum
// See: https://en.wikipedia.org/wiki/Additive_increase/multiplicative_decrease
suggestedBlockIntervals->Js.Dict.set(
mutSuggestedBlockIntervals->Js.Dict.set(
partitionId,
Pervasives.min(
executedBlockInterval + syncConfig.accelerationAdditive,
Expand Down Expand Up @@ -634,15 +663,19 @@ let make = ({sourceFor, syncConfig, url, chain, contracts, eventRouter}: options
(
{
eventConfig: (eventConfig :> Internal.eventConfig),
timestamp: block->Types.Block.getTimestamp,
timestamp: block.timestamp,
blockNumber: block.number,
chain,
blockNumber: block->Types.Block.getNumber,
logIndex: log.logIndex,
event: {
chainId: chain->ChainMap.Chain.toChainId,
params: decodedEvent.args,
transaction,
block,
// Unreliably expect that the Ethers block fields match the types in HyperIndex
// I assume this is wrong in some cases, so we need to fix it in the future
block: block->(
Utils.magic: Ethers.JsonRpcProvider.block => Internal.eventBlock
),
srcAddress: log.address,
logIndex: log.logIndex,
}->Internal.fromGenericEvent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ type hyperFuelConfig = {endpointUrl: string}

@genType.opaque
type rpcConfig = {
syncConfig: Config.syncConfig,
syncConfig: InternalConfig.sourceSync,
}

@genType
Expand Down
12 changes: 1 addition & 11 deletions codegenerator/cli/templates/static/codegen/src/Config.res
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,6 @@ type syncConfigOptions = {
fallbackStallTimeout?: int,
}

type syncConfig = {
initialBlockInterval: int,
backoffMultiplicative: float,
accelerationAdditive: int,
intervalCeiling: int,
backoffMillis: int,
queryTimeoutMillis: int,
fallbackStallTimeout: int,
}

type historyFlag = FullHistory | MinHistory
type rollbackFlag = RollbackOnReorg | NoRollback
type historyConfig = {rollbackFlag: rollbackFlag, historyFlag: historyFlag}
Expand All @@ -36,7 +26,7 @@ let getSyncConfig = (
?queryTimeoutMillis,
?fallbackStallTimeout,
}: syncConfigOptions,
): syncConfig => {
): InternalConfig.sourceSync => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Return type switch to InternalConfig.sourceSync looks good; verify template/call-site alignment.

This aligns the template with the new public type. Please double-check that all consumers (templates and runtime) now expect InternalConfig.sourceSync and that no references to Config.syncConfig remain.

Run to confirm no stale references:


🏁 Script executed:

#!/bin/bash
rg -nP 'Config\.syncConfig\b|:?\s*syncConfig\b'
rg -nP 'InternalConfig\.sourceSync\b'

Length of output: 6790


Stale Config.syncConfig references found—update to InternalConfig.sourceSync

  • scenarios/test_codegen/test/**/* and mocks still use Config.getSyncConfig
  • static template at codegenerator/cli/templates/static/codegen/src/eventFetching/NetworkSources.res
  • dynamic template codegenerator/cli/templates/dynamic/codegen/src/ConfigYAML.res.hbs
  • HBS code in codegenerator/cli/src/hbs_templating/codegen_templates.rs
  • runtime sources in codegenerator/cli/npm/envio/src/sources/RpcSource.res

Ensure every call-site uses InternalConfig.sourceSync and remove all Config.syncConfig/getSyncConfig occurrences.

🤖 Prompt for AI Agents
In codegenerator/cli/templates/static/codegen/src/Config.res around line 29,
there are stale references to Config.syncConfig/getSyncConfig; replace all
call-sites to use InternalConfig.sourceSync instead, update the return/type
usage to match InternalConfig.sourceSync, and remove any remaining
Config.syncConfig/getSyncConfig occurrences; repeat this replacement in the
listed templates and files (scenarios/test_codegen/test/**/*, mocks,
codegenerator/cli/templates/static/codegen/src/eventFetching/NetworkSources.res,
codegenerator/cli/templates/dynamic/codegen/src/ConfigYAML.res.hbs,
codegenerator/cli/src/hbs_templating/codegen_templates.rs, and
codegenerator/cli/npm/envio/src/sources/RpcSource.res), ensuring imports/aliases
are adjusted and any type mismatches are fixed so callers compile against
InternalConfig.sourceSync.

let queryTimeoutMillis = queryTimeoutMillis->Option.getWithDefault(20_000)
{
initialBlockInterval: Env.Configurable.SyncConfig.initialBlockInterval->Option.getWithDefault(
Expand Down
41 changes: 41 additions & 0 deletions scenarios/test_codegen/test/HyperSync_test.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
open RescriptMocha

let testApiToken = "3dc856dd-b0ea-494f-b27e-017b8b6b7e07"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Hardcoded API token committed — remove and rotate immediately.

Never commit secrets, even in skipped tests. Replace with an env-driven value and rotate the exposed token.

Apply:

- let testApiToken = "3dc856dd-b0ea-494f-b27e-017b8b6b7e07"
+ // Read from your CI/test runner env and document the requirement.
+ let testApiToken = "<SET_HYPERSYNC_API_TOKEN_IN_ENV>"

Optionally, gate the test at runtime when the token is missing.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let testApiToken = "3dc856dd-b0ea-494f-b27e-017b8b6b7e07"
// Read from your CI/test runner env and document the requirement.
let testApiToken = "<SET_HYPERSYNC_API_TOKEN_IN_ENV>"
🤖 Prompt for AI Agents
In scenarios/test_codegen/test/HyperSync_test.res around line 3, a hardcoded API
token was committed; remove the literal string and replace it with an
environment-driven value (e.g., read from process.env or a test-specific config)
so the test reads the token at runtime, rotate the exposed token immediately,
and add a runtime guard in the test to skip/fail with a clear message if the env
var is missing to avoid accidental failures.


describe_skip("Test Hyperliquid broken transaction response", () => {
Async.it("should handle broken transaction response", async () => {
let page = await HyperSync.GetLogs.query(
~client=HyperSyncClient.make(
~url="https://645749.hypersync.xyz",
~apiToken=testApiToken,
~maxNumRetries=Env.hyperSyncClientMaxRetries,
~httpReqTimeoutMillis=Env.hyperSyncClientTimeoutMillis,
),
~fromBlock=12403138,
~toBlock=Some(12403139),
~logSelections=[
{
addresses: [],
topicSelections: [
{
topic0: [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"->EvmTypes.Hex.fromStringUnsafe,
],
topic1: [],
topic2: [],
topic3: [],
},
],
},
],
~fieldSelection={
log: [Address, Data, LogIndex, Topic0, Topic1, Topic2, Topic3],
transaction: [Hash],
},
~nonOptionalBlockFieldNames=[],
~nonOptionalTransactionFieldNames=["hash"],
)

Js.log(page)
})
})
Loading