Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions codegenerator/cli/npm/envio/evm.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@
"boolean",
"null"
]
},
"address_format": {
"description": "Address format for Ethereum addresses: 'checksum' or 'lowercase' (default: checksum)",
"anyOf": [
{
"$ref": "#/$defs/AddressFormat"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false,
Expand Down Expand Up @@ -653,6 +664,13 @@
"viem",
"hypersync-client"
]
},
"AddressFormat": {
"type": "string",
"enum": [
"checksum",
"lowercase"
]
}
}
}
23 changes: 23 additions & 0 deletions codegenerator/cli/npm/envio/src/Address.res
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,29 @@ external unsafeFromString: string => t = "%identity"
module Evm = {
@module("viem")
external fromStringOrThrow: string => t = "getAddress"

// NOTE: the function is named to be overshadowed by the one below, so that we don't have to import viem in the handler code
@module("viem")
external fromStringLowercaseOrThrow: string => bool = "isAddress"

// Reassign since the function might be used in the handler code
// and we don't want to have a "viem" import there. It's needed to keep "viem" a dependency
// of generated code instead of adding it to the indexer project dependencies.
// Also, we want a custom error message, which is searchable in our codebase.
// Validate that the string is a proper address but return a lowercased value
let fromStringLowercaseOrThrow = string => {
if fromStringLowercaseOrThrow(string) {
unsafeFromString(string->Js.String2.toLowerCase)
} else {
Js.Exn.raiseError(
`Address "${string}" is invalid. Expected a 20-byte hex string starting with 0x.`,
)
}
}
Comment on lines +23 to +31
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 | 🟠 Major

🧩 Analysis chain

Verify viem's isAddress accepts both checksummed and lowercase inputs.

The wrapper validates using isAddress then lowercases the result. Ensure viem's isAddress accepts both checksummed (e.g., 0xAbC...) and lowercase (e.g., 0xabc...) addresses, so users can pass either format.

Based on the PR discussion, this validation path may be more expensive than a simple regex but provides better UX by accepting mixed-case inputs.


🌐 Web query:

Does viem's isAddress function accept both checksummed and lowercase Ethereum addresses?

💡 Result:

Short answer: Yes — but with options.

Details: isAddress(address, { strict?: boolean }) defaults to strict: true, which enforces EIP‑55 checksums. An all-lowercase address will be considered valid only if you call isAddress(..., { strict: false }). [1]

Sources:

  • viem isAddress docs. [1]

Use non-strict validation to accept lowercase addresses
In codegenerator/cli/npm/envio/src/Address.res, update the isAddress call to pass { strict: false }, for example:

let fromStringLowercaseOrThrow = string => {
  if isAddress(string, { strict: false }) {
    unsafeFromString(string->Js.String2.toLowerCase)
  } else {
    Js.Exn.raiseError(
      `Address "${string}" is invalid. Expected a 20-byte hex string starting with 0x.`,
    )
  }
}

This ensures both EIP-55 checksummed and all-lowercase addresses are accepted.

🤖 Prompt for AI Agents
In codegenerator/cli/npm/envio/src/Address.res around lines 23 to 31, the
validation currently rejects valid lowercase Ethereum addresses; update the
boolean check to call isAddress(string, { strict: false }) so the validator
accepts both EIP-55 checksummed and all-lowercase addresses, then keep the
existing unsafeFromString(string->Js.String2.toLowerCase) and error raise logic
unchanged.


let fromAddressLowercaseOrThrow = address =>
address->toString->Js.String2.toLowerCase->(Utils.magic: string => t)

// Reassign since the function might be used in the handler code
// and we don't want to have a "viem" import there. It's needed to keep "viem" a dependency
// of generated code instead of adding it to the indexer project dependencies.
Expand Down
46 changes: 35 additions & 11 deletions codegenerator/cli/npm/envio/src/bindings/Ethers.res
Original file line number Diff line number Diff line change
Expand Up @@ -147,19 +147,43 @@ module JsonRpcProvider = {
@send
external getTransaction: (t, ~transactionHash: string) => promise<transaction> = "getTransaction"

let makeGetTransactionFields = (~getTransactionByHash) =>
async (log: log): promise<unknown> => {
let transaction = await getTransactionByHash(log.transactionHash)
// Mutating should be fine, since the transaction isn't used anywhere else outside the function
let fields: {..} = transaction->Obj.magic

// Make it compatible with HyperSync transaction fields
fields["transactionIndex"] = log.transactionIndex
fields["input"] = fields["data"]

fields->Obj.magic
let makeGetTransactionFields = (~getTransactionByHash, ~lowercaseAddresses: bool) => async (
log: log,
): promise<unknown> => {
let transaction = await getTransactionByHash(log.transactionHash)
// Mutating should be fine, since the transaction isn't used anywhere else outside the function
Comment on lines 147 to +154
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 | 🟠 Major

Handle nullable getTransaction results (ethers may return null).

Provider.getTransaction can return null; current type/signature assumes non-null and may crash.

Apply:

-  @send
-  external getTransaction: (t, ~transactionHash: string) => promise<transaction> = "getTransaction"
+  @send
+  external getTransaction: (t, ~transactionHash: string) => promise<Js.nullable<transaction>> = "getTransaction"
@@
-  let makeGetTransactionFields = (~getTransactionByHash, ~lowercaseAddresses: bool) => async (
+  let makeGetTransactionFields = (~getTransactionByHash, ~lowercaseAddresses: bool) => async (
     log: log,
   ): promise<unknown> => {
-    let transaction = await getTransactionByHash(log.transactionHash)
+    let transaction = await getTransactionByHash(log.transactionHash)
+    let transaction =
+      switch transaction {
+      | Js.Nullable.Value(tx) => tx
+      | Js.Nullable.Null
+      | Js.Nullable.Undefined =>
+        Js.Exn.raiseError("getTransaction returned null for " ++ log.transactionHash)
+      }

Also applies to: 150-156

let fields: {..} = transaction->Obj.magic

// Make it compatible with HyperSync transaction fields
fields["transactionIndex"] = log.transactionIndex
fields["input"] = fields["data"]

// NOTE: this is wasteful if these fields are not selected in the users config.
// There might be a better way to do this in the `makeThrowingGetEventTransaction` function rather based on the schema.
// However this is not extremely expensive and good enough for now (only on rpc sync also).
if lowercaseAddresses {
open Js.Nullable
switch fields["from"] {
| Value(from) => fields["from"] = from->Js.String2.toLowerCase
| Undefined => ()
| Null => ()
}
switch fields["to"] {
| Value(to) => fields["to"] = to->Js.String2.toLowerCase
| Undefined => ()
| Null => ()
}
switch fields["contractAddress"] {
| Value(contractAddress) =>
fields["contractAddress"] = contractAddress->Js.String2.toLowerCase
| Undefined => ()
| Null => ()
}
}

fields->Obj.magic
}

type block = {
_difficulty: bigint,
difficulty: int,
Expand Down
10 changes: 8 additions & 2 deletions codegenerator/cli/npm/envio/src/sources/HyperSyncClient.res
Original file line number Diff line number Diff line change
Expand Up @@ -448,10 +448,16 @@ type t = {
}

@module("@envio-dev/hypersync-client") @scope("HypersyncClient") external make: cfg => t = "new"
let make = (~url, ~apiToken, ~httpReqTimeoutMillis, ~maxNumRetries) =>
let make = (
~url,
~apiToken,
~httpReqTimeoutMillis,
~maxNumRetries,
~enableChecksumAddresses=true,
) =>
make({
url,
enableChecksumAddresses: true,
enableChecksumAddresses,
bearerToken: apiToken,
httpReqTimeoutMillis,
maxNumRetries,
Expand Down
9 changes: 8 additions & 1 deletion codegenerator/cli/npm/envio/src/sources/HyperSyncSource.res
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ type options = {
apiToken: option<string>,
clientMaxRetries: int,
clientTimeoutMillis: int,
lowercaseAddresses: bool,
}

let make = (
Expand All @@ -167,6 +168,7 @@ let make = (
apiToken,
clientMaxRetries,
clientTimeoutMillis,
lowercaseAddresses,
}: options,
): t => {
let name = "HyperSync"
Expand All @@ -180,6 +182,7 @@ let make = (
~apiToken,
~maxNumRetries=clientMaxRetries,
~httpReqTimeoutMillis=clientTimeoutMillis,
~enableChecksumAddresses=!lowercaseAddresses,
)

let hscDecoder: ref<option<HyperSyncClient.Decoder.t>> = ref(None)
Expand All @@ -193,7 +196,11 @@ let make = (
~msg="Failed to instantiate a decoder from hypersync client, please double check your ABI or try using 'event_decoder: viem' config option",
)
| decoder =>
decoder.enableChecksummedAddresses()
if lowercaseAddresses {
decoder.disableChecksummedAddresses()
} else {
decoder.enableChecksummedAddresses()
}
decoder
}
}
Expand Down
156 changes: 153 additions & 3 deletions codegenerator/cli/npm/envio/src/sources/RpcSource.res
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ let rec getKnownBlockWithBackoff = async (
~chain,
~blockNumber,
~backoffMsOnFailure,
~lowercaseAddresses: bool,
) =>
switch await getKnownBlock(provider, blockNumber) {
| exception err =>
Expand All @@ -39,8 +40,17 @@ let rec getKnownBlockWithBackoff = async (
~chain,
~blockNumber,
~backoffMsOnFailure=backoffMsOnFailure * 2,
~lowercaseAddresses,
)
| result => result
| result =>
if lowercaseAddresses {
// NOTE: this is wasteful if these fields are not selected in the users config.
// There might be a better way to do this based on the block schema.
// However this is not extremely expensive and good enough for now (only on rpc sync also).
// The in place mutation is cheapest.
(result->Obj.magic)["miner"] = result.miner->Address.Evm.fromAddressLowercaseOrThrow
}
result
Comment on lines +45 to +53
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 | 🟠 Major

🧩 Analysis chain

Verify: in-place mutation may affect cached blocks and happens on every fetch.

The miner address is mutated in-place on every successful block fetch (not just after backoff). Since blockLoader is a LazyLoader (line 518), the mutated block may be cached and shared across multiple call sites. This could cause unexpected behavior if the same block is accessed elsewhere expecting checksummed addresses.

Consider returning a transformed copy instead of mutating in-place, or ensure the loader never caches when lowercaseAddresses is enabled.

Run this script to check if the blockLoader cache could be accessed from multiple locations:


🏁 Script executed:

#!/bin/bash
# Check for other usages of blockLoader that might expect unmutated blocks
rg -nC3 'blockLoader->LazyLoader\.get' --type=res

Length of output: 27


Let's rerun the search without the --type filter:


🏁 Script executed:

#!/bin/bash
rg -nC3 'blockLoader' -g '**/*.res'
rg -nC3 'LazyLoader' -g '**/*.res'
rg -nC3 'getKnownBlockWithBackoff' -g '**/*.res'

Length of output: 9687


🏁 Script executed:

#!/bin/bash
sed -n '1,100p' codegenerator/cli/npm/envio/src/sources/RpcSource.res

Length of output: 3708


🏁 Script executed:

#!/bin/bash
# Find LazyLoader implementation
fd LazyLoader.res

Length of output: 46


🏁 Script executed:

#!/bin/bash
sed -n '1,200p' codegenerator/cli/npm/envio/src/LazyLoader.res

Length of output: 4422


🏁 Script executed:

#!/bin/bash
sed -n '400,650p' codegenerator/cli/npm/envio/src/sources/RpcSource.res

Length of output: 8842


Return a new block object instead of mutating the cached record
The in-place mutation in getKnownBlockWithBackoff (RpcSource.res) writes the lower-cased miner into the shared LazyLoader cache, so every later fetch of the same block sees a mutated address even if checksummed values are required. Clone result before altering—e.g.:

let block =
  if lowercaseAddresses {
    {...result, miner: Address.Evm.fromAddressLowercaseOrThrow(result.miner)}
  } else {
    result
  }
block

—or disable caching when lowercaseAddresses is true.

🤖 Prompt for AI Agents
In codegenerator/cli/npm/envio/src/sources/RpcSource.res around lines 45-53, the
function mutates the cached `result` by assigning a lower-cased `miner` into the
shared LazyLoader cache; instead clone `result` before altering so the cache is
not mutated — construct and return a new block object where `miner` is replaced
with Address.Evm.fromAddressLowercaseOrThrow(result.miner) when
`lowercaseAddresses` is true (otherwise return the original `result`), and
remove the in-place Obj.magic mutation; alternatively, if cloning is
undesirable, disable caching/avoid returning cached records when
`lowercaseAddresses` is true.

}
let getSuggestedBlockIntervalFromExn = {
// Unknown provider: "retry with the range 123-456"
Expand Down Expand Up @@ -453,9 +463,24 @@ type options = {
chain: ChainMap.Chain.t,
contracts: array<Internal.evmContractConfig>,
eventRouter: EventRouter.t<Internal.evmEventConfig>,
allEventSignatures: array<string>,
shouldUseHypersyncClientDecoder: bool,
lowercaseAddresses: bool,
}

let make = ({sourceFor, syncConfig, url, chain, contracts, eventRouter}: options): t => {
let make = (
{
sourceFor,
syncConfig,
url,
chain,
contracts,
eventRouter,
allEventSignatures,
shouldUseHypersyncClientDecoder,
lowercaseAddresses,
}: options,
): t => {
let urlHost = switch sanitizeUrl(url) {
| None =>
Js.Exn.raiseError(
Expand Down Expand Up @@ -498,6 +523,7 @@ let make = ({sourceFor, syncConfig, url, chain, contracts, eventRouter}: options
~chain,
~backoffMsOnFailure=1000,
~blockNumber,
~lowercaseAddresses,
),
~onError=(am, ~exn) => {
Logging.error({
Expand All @@ -522,6 +548,7 @@ let make = ({sourceFor, syncConfig, url, chain, contracts, eventRouter}: options
let getEventTransactionOrThrow = makeThrowingGetEventTransaction(
~getTransactionFields=Ethers.JsonRpcProvider.makeGetTransactionFields(
~getTransactionByHash=LazyLoader.get(transactionLoader, _),
~lowercaseAddresses,
),
)

Expand All @@ -530,6 +557,32 @@ let make = ({sourceFor, syncConfig, url, chain, contracts, eventRouter}: options
contractNameAbiMapping->Js.Dict.set(contract.name, contract.abi)
})

let convertEthersLogToHyperSyncEvent = (log: Ethers.log): HyperSyncClient.ResponseTypes.event => {
let hyperSyncLog: HyperSyncClient.ResponseTypes.log = {
removed: log.removed->Option.getWithDefault(false),
index: log.logIndex,
transactionIndex: log.transactionIndex,
transactionHash: log.transactionHash,
blockHash: log.blockHash,
blockNumber: log.blockNumber,
address: log.address,
data: log.data,
topics: log.topics->Array.map(topic => Js.Nullable.return(topic)),
}
{log: hyperSyncLog}
}

let hscDecoder: ref<option<HyperSyncClient.Decoder.t>> = ref(None)
let getHscDecoder = () => {
switch hscDecoder.contents {
| Some(decoder) => decoder
| None => {
let decoder = HyperSyncClient.Decoder.fromSignatures(allEventSignatures)
decoder
}
}
}
Comment on lines +575 to +584
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 | 🟠 Major

Cache the HyperSync decoder and set address mode like HyperSyncSource

getHscDecoder() creates a new decoder every time and doesn’t set address casing behavior. Mirror HyperSyncSource: cache once and toggle checksummed vs lowercase to keep decoded address fields consistent.

Apply this diff:

 let hscDecoder: ref<option<HyperSyncClient.Decoder.t>> = ref(None)
 let getHscDecoder = () => {
   switch hscDecoder.contents {
   | Some(decoder) => decoder
-  | None => {
-      let decoder = HyperSyncClient.Decoder.fromSignatures(allEventSignatures)
-      decoder
-    }
+  | None => {
+      let decoder = HyperSyncClient.Decoder.fromSignatures(allEventSignatures)
+      // Align decoded address handling with lowercaseAddresses
+      if lowercaseAddresses {
+        decoder.disableChecksummedAddresses()
+      } else {
+        decoder.enableChecksummedAddresses()
+      };
+      hscDecoder := Some(decoder);
+      decoder
+    }
   }
 }
🤖 Prompt for AI Agents
In codegenerator/cli/npm/envio/src/sources/RpcSource.res around lines 575-584,
getHscDecoder currently constructs a new decoder on every call and never updates
hscDecoder.contents or the decoder's address-casing behavior; change it to
create the decoder only once (store it into hscDecoder.contents when None) and
set its address mode to mirror HyperSyncSource (toggle between Checksummed and
Lowercase as used elsewhere — e.g., set decoder.addressMode =
HyperSyncClient.Decoder.AddressMode.Checksummed when checksummed addresses are
expected, otherwise .Lowercase) so decoded address fields remain consistent.


let getItemsOrThrow = async (
~fromBlock,
~toBlock,
Expand Down Expand Up @@ -603,10 +656,106 @@ let make = ({sourceFor, syncConfig, url, chain, contracts, eventRouter}: options
)
}

let parsedQueueItems =
let parsedQueueItems = if shouldUseHypersyncClientDecoder {
// Convert Ethers logs to HyperSync events
let hyperSyncEvents = logs->Belt.Array.map(convertEthersLogToHyperSyncEvent)

// Decode using HyperSyncClient decoder
let parsedEvents = try await getHscDecoder().decodeEvents(hyperSyncEvents) catch {
| exn =>
raise(
Source.GetItemsError(
FailedParsingItems({
message: "Failed to parse events using hypersync client decoder. Please double-check your ABI.",
exn,
blockNumber: fromBlock,
logIndex: 0,
}),
),
)
}

await logs
->Array.zip(parsedEvents)
->Array.keepMap(((
log: Ethers.log,
maybeDecodedEvent: Js.Nullable.t<HyperSyncClient.Decoder.decodedEvent>,
)) => {
let topic0 = log.topics[0]->Option.getWithDefault("0x0"->EvmTypes.Hex.fromStringUnsafe)
let routedAddress = if lowercaseAddresses {
log.address->Address.Evm.fromAddressLowercaseOrThrow
} else {
log.address
}

switch eventRouter->EventRouter.get(
~tag=EventRouter.getEvmEventId(
~sighash=topic0->EvmTypes.Hex.toString,
~topicCount=log.topics->Array.length,
),
~indexingContracts,
~contractAddress=routedAddress,
~blockNumber=log.blockNumber,
) {
Comment on lines +678 to +699
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 | 🔴 Critical

Bug: topic0 extraction uses Option on non-option; also normalize routedAddress here

log.topics is an array; indexing doesn’t return option. Use Js.Array2.unsafe_get(0) like the Viem branch, and keep routedAddress normalization.

Apply this diff:

-      await logs
-      ->Array.zip(parsedEvents)
+      logs
+      ->Array.zip(parsedEvents)
       ->Array.keepMap(((
         log: Ethers.log,
         maybeDecodedEvent: Js.Nullable.t<HyperSyncClient.Decoder.decodedEvent>,
       )) => {
-        let topic0 = log.topics[0]->Option.getWithDefault("0x0"->EvmTypes.Hex.fromStringUnsafe)
+        let topic0 = log.topics->Js.Array2.unsafe_get(0)
         let routedAddress = if lowercaseAddresses {
           log.address->Address.Evm.fromAddressLowercaseOrThrow
         } else {
           log.address
         }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In codegenerator/cli/npm/envio/src/sources/RpcSource.res around lines 678 to
699, the code incorrectly treats log.topics[0] as an Option and uses
Option.getWithDefault; replace that with Js.Array2.unsafe_get(0) (as in the Viem
branch) to directly extract the first topic, and keep/ensure the routedAddress
normalization logic (using Address.Evm.fromAddressLowercaseOrThrow when
lowercaseAddresses is true) so the EventRouter.get call uses the normalized
address and the correct topic0 string and topicCount.

| None => None
| Some(eventConfig) =>
switch maybeDecodedEvent {
| Js.Nullable.Value(decoded) =>
Some(
(
async () => {
let (block, transaction) = try await Promise.all2((
log->getEventBlockOrThrow,
log->getEventTransactionOrThrow(
~transactionSchema=eventConfig.transactionSchema,
),
)) catch {
| exn =>
raise(
Source.GetItemsError(
FailedGettingFieldSelection({
message: "Failed getting selected fields. Please double-check your RPC provider returns correct data.",
exn,
blockNumber: log.blockNumber,
logIndex: log.logIndex,
}),
),
)
}

Internal.Event({
eventConfig: (eventConfig :> Internal.eventConfig),
timestamp: block.timestamp,
blockNumber: block.number,
chain,
logIndex: log.logIndex,
event: {
chainId: chain->ChainMap.Chain.toChainId,
params: decoded->eventConfig.convertHyperSyncEventArgs,
transaction,
block: block->(
Utils.magic: Ethers.JsonRpcProvider.block => Internal.eventBlock
),
srcAddress: routedAddress,
logIndex: log.logIndex,
}->Internal.fromGenericEvent,
})
}
)(),
)
| Js.Nullable.Null
| Js.Nullable.Undefined =>
None
}
}
})
->Promise.all
} else {
// Decode using Viem
await logs
->Belt.Array.keepMap(log => {
let topic0 = log.topics->Js.Array2.unsafe_get(0)

switch eventRouter->EventRouter.get(
~tag=EventRouter.getEvmEventId(
~sighash=topic0->EvmTypes.Hex.toString,
Expand Down Expand Up @@ -685,6 +834,7 @@ let make = ({sourceFor, syncConfig, url, chain, contracts, eventRouter}: options
}
})
->Promise.all
}

let optFirstBlockParent = await firstBlockParentPromise

Expand Down
1 change: 1 addition & 0 deletions codegenerator/cli/src/cli_args/init_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ pub mod evm {
field_selection: None,
raw_events: None,
preload_handlers: Some(true),
address_format: None,
})
}

Expand Down
Loading