diff --git a/packages/cli/src/config_parsing/system_config.rs b/packages/cli/src/config_parsing/system_config.rs index a3e858344..b1c8a340b 100644 --- a/packages/cli/src/config_parsing/system_config.rs +++ b/packages/cli/src/config_parsing/system_config.rs @@ -1921,12 +1921,75 @@ impl Contract { events: Vec, abi: Abi, ) -> Result { - // TODO: Validatate that all event names are unique validate_names_valid_rescript( &events.iter().map(|e| e.name.clone()).collect(), "event".to_string(), )?; + // Codegen keys the generated event modules by name and routing looks + // events up by name, so two events on one contract can't share a name. + // Overloads (same name, different signature) are the usual cause — point + // at the `name` alias as the fix; a byte-identical copy just needs + // removing. + let mut seen_by_name: HashMap<&str, &Event> = HashMap::new(); + for event in &events { + if let Some(existing) = seen_by_name.insert(&event.name, event) { + if existing.sighash == event.sighash { + return Err(anyhow!( + "Contract {name} defines the event \"{}\" more than once. \ + Please remove the duplicate.", + event.name, + )); + } + return Err(anyhow!( + "Contract {name} has two events named \"{}\". Give one of them a \ + unique name with the \"name\" field so the generated code and \ + the indexer's routing can tell them apart.", + event.name, + )); + } + } + + // Two events on one contract that share a dispatch key are + // indistinguishable at routing time — one log/instruction would decode + // to both — so reject them here. The key mirrors the runtime `eventId`: + // sighash plus indexed-topic count for EVM, the discriminator for SVM + // (already program-scoped, since these are one program's instructions), + // the sighash for Fuel (a `LogData` logId or a fixed `mint`/`burn`/…). + // Names are unique by the check above, so a collision here is always + // between two differently-named events. + let mut seen_by_dispatch_key: HashMap = HashMap::new(); + for event in &events { + let dispatch_key = match &event.kind { + EventKind::Params(params) => { + let indexed_count = params.iter().filter(|p| p.indexed).count(); + Some(format!("{}_{}", event.sighash, indexed_count)) + } + // The router decodes the discriminator to bytes before matching, + // so `0x0f` and `0x0F` collide — lowercase before keying. + EventKind::Svm(svm) => Some( + svm.discriminator + .as_ref() + .map(|d| d.to_lowercase()) + .unwrap_or_else(|| "none".to_string()), + ), + EventKind::Fuel(_) => Some(event.sighash.clone()), + }; + if let Some(dispatch_key) = dispatch_key { + if let Some(existing) = + seen_by_dispatch_key.insert(dispatch_key, event.name.clone()) + { + return Err(anyhow!( + "Contract {name} has two events the indexer can't tell apart: \ + \"{existing}\" and \"{}\". They match the same on-chain data, so \ + the indexer can't decide which one a log belongs to. Please remove \ + one of them.", + event.name, + )); + } + } + } + Ok(Self { name, events, diff --git a/packages/envio-tests/test/HandlerRegisterValidation_test.res b/packages/envio-tests/test/HandlerRegisterValidation_test.res deleted file mode 100644 index 88e7b18f0..000000000 --- a/packages/envio-tests/test/HandlerRegisterValidation_test.res +++ /dev/null @@ -1,54 +0,0 @@ -open Vitest - -// Covers `HandlerRegister.validateEventIdOrThrow`, the per-chain dispatch-id -// guard `finishRegistration` runs so a single log/receipt/instruction can't fan -// out to two events on one contract, or to two wildcards sharing a signature — -// Rust-side routing dispatches by these ids, so a collision double-delivers. - -describe("HandlerRegister.validateEventIdOrThrow", () => { - let add = (validator, ~eventId, ~contractName, ~isWildcard) => - validator->HandlerRegister.validateEventIdOrThrow( - ~eventId, - ~contractName, - ~eventName="Event1", - ~isWildcard, - ~chainId=1, - ) - - it("accepts the same eventId across different contracts", t => { - let validator = HandlerRegister.makeEventIdValidator() - validator->add(~eventId="0xsig", ~contractName="Contract1", ~isWildcard=false) - t.expect( - () => validator->add(~eventId="0xsig", ~contractName="Contract2", ~isWildcard=false), - ).not.toThrow() - }) - - it("throws on a duplicate event for the same contract", t => { - let validator = HandlerRegister.makeEventIdValidator() - validator->add(~eventId="0xsig", ~contractName="Contract1", ~isWildcard=false) - t.expect( - () => validator->add(~eventId="0xsig", ~contractName="Contract1", ~isWildcard=false), - ).toThrowError("Duplicate event detected: Event1 for contract Contract1 on chain 1") - }) - - it("throws when a second wildcard claims the same eventId", t => { - let validator = HandlerRegister.makeEventIdValidator() - validator->add(~eventId="0xsig", ~contractName="Contract1", ~isWildcard=true) - t.expect( - () => validator->add(~eventId="0xsig", ~contractName="Contract2", ~isWildcard=true), - ).toThrowError( - "Another event is already registered with the same signature that would interfere with wildcard filtering: Event1 for contract Contract2 on chain 1", - ) - }) - - it("accepts two wildcards whose eventIds differ (e.g. SVM program-scoped ids)", t => { - // finishRegistration scopes the SVM key by programId (`${programId}_${id}`), - // so two wildcard instructions sharing a discriminator on different programs - // reach the validator as distinct eventIds and must not collide. - let validator = HandlerRegister.makeEventIdValidator() - validator->add(~eventId="progA_0x0f", ~contractName="ProgA", ~isWildcard=true) - t.expect( - () => validator->add(~eventId="progB_0x0f", ~contractName="ProgB", ~isWildcard=true), - ).not.toThrow() - }) -}) diff --git a/packages/envio-tests/test/HandlerRegister_test.res b/packages/envio-tests/test/HandlerRegister_test.res new file mode 100644 index 000000000..9b71e9031 --- /dev/null +++ b/packages/envio-tests/test/HandlerRegister_test.res @@ -0,0 +1,336 @@ +open Vitest + +// Covers `HandlerRegister`'s multiple-registrations-per-event behaviour: +// every `onEvent` becomes its own registration, a `contractRegister` merges +// into a handler registration (either order) when their filters match, and +// unlimited wildcard registrations are allowed. + +let config = InternalTestIndexer.fromUserApi(~configYaml=` +name: handler-register-test +contracts: + - name: ERC20 + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) + - event: Approval(address indexed owner, address indexed spender, uint256 value) + - name: ERC721 + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) +chains: + - id: 1 + rpc: + url: https://eth.com + for: sync + start_block: 0 + contracts: + - name: ERC20 + address: "0x1111111111111111111111111111111111111111" + - name: ERC721 + address: "0x2222222222222222222222222222222222222222" +`).config + +// Single chain 137 (narrowing target). Used with `configMultichain` to verify +// that one registration resolved for the full chain set narrows to each chain. +let config137 = InternalTestIndexer.fromUserApi(~configYaml=` +name: handler-register-test-137 +contracts: + - name: ERC20 + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) + - event: Approval(address indexed owner, address indexed spender, uint256 value) +chains: + - id: 137 + rpc: + url: https://polygon.com + for: sync + start_block: 0 + contracts: + - name: ERC20 + address: "0x1111111111111111111111111111111111111111" +`).config + +// The full chain set (1 + 137). Handlers register against this; a +// `finishRegistration` for either `config` (chain 1) or `config137` (chain 137) +// narrows to that chain (mirrors MockIndexer registering once, narrowing per run). +let configMultichain = InternalTestIndexer.fromUserApi(~configYaml=` +name: handler-register-multichain +contracts: + - name: ERC20 + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) + - event: Approval(address indexed owner, address indexed spender, uint256 value) +chains: + - id: 1 + rpc: + url: https://eth.com + for: sync + start_block: 0 + contracts: + - name: ERC20 + address: "0x1111111111111111111111111111111111111111" + - id: 137 + rpc: + url: https://polygon.com + for: sync + start_block: 0 + contracts: + - name: ERC20 + address: "0x1111111111111111111111111111111111111111" +`).config + +// raw_events enabled, single chain. Used to verify that a `where: false` event +// is excluded entirely while a handler-less event still gets a bare raw-events +// registration. +let configWithRawEvents = InternalTestIndexer.fromUserApi(~configYaml=` +name: handler-register-raw-events +raw_events: true +contracts: + - name: ERC20 + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) + - event: Approval(address indexed owner, address indexed spender, uint256 value) +chains: + - id: 1 + rpc: + url: https://eth.com + for: sync + start_block: 0 + contracts: + - name: ERC20 + address: "0x1111111111111111111111111111111111111111" +`).config + +// Each handler/contractRegister is a distinct function so registrations can be +// identified by reference in assertions. +let makeHandler = (): Internal.handler => %raw(`() => Promise.resolve()`) +let makeContractRegister = (): Internal.contractRegister => %raw(`() => Promise.resolve()`) + +let setHandler = (~contractName="ERC20", ~eventName="Transfer", ~eventOptions=?, handler) => + HandlerRegister.setHandler( + ~contractName, + ~eventName, + handler->(Utils.magic: Internal.handler => Internal.genericHandler<_>), + ~eventOptions, + ) + +let setContractRegister = ( + ~contractName="ERC20", + ~eventName="Transfer", + ~eventOptions=?, + contractRegister, +) => + HandlerRegister.setContractRegister( + ~contractName, + ~eventName, + contractRegister->(Utils.magic: Internal.contractRegister => Internal.genericContractRegister<_>), + ~eventOptions, + ) + +// Registrations for the given event on chain 1, described as +// `(handlerLabel, contractRegisterLabel, index)` where labels resolve the +// stored function back to the ones registered below. +let describeRegistrations = ( + registrations: HandlerRegister.registrationsByChainId, + ~chainKey="1", + ~contractName="ERC20", + ~eventName="Transfer", + ~labels: array<(Internal.handler, string)>, + ~crLabels: array<(Internal.contractRegister, string)>, +) => { + let handlerLabel = h => + labels->Array.find(((fn, _)) => fn === h)->Option.map(((_, label)) => label)->Option.getOr("?") + let crLabel = cr => + crLabels + ->Array.find(((fn, _)) => fn === cr) + ->Option.map(((_, label)) => label) + ->Option.getOr("?") + let chainRegistrations: HandlerRegister.chainRegistrations = + registrations->Utils.Dict.dangerouslyGetNonOption(chainKey)->Option.getOrThrow + chainRegistrations.onEventRegistrations + ->Array.filter(reg => + reg.eventConfig.contractName === contractName && reg.eventConfig.name === eventName + ) + ->Array.map(reg => ( + reg.handler->Option.map(handlerLabel), + reg.contractRegister->Option.map(crLabel), + reg.index, + )) +} + +let register = fn => { + HandlerRegister.resetOnEventRegistrations() + HandlerRegister.startRegistration(~config) + fn() + HandlerRegister.finishRegistration(~config) +} + +describe("HandlerRegister multiple registrations", () => { + it("keeps two onEvent handlers as separate registrations in registration order", t => { + let h1 = makeHandler() + let h2 = makeHandler() + let registrations = register(() => { + setHandler(h1) + setHandler(h2) + }) + t.expect( + registrations->describeRegistrations( + ~labels=[(h1, "h1"), (h2, "h2")], + ~crLabels=[], + ), + ).toEqual([(Some("h1"), None, 0), (Some("h2"), None, 1)]) + }) + + it("merges a contractRegister into a handler with matching filter", t => { + let h1 = makeHandler() + let cr1 = makeContractRegister() + let registrations = register(() => { + setHandler(h1) + setContractRegister(cr1) + }) + t.expect( + registrations->describeRegistrations( + ~labels=[(h1, "h1")], + ~crLabels=[(cr1, "cr1")], + ), + ).toEqual([(Some("h1"), Some("cr1"), 0)]) + }) + + it("merges a handler into an earlier contractRegister, keeping the handler's slot", t => { + let h1 = makeHandler() + let cr1 = makeContractRegister() + let registrations = register(() => { + setContractRegister(cr1) + setHandler(h1) + }) + t.expect( + registrations->describeRegistrations( + ~labels=[(h1, "h1")], + ~crLabels=[(cr1, "cr1")], + ), + ).toEqual([(Some("h1"), Some("cr1"), 0)]) + }) + + it("keeps a handler and contractRegister with different `where` filters separate", t => { + let h1 = makeHandler() + let cr1 = makeContractRegister() + let registrations = register(() => { + setHandler(h1) + setContractRegister( + ~eventOptions={ + where: %raw(`{"params": {"from": "0x1111111111111111111111111111111111111111"}}`), + }, + cr1, + ) + }) + t.expect( + registrations->describeRegistrations( + ~labels=[(h1, "h1")], + ~crLabels=[(cr1, "cr1")], + ), + ).toEqual([(Some("h1"), None, 0), (None, Some("cr1"), 1)]) + }) + + it("does not merge a wildcard handler with a non-wildcard contractRegister", t => { + let h1 = makeHandler() + let cr1 = makeContractRegister() + let registrations = register(() => { + setHandler(~eventOptions={wildcard: true}, h1) + setContractRegister(cr1) + }) + t.expect( + registrations->describeRegistrations( + ~labels=[(h1, "h1")], + ~crLabels=[(cr1, "cr1")], + ), + ).toEqual([(Some("h1"), None, 0), (None, Some("cr1"), 1)]) + }) + + it("allows multiple wildcard registrations sharing a signature", t => { + let h1 = makeHandler() + let h2 = makeHandler() + let registrations = register(() => { + setHandler(~contractName="ERC20", ~eventOptions={wildcard: true}, h1) + setHandler(~contractName="ERC721", ~eventOptions={wildcard: true}, h2) + }) + t.expect(( + registrations->describeRegistrations( + ~contractName="ERC20", + ~labels=[(h1, "h1")], + ~crLabels=[], + ), + registrations->describeRegistrations( + ~contractName="ERC721", + ~labels=[(h2, "h2")], + ~crLabels=[], + ), + )).toEqual(([(Some("h1"), None, 0)], [(Some("h2"), None, 1)])) + }) + + it("narrows one registration to each requested chain", t => { + // Register once against the full set (chains 1 + 137), then finish for each + // chain separately without re-registering — both must materialize the + // handler (the MockIndexer register-once, narrow-per-run case). + let h1 = makeHandler() + HandlerRegister.resetOnEventRegistrations() + HandlerRegister.startRegistration(~config=configMultichain) + setHandler(h1) + let registrations1 = HandlerRegister.finishRegistration(~config) + let registrations137 = HandlerRegister.finishRegistration(~config=config137) + t.expect(( + registrations1->describeRegistrations(~chainKey="1", ~labels=[(h1, "h1")], ~crLabels=[]), + registrations137->describeRegistrations(~chainKey="137", ~labels=[(h1, "h1")], ~crLabels=[]), + )).toEqual(([(Some("h1"), None, 0)], [(Some("h1"), None, 0)])) + }) + + it("replays pre-registered handlers in source order, not reversed", t => { + // Handlers imported before `startRegistration` are queued in `preRegistered` + // and replayed at start. That replay order is the dispatch order, so two + // handlers on one event must keep their source order (h1 before h2). + let h1 = makeHandler() + let h2 = makeHandler() + HandlerRegister.resetOnEventRegistrations() + setHandler(h1) + setHandler(h2) + HandlerRegister.startRegistration(~config) + let registrations = HandlerRegister.finishRegistration(~config) + t.expect( + registrations->describeRegistrations(~labels=[(h1, "h1"), (h2, "h2")], ~crLabels=[]), + ).toEqual([(Some("h1"), None, 0), (Some("h2"), None, 1)]) + }) + + it("invokes a `where` callback once per chain, even across repeated finishes", t => { + // The `where` callback is resolved once per chain and cached on the intent, + // so re-materializing registrations (finishRegistration, simulate) must not + // call it again. + let calls = ref(0) + let h1 = makeHandler() + let whereFn = _ => { + calls := calls.contents + 1 + true + } + HandlerRegister.resetOnEventRegistrations() + HandlerRegister.startRegistration(~config) + setHandler(~eventOptions={where: whereFn->Obj.magic}, h1) + let _ = HandlerRegister.finishRegistration(~config) + let _ = HandlerRegister.finishRegistration(~config) + t.expect(calls.contents).toBe(1) + }) + + it("excludes a where:false event but backfills a handler-less event for raw events", t => { + // Transfer's handler opts out of the chain via `where: false` → excluded + // entirely (no raw-events registration). Approval has no handler → with raw + // events enabled it gets a bare (handler-less) registration. + let h1 = makeHandler() + HandlerRegister.resetOnEventRegistrations() + HandlerRegister.startRegistration(~config=configWithRawEvents) + setHandler(~eventName="Transfer", ~eventOptions={where: %raw(`() => false`)}, h1) + let registrations = HandlerRegister.finishRegistration(~config=configWithRawEvents) + t.expect(( + registrations->describeRegistrations( + ~eventName="Transfer", + ~labels=[(h1, "h1")], + ~crLabels=[], + ), + registrations->describeRegistrations(~eventName="Approval", ~labels=[], ~crLabels=[]), + )).toEqual(([], [(None, None, 0)])) + }) +}) diff --git a/packages/envio-tests/test/UserApiValidation_test.res b/packages/envio-tests/test/UserApiValidation_test.res index 25140f4ba..1c201f9da 100644 --- a/packages/envio-tests/test/UserApiValidation_test.res +++ b/packages/envio-tests/test/UserApiValidation_test.res @@ -479,6 +479,61 @@ chains: }) describe("system config validation errors", () => { + it("rejects two differently-named events that share a dispatch signature", t => { + expectParseError( + t, + ` +name: duplicate-event +contracts: + - name: Token + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) + - event: Transfer(address indexed from, address indexed to, uint256 value) + name: Transfer2 +chains: + - id: 1 + start_block: 0 +`, + `Config parse error: Failed parsing globally defined contract: Contract Token has two events the indexer can't tell apart: "Transfer" and "Transfer2". They match the same on-chain data, so the indexer can't decide which one a log belongs to. Please remove one of them.`, + ) + }) + + it("rejects a byte-identical duplicate event, pointing at removal", t => { + expectParseError( + t, + ` +name: duplicate-event +contracts: + - name: Token + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) + - event: Transfer(address indexed from, address indexed to, uint256 value) +chains: + - id: 1 + start_block: 0 +`, + `Config parse error: Failed parsing globally defined contract: Contract Token defines the event "Transfer" more than once. Please remove the duplicate.`, + ) + }) + + it("rejects two events with the same name but different signatures, suggesting the name alias", t => { + expectParseError( + t, + ` +name: overloaded-event +contracts: + - name: Token + events: + - event: Transfer(address from) + - event: Transfer(address indexed from, address indexed to, uint256 value) +chains: + - id: 1 + start_block: 0 +`, + `Config parse error: Failed parsing globally defined contract: Contract Token has two events named "Transfer". Give one of them a unique name with the "name" field so the generated code and the indexer's routing can tell them apart.`, + ) + }) + it("preserves the root cause from nested Rust error contexts", t => { expectParseError( t, @@ -809,6 +864,62 @@ chains: }) describe("config YAML success cases", () => { + it("allows distinct events on one contract and the same event across contracts", t => { + // Distinct signatures on one contract are fine, and the same event on two + // different contracts is allowed — routing scopes matches by contract. + let {config} = InternalTestIndexer.fromUserApi(~configYaml=` +name: distinct-and-cross-contract-events +contracts: + - name: ERC20 + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) + - event: Approval(address indexed owner, address indexed spender, uint256 value) + - name: ERC721 + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) +chains: + - id: 1 + rpc: + url: https://eth.com + for: sync + start_block: 0 + contracts: + - name: ERC20 + address: "0x1111111111111111111111111111111111111111" + - name: ERC721 + address: "0x2222222222222222222222222222222222222222" +`) + let chain = config.chainMap->ChainMap.values->Array.getUnsafe(0) + t.expect(chain.contracts->Array.length).toBe(2) + }) + + it("allows two same-named overloads once a name alias disambiguates them", t => { + // Two events resolving to the name "Transfer" would clash, but the alias on + // one gives them distinct names (and their signatures differ, so no dispatch + // collision either). + let {config} = InternalTestIndexer.fromUserApi(~configYaml=` +name: aliased-overload +contracts: + - name: Token + events: + - event: Transfer(address from) + name: TransferSimple + - event: Transfer(address indexed from, address indexed to, uint256 value) +chains: + - id: 1 + rpc: + url: https://eth.com + for: sync + start_block: 0 + contracts: + - name: Token + address: "0x1111111111111111111111111111111111111111" +`) + let chain = config.chainMap->ChainMap.values->Array.getUnsafe(0) + let contract = chain.contracts->Array.getUnsafe(0) + t.expect(contract.events->Array.map(e => e.name)).toEqual(["TransferSimple", "Transfer"]) + }) + it("parses a minimal Fuel config through the public boundary", t => { let {config} = InternalTestIndexer.fromUserApi(~configYaml=` name: fuel-config @@ -1019,6 +1130,17 @@ chains: `, "Config parse error: Program \"Program\" declares the instruction \"Transfer\" more than once", ), + ( + "rejects two instructions whose discriminators differ only in hex casing", + prefix ++ ` + - name: Program + program_id: metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s + instructions: + - {name: Transfer, discriminator: "0x0f"} + - {name: Withdraw, discriminator: "0x0F"} +`, + `Config parse error: Contract Program has two events the indexer can't tell apart: "Transfer" and "Withdraw". They match the same on-chain data, so the indexer can't decide which one a log belongs to. Please remove one of them.`, + ), ( "rejects invalid discriminators", prefix ++ ` diff --git a/packages/envio/src/EnvioGlobal.res b/packages/envio/src/EnvioGlobal.res index 8e1fd30bc..d5258e8ff 100644 --- a/packages/envio/src/EnvioGlobal.res +++ b/packages/envio/src/EnvioGlobal.res @@ -15,7 +15,6 @@ // deduplication hint instead of silently mixing shapes across builds. type t = { version: string, - eventRegistrations: dict, mutable activeRegistration: option, preRegistered: array, rollbackCommitCallbacks: array, @@ -40,7 +39,6 @@ let value: t = { | None => let fresh = { version, - eventRegistrations: Dict.make(), activeRegistration: None, preRegistered: [], rollbackCommitCallbacks: [], diff --git a/packages/envio/src/HandlerRegister.res b/packages/envio/src/HandlerRegister.res index dfae00a37..b51226ad0 100644 --- a/packages/envio/src/HandlerRegister.res +++ b/packages/envio/src/HandlerRegister.res @@ -1,19 +1,7 @@ -type eventRegistration = { - handler: option, - contractRegister: option, - eventOptions: option>, -} - -let empty = { - handler: None, - contractRegister: None, - eventOptions: None, -} - -// Per-chain onEventRegistrations built from the event definitions in -// `Config.t` plus whatever handler/contractRegister/eventOptions got -// registered for them, and the onBlock registrations collected during -// registration. +// Per-chain onEvent + onBlock registrations. Used both as the live registration +// store (`activeRegistration`, where onEvent regs are raw: registration order, +// unmerged, unindexed, `where:false` ones kept) and as the finished output of +// `finishRegistration` (merged, backfilled, indexed). type chainRegistrations = { onEventRegistrations: array, onBlockRegistrations: array, @@ -22,38 +10,24 @@ type chainRegistrations = { // The finished registration state returned by `finishRegistration`. type registrationsByChainId = dict -// Incrementally built during registration: every `indexer.onEvent` / -// `.contractRegister` call resolves its `where` per chain right away and -// stores the resulting registration here, keyed by "Contract.Event", so -// invalid configuration throws at the user's registration call site. -type pendingChainRegistrations = { - onEventRegistrations: dict, - onBlockRegistrations: array, -} - +// The one registration, resolved once and reused. Handlers register into it at +// `onEvent`/`onBlock` call time (resolving for every chain in `config`, which is +// the full chain set), and it persists in `EnvioGlobal` across the many +// `finishRegistration` calls a single isolate makes (handler modules are +// import-cached and register only once). `finishRegistration` reads it and +// builds a fresh per-config output, never mutating the store. type activeRegistration = { config: Config.t, - registrationsByChainId: dict, + registrationsByChainId: dict, mutable finished: bool, } -// Registration state lives in the process-wide `EnvioGlobal` record (shared -// across duplicate envio module instances); the slots are opaque there, so -// cast them to the real types once here. -let eventRegistrations = - EnvioGlobal.value.eventRegistrations->(Utils.magic: dict => dict) - let getKey = (~contractName, ~eventName) => contractName ++ "." ++ eventName -let get = (~contractName, ~eventName) => { - switch eventRegistrations->Utils.Dict.dangerouslyGetNonOption(getKey(~contractName, ~eventName)) { - | Some(existing) => existing - | None => empty - } -} - -let set = (~contractName, ~eventName, registration) => { - eventRegistrations->Dict.set(getKey(~contractName, ~eventName), registration) +// Test-only: reset to fresh-import state so a new registration cycle starts +// empty (production starts each isolate empty and registers once). +let resetOnEventRegistrations = () => { + EnvioGlobal.value.activeRegistration = None } let getActiveRegistration = () => @@ -85,29 +59,36 @@ let withRegistration = (fn: activeRegistration => unit) => { } } +// Idempotent: handlers register once (import-cached), so the first call builds +// the registration and every later call reuses it. `config` must be the full +// chain set — registrations resolve for all its chains here, and +// `finishRegistration` later narrows to whatever config it's given. let startRegistration = (~config: Config.t) => { - let r = { - config, - registrationsByChainId: Dict.make(), - finished: false, - } - EnvioGlobal.value.activeRegistration = Some(r->(Utils.magic: activeRegistration => unknown)) - while preRegistered->Array.length > 0 { - // Loop + cleanup in one go - switch preRegistered->Array.pop { - | Some(fn) => fn(r) - | None => () + switch getActiveRegistration() { + | Some(_) => () + | None => + let r = { + config, + registrationsByChainId: Dict.make(), + finished: false, } + EnvioGlobal.value.activeRegistration = Some(r->(Utils.magic: activeRegistration => unknown)) + // Replay pre-registered callbacks in source (FIFO) order, then clear. For + // multiple handlers on one event this replay order is the dispatch order, so + // it must not reverse (which `Array.pop` would). + let queued = preRegistered->Array.copy + preRegistered->Array.splice(~start=0, ~remove=preRegistered->Array.length, ~insert=[]) + queued->Array.forEach(fn => fn(r)) } } -let getPendingChainRegistrations = (r: activeRegistration, ~chainId: int) => { +let getChainRegistrations = (r: activeRegistration, ~chainId: int): chainRegistrations => { let key = chainId->Int.toString switch r.registrationsByChainId->Utils.Dict.dangerouslyGetNonOption(key) { - | Some(pending) => pending + | Some(existing) => existing | None => - let fresh = { - onEventRegistrations: Dict.make(), + let fresh: chainRegistrations = { + onEventRegistrations: [], onBlockRegistrations: [], } r.registrationsByChainId->Dict.set(key, fresh) @@ -158,268 +139,367 @@ let buildOnEventRegistrationWith = ( } } -// Enrich one event definition into its (event, chain) registration using -// whatever handler/contractRegister/where the user registered for it. Shared -// by the incremental per-chain sync below, `simulate`, and test helpers so -// they stay in sync instead of re-deriving the per-ecosystem dispatch each -// place. -let buildOnEventRegistration = ( - ~config: Config.t, - ~chainId: int, - ~eventConfig: Internal.eventConfig, - ~startBlock=?, -): Internal.onEventRegistration => { - let t = get(~contractName=eventConfig.contractName, ~eventName=eventConfig.name) - buildOnEventRegistrationWith( - ~config, - ~chainId, - ~eventConfig, - ~isWildcard=t.eventOptions->Option.flatMap(v => v.wildcard)->Option.getOr(false), - ~handler=t.handler, - ~contractRegister=t.contractRegister, - ~where=t.eventOptions->Option.flatMap(v => v.where), - ~startBlock?, - ) -} - -let getHandler = (~contractName, ~eventName) => get(~contractName, ~eventName).handler - -let getContractRegister = (~contractName, ~eventName) => - get(~contractName, ~eventName).contractRegister - -let isWildcard = (~contractName, ~eventName) => - get(~contractName, ~eventName).eventOptions - ->Option.flatMap(value => value.wildcard) - ->Option.getOr(false) - -let hasRegistration = (~contractName, ~eventName) => { - let r = get(~contractName, ~eventName) - r.handler->Option.isSome || r.contractRegister->Option.isSome -} - -type eventNamespace = {contractName: string, eventName: string} - -let raiseDuplicateRegistration = (~contractName, ~eventName, ~msg, ~logger) => { - let fullMsg = msg ++ " for " ++ contractName ++ "." ++ eventName - Logging.createChildFrom(~logger, ~params={contractName, eventName})->Logging.childError(fullMsg) - JsError.throwWithMessage(fullMsg) -} - -// `where` equality is checked per chain on the resolved structure (see -// `syncOnEventRegistrations`), so registration options only need to agree on -// `wildcard` here — two callbacks that resolve to identical filters count as -// identical options even when the function references differ. -let eventOptionsMatch = ( - existing: option>, - incoming: option>, -) => { - switch (existing, incoming) { - | (None, None) => true - | (Some(a), Some(b)) => a.wildcard === b.wildcard - | _ => false - } -} - let getResolvedWhere = (reg: Internal.onEventRegistration) => ( reg->(Utils.magic: Internal.onEventRegistration => Internal.evmOnEventRegistration) ).resolvedWhere -// Resolve the registration for every configured chain that defines the event -// and store it in the pending per-chain registry. When the chain already -// holds a registration for this event, the resolved `where` structures must -// deep-compare equal (`Values` by hex arrays, `ContractAddresses` by contract -// name, plus `startBlock`) — otherwise it's a conflicting duplicate -// registration. Both live registrations and `preRegistered` callbacks -// replayed by `startRegistration` run through this single code path. -let syncOnEventRegistrations = ( - r: activeRegistration, - ~contractName, - ~eventName, - ~where: option, - ~duplicateMsg, - ~logger, -) => { - let config = r.config - let t = get(~contractName, ~eventName) - let isWildcard = t.eventOptions->Option.flatMap(v => v.wildcard)->Option.getOr(false) - let key = getKey(~contractName, ~eventName) +// Two chain registrations target the same fetched log when they share the +// event, the wildcard flag, and (on EVM) the resolved `where` — a handler and +// a contractRegister that agree on all three can be merged into one +// registration (so one item per log runs both). `where` is compared on the +// resolved structure (`Values` by hex arrays, `ContractAddresses` by contract +// name, plus `startBlock`); differing filters stay separate registrations. +let sameEventAndFilter = ( + a: Internal.onEventRegistration, + b: Internal.onEventRegistration, + ~config: Config.t, +) => + a.eventConfig.contractName === b.eventConfig.contractName && + a.eventConfig.name === b.eventConfig.name && + a.isWildcard === b.isWildcard && + switch config.ecosystem.name { + | Evm => getResolvedWhere(a) == getResolvedWhere(b) + | Fuel | Svm => true + } - config.chainMap - ->ChainMap.values - ->Array.forEach(chainConfig => { - chainConfig.contracts->Array.forEach(contract => { - if contract.name === contractName { - switch contract.events->Array.find(e => e.name === eventName) { - | None => () - | Some(eventConfig) => - let newRegistration = buildOnEventRegistrationWith( - ~config, - ~chainId=chainConfig.id, - ~eventConfig, - ~isWildcard, - ~handler=t.handler, - ~contractRegister=t.contractRegister, - ~where, - ~startBlock=?contract.startBlock, - ) - let pending = r->getPendingChainRegistrations(~chainId=chainConfig.id) - switch pending.onEventRegistrations->Utils.Dict.dangerouslyGetNonOption(key) { - | Some(existing) if config.ecosystem.name === Evm => - if !(existing->getResolvedWhere == newRegistration->getResolvedWhere) { - raiseDuplicateRegistration(~contractName, ~eventName, ~msg=duplicateMsg, ~logger) - } - | _ => () - } - pending.onEventRegistrations->Dict.set(key, newRegistration) - } +// Merge each contractRegister into a matching handler registration (either +// registration order; the merged registration takes the handler's slot so +// dispatch order follows handler registration order). Two handlers (or two +// contractRegisters) for one event never merge. Operates on the raw per-chain +// registrations stored at `onEvent` time; shared by `finishRegistration` and +// simulate so both see the same registrations. +let mergeRegistrations = (resolved: array, ~config: Config.t): array< + Internal.onEventRegistration, +> => { + let merged: ref> = ref([]) + resolved->Array.forEach((reg: Internal.onEventRegistration) => { + if reg.handler->Option.isSome { + // A handler absorbs a matching contractRegister-only registration, + // dropping it and taking its own (handler) slot. + switch merged.contents->Array.findIndex(m => + m.handler->Option.isNone && + m.contractRegister->Option.isSome && + sameEventAndFilter(m, reg, ~config) + ) { + | -1 => merged := merged.contents->Array.concat([reg]) + | i => + let target = merged.contents->Array.getUnsafe(i) + merged := + merged.contents + ->Array.filterWithIndex((_, j) => j !== i) + ->Array.concat([{...reg, contractRegister: target.contractRegister}]) } - }) + } else { + // A contractRegister merges into a matching handler registration, + // keeping the handler's slot. + switch merged.contents->Array.findIndex(m => + m.handler->Option.isSome && + m.contractRegister->Option.isNone && + sameEventAndFilter(m, reg, ~config) + ) { + | -1 => merged := merged.contents->Array.concat([reg]) + | i => + let target = merged.contents->Array.getUnsafe(i) + let next = merged.contents->Array.copy + next->Array.setUnsafe(i, {...target, contractRegister: reg.contractRegister}) + merged := next + } + } }) + merged.contents } -let setEventOptions = (~contractName, ~eventName, ~eventOptions, ~logger=Logging.getLogger()) => { - switch eventOptions { - | Some(value) => - let value = value->(Utils.magic: Internal.eventOptions<'where> => Internal.eventOptions) - let t = get(~contractName, ~eventName) - switch t.eventOptions { - | None => set(~contractName, ~eventName, {...t, eventOptions: Some(value)}) - | Some(existingValue) => - if !eventOptionsMatch(Some(existingValue), Some(value)) { - raiseDuplicateRegistration( - ~contractName, - ~eventName, - ~msg="Cannot register handler with different options. Make sure all handlers for the same event use identical options (wildcard, where)", - ~logger, +// A `where` that resolved to no topic selections (`false` for this chain) +// should never be fetched or dispatched here — drop it. Only meaningful on EVM. +let isDroppedByWhere = (~config: Config.t, reg: Internal.onEventRegistration) => + config.ecosystem.name === Evm && (reg->getResolvedWhere).topicSelections->Utils.Array.isEmpty + +// Resolve one `onEvent`/`contractRegister` call into a registration for every +// chain in the config (the full chain set) and store it in registration order. +// Building runs the user's `where` callback here — once per chain — so a broken +// filter throws at the call site. A chain that doesn't define the event is +// skipped. +let addOnEventRegistration = ( + registration: activeRegistration, + ~contractName, + ~eventName, + ~handler: option, + ~contractRegister: option, + ~eventOptions: option>, +) => { + let isWildcard = eventOptions->Option.flatMap(v => v.wildcard)->Option.getOr(false) + let where = eventOptions->Option.flatMap(v => v.where) + registration.config.chainMap + ->ChainMap.values + ->Array.forEach(chainConfig => + switch chainConfig.contracts->Array.find(c => c.name === contractName) { + | None => () + | Some(contract) => + switch contract.events->Array.find(e => e.name === eventName) { + | None => () + | Some(eventConfig) => + let reg = buildOnEventRegistrationWith( + ~config=registration.config, + ~chainId=chainConfig.id, + ~eventConfig, + ~isWildcard, + ~handler, + ~contractRegister, + ~where, + ~startBlock=?contract.startBlock, ) + (registration->getChainRegistrations(~chainId=chainConfig.id)).onEventRegistrations + ->Array.push(reg) + ->ignore } } - | None => () - } + ) } -let setHandler = ( - ~contractName, - ~eventName, - handler, - ~eventOptions, - ~logger=Logging.getLogger(), -) => { +let setHandler = (~contractName, ~eventName, handler, ~eventOptions) => { withRegistration(registration => { - let t = get(~contractName, ~eventName) let newHandler = handler->(Utils.magic: Internal.genericHandler<'args> => Internal.handler) - let incomingEventOptions = + let eventOptions = eventOptions->Option.map(v => v->(Utils.magic: Internal.eventOptions<'where> => Internal.eventOptions) ) - switch t.handler { - | None => - setEventOptions(~contractName, ~eventName, ~eventOptions, ~logger) - let t = get(~contractName, ~eventName) - set( - ~contractName, - ~eventName, - { - ...t, - handler: Some(newHandler), - }, - ) - | Some(prevHandler) => - if eventOptionsMatch(t.eventOptions, incomingEventOptions) { - let composedHandler: Internal.handler = async args => { - await prevHandler(args) - await newHandler(args) - } - set( - ~contractName, - ~eventName, - { - ...t, - handler: Some(composedHandler), - }, - ) - } else { - raiseDuplicateRegistration( - ~contractName, - ~eventName, - ~msg="Cannot register a second handler with different options. Make sure all handlers for the same event use identical options (wildcard, where)", - ~logger, - ) - } - } - registration->syncOnEventRegistrations( + registration->addOnEventRegistration( ~contractName, ~eventName, - ~where=incomingEventOptions->Option.flatMap(v => v.where), - ~duplicateMsg="Cannot register a second handler with different options. Make sure all handlers for the same event use identical options (wildcard, where)", - ~logger, + ~handler=Some(newHandler), + ~contractRegister=None, + ~eventOptions, ) }) } -let setContractRegister = ( - ~contractName, - ~eventName, - contractRegister, - ~eventOptions, - ~logger=Logging.getLogger(), -) => { +let setContractRegister = (~contractName, ~eventName, contractRegister, ~eventOptions) => { withRegistration(registration => { - let t = get(~contractName, ~eventName) let newContractRegister = contractRegister->( Utils.magic: Internal.genericContractRegister< Internal.genericContractRegisterArgs<'event, 'context>, > => Internal.contractRegister ) - let incomingEventOptions = + let eventOptions = eventOptions->Option.map(v => v->(Utils.magic: Internal.eventOptions<'where> => Internal.eventOptions) ) - switch t.contractRegister { - | None => - setEventOptions(~contractName, ~eventName, ~eventOptions, ~logger) - let t = get(~contractName, ~eventName) - set( - ~contractName, - ~eventName, - { - ...t, - contractRegister: Some(newContractRegister), - }, + registration->addOnEventRegistration( + ~contractName, + ~eventName, + ~handler=None, + ~contractRegister=Some(newContractRegister), + ~eventOptions, + ) + }) +} + +// Raw onEvent registrations stored for a chain (empty if the chain has none). +let storedOnEventRegistrations = (r: activeRegistration, ~chainId: int): array< + Internal.onEventRegistration, +> => + switch r.registrationsByChainId->Utils.Dict.dangerouslyGetNonOption(chainId->Int.toString) { + | Some(chainRegs) => chainRegs.onEventRegistrations + | None => [] + } + +// True when any registration for the event is a wildcard. Used by simulate to +// decide whether a src address needs deriving. +let isWildcard = (~contractName, ~eventName) => + switch getActiveRegistration() { + | Some(r) => + r.registrationsByChainId + ->Dict.valuesToArray + ->Array.some(chainRegs => + chainRegs.onEventRegistrations->Array.some(reg => + reg.eventConfig.contractName === contractName && + reg.eventConfig.name === eventName && + reg.isWildcard ) - | Some(prevContractRegister) => - if eventOptionsMatch(t.eventOptions, incomingEventOptions) { - let composedContractRegister: Internal.contractRegister = async args => { - await prevContractRegister(args) - await newContractRegister(args) - } - set( - ~contractName, - ~eventName, + ) + | None => false + } + +// Every registration for one event on a chain, so simulate fans a simulated +// event out to each the way real routing does. Falls back to a bare +// registration when the event has no handler/contractRegister, so a simulated +// item still produces an item to run. +let getSimulateOnEventRegistrations = ( + ~config: Config.t, + ~chainId: int, + ~eventConfig: Internal.eventConfig, +): array => { + let stored = switch getActiveRegistration() { + | Some(r) => r->storedOnEventRegistrations(~chainId) + | None => [] + } + let matching = + mergeRegistrations(stored, ~config)->Array.filter(reg => + reg.eventConfig.contractName === eventConfig.contractName && + reg.eventConfig.name === eventConfig.name + ) + if matching->Utils.Array.notEmpty { + matching + } else { + [ + buildOnEventRegistrationWith( + ~config, + ~chainId, + ~eventConfig, + ~isWildcard=false, + ~handler=None, + ~contractRegister=None, + ~where=None, + ), + ] + } +} + +let finishRegistration = (~config: Config.t): registrationsByChainId => { + switch getActiveRegistration() { + | Some(r) => { + r.finished = true + let notRegisteredEventsByContract: dict> = Dict.make() + let registrationsByChainId: registrationsByChainId = Dict.make() + config.chainMap + ->ChainMap.values + ->Array.forEach(chainConfig => { + let chainId = chainConfig.id + let key = chainId->Int.toString + + let builtRegs = mergeRegistrations(r->storedOnEventRegistrations(~chainId), ~config) + let registeredKeys = Utils.Set.make() + builtRegs->Array.forEach(reg => + registeredKeys + ->Utils.Set.add( + getKey(~contractName=reg.eventConfig.contractName, ~eventName=reg.eventConfig.name), + ) + ->ignore + ) + + // Events with no handler/contractRegister aren't fetched or dispatched + // unless raw events are enabled, in which case a bare registration is + // added to fetch them. Otherwise they're reported once below. Keyed on + // the resolved registrations (before the where-empty drop) so a + // `where: false` event still counts as registered — its handler opted + // out of this chain, so it gets no raw-event registration either. + let rawEventRegs = [] + chainConfig.contracts->Array.forEach(contract => { + contract.events->Array.forEach( + eventConfig => { + if ( + !( + registeredKeys->Utils.Set.has( + getKey(~contractName=contract.name, ~eventName=eventConfig.name), + ) + ) + ) { + if config.enableRawEvents { + rawEventRegs + ->Array.push( + buildOnEventRegistrationWith( + ~config, + ~chainId, + ~eventConfig, + ~isWildcard=false, + ~handler=None, + ~contractRegister=None, + ~where=None, + ~startBlock=?contract.startBlock, + ), + ) + ->ignore + } else { + let eventNames = switch notRegisteredEventsByContract->Utils.Dict.dangerouslyGetNonOption( + contract.name, + ) { + | Some(set) => set + | None => { + let set = Utils.Set.make() + notRegisteredEventsByContract->Dict.set(contract.name, set) + set + } + } + eventNames->Utils.Set.add(eventConfig.name)->ignore + } + } + }, + ) + }) + + // Drop registrations whose `where` opts out of this chain, then assign + // each survivor its chain-scoped index by position. + let onEventRegistrations: array = [] + builtRegs + ->Array.concat(rawEventRegs) + ->Array.forEach(reg => { + if !isDroppedByWhere(~config, reg) { + onEventRegistrations + ->Array.push({...reg, index: onEventRegistrations->Array.length}) + ->ignore + } + }) + + registrationsByChainId->Dict.set( + key, { - ...t, - contractRegister: Some(composedContractRegister), + onEventRegistrations, + // Copy so a consumer appending to the output (e.g. simulate source + // registration) never mutates the persistent store. + onBlockRegistrations: switch r.registrationsByChainId->Utils.Dict.dangerouslyGetNonOption( + key, + ) { + | Some(chainRegs) => chainRegs.onBlockRegistrations->Array.copy + | None => [] + }, }, ) - } else { - raiseDuplicateRegistration( - ~contractName, - ~eventName, - ~msg="Cannot register a second contractRegister with different options. Make sure all handlers for the same event use identical options (wildcard, where)", - ~logger, + }) + + // Reported once for the whole indexer (a shared contract on multiple + // chains would otherwise repeat the same message per chain). + let notRegisteredEntries = notRegisteredEventsByContract->Dict.toArray + if notRegisteredEntries->Utils.Array.notEmpty { + let groups = + notRegisteredEntries + ->Array.map(((contractName, eventNames)) => + `${contractName} (${eventNames->Utils.Set.toArray->Array.joinUnsafe(", ")})` + ) + ->Array.joinUnsafe(", ") + Logging.getLogger()->Logging.childInfo( + `Events without a handler, skipped for indexing: ${groups}`, ) } + + registrationsByChainId } - registration->syncOnEventRegistrations( - ~contractName, - ~eventName, - ~where=incomingEventOptions->Option.flatMap(v => v.where), - ~duplicateMsg="Cannot register a second contractRegister with different options. Make sure all handlers for the same event use identical options (wildcard, where)", - ~logger, + | None => + JsError.throwWithMessage( + "The indexer has not started registering handlers, so can't finish it.", ) - }) + } +} + +let isPendingRegistration = () => { + switch getActiveRegistration() { + | Some(r) => !r.finished + | None => false + } +} + +// Early guard called from `indexer.onEvent` / `.contractRegister` / `.onBlock` / +// `.onSlot` so the user sees a method-specific error at the call site, instead +// of hitting the generic `withRegistration` throw deep inside `setHandler` etc. +let throwIfFinishedRegistration = (~methodName) => { + switch getActiveRegistration() { + | Some({finished: true}) => + JsError.throwWithMessage( + `Cannot call \`indexer.${methodName}\` after the indexer has started. Make sure all handlers are registered at the top level of your handler module.`, + ) + | _ => () + } } // Shape of the user-returned `{_gte?, _lte?, _every?}` filter chunk after @@ -557,12 +637,12 @@ let registerOnBlock = ( } | None => () } - let pending = registration->getPendingChainRegistrations(~chainId) - pending.onBlockRegistrations + let chainRegs = registration->getChainRegistrations(~chainId) + chainRegs.onBlockRegistrations ->Array.push( ( { - index: pending.onBlockRegistrations->Array.length, + index: chainRegs.onBlockRegistrations->Array.length, name, startBlock: range._gte, endBlock: range._lte, @@ -587,218 +667,3 @@ let registerOnBlock = ( } }) } - -// Per-eventId dispatch validation state: two events on one contract can't -// share a dispatch id, and only one wildcard may claim it — Rust-side routing -// fans a log/receipt/instruction out by these ids, so a collision would -// double-deliver. Scoped per chain (a fresh validator per chain iteration). -type eventIdValidator = { - contractNamesByEventId: dict>, - wildcardEventIds: Utils.Set.t, -} - -let makeEventIdValidator = (): eventIdValidator => { - contractNamesByEventId: Dict.make(), - wildcardEventIds: Utils.Set.make(), -} - -let validateEventIdOrThrow = ( - validator: eventIdValidator, - ~eventId, - ~contractName, - ~eventName, - ~isWildcard, - ~chainId, -) => { - let chainSuffix = `on chain ${chainId->Int.toString}` - let contractNames = switch validator.contractNamesByEventId->Utils.Dict.dangerouslyGetNonOption( - eventId, - ) { - | Some(contractNames) => contractNames - | None => { - let contractNames = Utils.Set.make() - validator.contractNamesByEventId->Dict.set(eventId, contractNames) - contractNames - } - } - if contractNames->Utils.Set.has(contractName) { - JsError.throwWithMessage( - `Duplicate event detected: ${eventName} for contract ${contractName} ${chainSuffix}`, - ) - } - if isWildcard && validator.wildcardEventIds->Utils.Set.has(eventId) { - JsError.throwWithMessage( - `Another event is already registered with the same signature that would interfere with wildcard filtering: ${eventName} for contract ${contractName} ${chainSuffix}`, - ) - } - if isWildcard { - validator.wildcardEventIds->Utils.Set.add(eventId)->ignore - } - contractNames->Utils.Set.add(contractName)->ignore -} - -let finishRegistration = (~config: Config.t): registrationsByChainId => { - switch getActiveRegistration() { - | Some(r) => { - r.finished = true - let notRegisteredEventsByContract: dict> = Dict.make() - let registrationsByChainId: registrationsByChainId = Dict.make() - config.chainMap - ->ChainMap.values - ->Array.forEach(chainConfig => { - let key = chainConfig.id->Int.toString - let pending = r.registrationsByChainId->Utils.Dict.dangerouslyGetNonOption(key) - - let eventIdValidator = makeEventIdValidator() - - let onEventRegistrations: array = [] - - chainConfig.contracts->Array.forEach(contract => { - let contractName = contract.name - - contract.events->Array.forEach( - eventConfig => { - let eventName = eventConfig.name - let registration = - pending->Option.flatMap( - pending => - pending.onEventRegistrations->Utils.Dict.dangerouslyGetNonOption( - getKey(~contractName, ~eventName), - ), - ) - - // SVM dispatch is keyed by (programId, discriminator), not the - // discriminator alone — `eventConfig.id` is only the - // discriminator (or "none"). Scope the SVM validation key by - // programId so two wildcard handlers on different programs that - // share a discriminator aren't rejected as a false collision - // (Rust routing already scopes matches by program_id). - let eventId = switch config.ecosystem.name { - | Svm => - let svmEventConfig = - eventConfig->( - Utils.magic: Internal.eventConfig => Internal.svmInstructionEventConfig - ) - `${svmEventConfig.programId->SvmTypes.Pubkey.toString}_${eventConfig.id}` - | Evm | Fuel => eventConfig.id - } - eventIdValidator->validateEventIdOrThrow( - ~eventId, - ~contractName, - ~eventName, - ~isWildcard=switch registration { - | Some(registration) => registration.isWildcard - | None => isWildcard(~contractName, ~eventName) - }, - ~chainId=chainConfig.id, - ) - - let registration = switch registration { - | Some(_) as registration => registration - | None => - // No entry in the incremental store, but the persistent dict - // may still hold a handler: handler modules are import-cached, - // so a repeated registration cycle in the same process (tests - // restarting the indexer) never re-runs the `indexer.onEvent` - // calls. Rebuild from the dict in that case. Events without a - // handler/contractRegister aren't fetched or dispatched - // (unless raw events are enabled). - if hasRegistration(~contractName, ~eventName) || config.enableRawEvents { - Some( - buildOnEventRegistration( - ~config, - ~chainId=chainConfig.id, - ~eventConfig, - ~startBlock=?contract.startBlock, - ), - ) - } else { - let eventNames = switch notRegisteredEventsByContract->Utils.Dict.dangerouslyGetNonOption( - contractName, - ) { - | Some(set) => set - | None => { - let set = Utils.Set.make() - notRegisteredEventsByContract->Dict.set(contractName, set) - set - } - } - eventNames->Utils.Set.add(eventName)->ignore - None - } - } - - switch registration { - | Some(registration) => - // A `where` that resolved to no topic selections (`false` for - // this chain) drops the chain's registration entirely — the - // event should never be fetched here. - let isDroppedByWhere = - config.ecosystem.name === Evm && - (registration->getResolvedWhere).topicSelections->Utils.Array.isEmpty - if !isDroppedByWhere { - onEventRegistrations - ->Array.push({...registration, index: onEventRegistrations->Array.length}) - ->ignore - } - | None => () - } - }, - ) - }) - - registrationsByChainId->Dict.set( - key, - { - onEventRegistrations, - onBlockRegistrations: switch pending { - | Some(pending) => pending.onBlockRegistrations - | None => [] - }, - }, - ) - }) - - // Reported once for the whole indexer (a shared contract on multiple - // chains would otherwise repeat the same message per chain). - let notRegisteredEntries = notRegisteredEventsByContract->Dict.toArray - if notRegisteredEntries->Utils.Array.notEmpty { - let groups = - notRegisteredEntries - ->Array.map(((contractName, eventNames)) => - `${contractName} (${eventNames->Utils.Set.toArray->Array.joinUnsafe(", ")})` - ) - ->Array.joinUnsafe(", ") - Logging.getLogger()->Logging.childInfo( - `Events without a handler, skipped for indexing: ${groups}`, - ) - } - - registrationsByChainId - } - | None => - JsError.throwWithMessage( - "The indexer has not started registering handlers, so can't finish it.", - ) - } -} - -let isPendingRegistration = () => { - switch getActiveRegistration() { - | Some(r) => !r.finished - | None => false - } -} - -// Early guard called from `indexer.onEvent` / `.contractRegister` / `.onBlock` / -// `.onSlot` so the user sees a method-specific error at the call site, instead -// of hitting the generic `withRegistration` throw deep inside `setHandler` etc. -let throwIfFinishedRegistration = (~methodName) => { - switch getActiveRegistration() { - | Some({finished: true}) => - JsError.throwWithMessage( - `Cannot call \`indexer.${methodName}\` after the indexer has started. Make sure all handlers are registered at the top level of your handler module.`, - ) - | _ => () - } -} diff --git a/packages/envio/src/HandlerRegister.resi b/packages/envio/src/HandlerRegister.resi index 401050fac..632a19b0a 100644 --- a/packages/envio/src/HandlerRegister.resi +++ b/packages/envio/src/HandlerRegister.resi @@ -5,49 +5,30 @@ type chainRegistrations = { type registrationsByChainId = dict let startRegistration: (~config: Config.t) => unit +let resetOnEventRegistrations: unit => unit let isPendingRegistration: unit => bool let finishRegistration: (~config: Config.t) => registrationsByChainId let throwIfFinishedRegistration: (~methodName: string) => unit -type eventIdValidator -let makeEventIdValidator: unit => eventIdValidator -let validateEventIdOrThrow: ( - eventIdValidator, - ~eventId: string, - ~contractName: string, - ~eventName: string, - ~isWildcard: bool, - ~chainId: int, -) => unit - -let buildOnEventRegistration: ( - ~config: Config.t, - ~chainId: int, - ~eventConfig: Internal.eventConfig, - ~startBlock: int=?, -) => Internal.onEventRegistration - let setHandler: ( ~contractName: string, ~eventName: string, Internal.genericHandler>, ~eventOptions: option>, - ~logger: Pino.t=?, ) => unit let setContractRegister: ( ~contractName: string, ~eventName: string, Internal.genericContractRegister>, ~eventOptions: option>, - ~logger: Pino.t=?, ) => unit -let getHandler: (~contractName: string, ~eventName: string) => option -let getContractRegister: ( - ~contractName: string, - ~eventName: string, -) => option let isWildcard: (~contractName: string, ~eventName: string) => bool -let hasRegistration: (~contractName: string, ~eventName: string) => bool +let isDroppedByWhere: (~config: Config.t, Internal.onEventRegistration) => bool +let getSimulateOnEventRegistrations: ( + ~config: Config.t, + ~chainId: int, + ~eventConfig: Internal.eventConfig, +) => array type blockRange = { _gte: option, diff --git a/packages/envio/src/PgStorage.res b/packages/envio/src/PgStorage.res index 03222cad5..65baa07b0 100644 --- a/packages/envio/src/PgStorage.res +++ b/packages/envio/src/PgStorage.res @@ -849,9 +849,24 @@ let rec writeBatch = async ( let specificError = ref(None) let rawEvents = if config.enableRawEvents { + // A single on-chain log fans out to one item per matching registration; + // `raw_events` records the log itself, so dedupe by its coordinate + // (chain, block, logIndex) to keep one row per log. + let seenLogCoordinates = Utils.Set.make() let rows = batch.items->Array.filterMap(item => switch item { - | Internal.Event(_) => Some(config.ecosystem.toRawEvent(item->Internal.castUnsafeEventItem)) + | Internal.Event(_) => + let coordinate = `${item + ->Internal.getItemChainId + ->Int.toString}-${item + ->Internal.getItemBlockNumber + ->Int.toString}-${item->Internal.getItemLogIndex->Int.toString}` + if seenLogCoordinates->Utils.Set.has(coordinate) { + None + } else { + seenLogCoordinates->Utils.Set.add(coordinate)->ignore + Some(config.ecosystem.toRawEvent(item->Internal.castUnsafeEventItem)) + } | Internal.Block(_) => None } ) diff --git a/packages/envio/src/SimulateItems.res b/packages/envio/src/SimulateItems.res index e33bbfbcb..fc4a3bb05 100644 --- a/packages/envio/src/SimulateItems.res +++ b/packages/envio/src/SimulateItems.res @@ -339,46 +339,67 @@ let parse = ( | None => seenCoordinates->Dict.set(coordinate, itemIndex) } - // Build a real registration the same way `HandlerRegister.finishRegistration` - // does at startup (not a stub), so the address filter and `where` - // behave identically to real indexing — the dead-input tracker relies - // on `clientAddressFilter` actually gating unrouted items. - let onEventRegistration = HandlerRegister.buildOnEventRegistration( - ~config, - ~chainId, - ~eventConfig, - ) - // Append into the registration array that the chain state will own and - // put that same registration object directly on the simulated item. - let onEventRegistrationIndex = onEventRegistrations->Array.length - let onEventRegistration = {...onEventRegistration, index: onEventRegistrationIndex} - onEventRegistrations->Array.push(onEventRegistration)->ignore - - items - ->Array.push( - Internal.Event({ - onEventRegistration, - chain, - blockNumber, - logIndex, - // Simulate keeps the transaction inline on the payload, so the store - // key is unused. - transactionIndex: 0, - payload: ( - { - contractName: eventConfig.contractName, - eventName: eventConfig.name, - params, - chainId, - srcAddress, - logIndex, - transaction, - block, - }: Evm.payload - )->Evm.fromPayload, - }), - ) - ->ignore + // Fan the simulated event out to every registration that would actually + // run here the way real routing does (one item per registration). + // Registrations are built the same way `HandlerRegister.finishRegistration` + // does at startup (not stubs), so the address filter and `where` behave + // identically to real indexing — the dead-input tracker relies on + // `clientAddressFilter` actually gating unrouted items. Drop registrations + // whose `where` excludes this chain (they wouldn't be fetched live), and + // ignore the bare handler-less fallback so a missing handler surfaces below + // instead of silently running nothing. + let liveRegistrations = + HandlerRegister.getSimulateOnEventRegistrations( + ~config, + ~chainId, + ~eventConfig, + )->Array.filter(reg => + (reg.handler->Option.isSome || reg.contractRegister->Option.isSome) && + !HandlerRegister.isDroppedByWhere(~config, reg) + ) + if liveRegistrations->Utils.Array.isEmpty { + JsError.throwWithMessage( + `simulate: no handler runs for event "${eventName}" on contract "${contractName}"${switch config.chainMap + ->ChainMap.values + ->Array.length { + | 1 => "" + | _ => ` on chain ${chainId->Int.toString}` + }}. Register a handler with indexer.onEvent (and check any \`where\` filter isn't excluding this chain) before simulating it.`, + ) + } + liveRegistrations->Array.forEach(reg => { + // Append into the registration array that the chain state will own and + // put that same registration object directly on the simulated item. + let onEventRegistrationIndex = onEventRegistrations->Array.length + let onEventRegistration = {...reg, index: onEventRegistrationIndex} + onEventRegistrations->Array.push(onEventRegistration)->ignore + + items + ->Array.push( + Internal.Event({ + onEventRegistration, + chain, + blockNumber, + logIndex, + // Simulate keeps the transaction inline on the payload, so the store + // key is unused. + transactionIndex: 0, + payload: ( + { + contractName: eventConfig.contractName, + eventName: eventConfig.name, + params, + chainId, + srcAddress, + logIndex, + transaction, + block, + }: Evm.payload + )->Evm.fromPayload, + }), + ) + ->ignore + }) | _ => JsError.throwWithMessage(`simulate: Invalid item. Each item must have "contract" and "event" fields.`) diff --git a/scenarios/test_codegen/src/handlers/EventHandlers.ts b/scenarios/test_codegen/src/handlers/EventHandlers.ts index e22b04003..ebfe77f7e 100644 --- a/scenarios/test_codegen/src/handlers/EventHandlers.ts +++ b/scenarios/test_codegen/src/handlers/EventHandlers.ts @@ -924,60 +924,6 @@ indexer.onEvent({ contract: "EventFiltersTest", event: "FilterTestEvent", where: } }); -// Duplicate handler registration tests - -// Same options (no options) → should compose without error. -// The composed handler sets an additional entity to prove it ran. -indexer.onEvent({ contract: "Gravatar", event: "CustomSelection" }, async ({ event, context }) => { - context.CustomSelectionTestPass.set({ - id: "composed-" + event.transaction.hash, - }); -}); - -// Same options → composed contractRegister registers an additional contract -indexer.contractRegister({ contract: "Gravatar", event: "FactoryEvent" }, async ({ event, context }) => { - if (event.params.testCase === "composeContractRegister") { - context.chain.NftFactory.add(event.params.contract); - } -}); - -// Capture the inner add() closure in one contractRegister invocation, then try -// to invoke the captured closure from a later onEvent handler (after the first -// handler has resolved and params.isResolved === true). The call must throw — -// this guards against the captured-add bypass where -// `const add = context.chain.X.add` survives past handler resolution. -// We signal success via the CustomSelectionTestPass entity so the test can -// observe the outcome across the createTestIndexer worker boundary. -let _capturedCrAdd: ((address: `0x${string}`) => void) | null = null; -indexer.contractRegister({ contract: "Gravatar", event: "FactoryEvent" }, async ({ event, context }) => { - if (event.params.testCase === "captureAdd") { - _capturedCrAdd = context.chain.SimpleNft.add; - } -}); -indexer.onEvent({ contract: "Gravatar", event: "FactoryEvent" }, async ({ event, context }) => { - if (event.params.testCase === "callCapturedAdd" && _capturedCrAdd) { - const outcome = (() => { - try { - _capturedCrAdd!("0x1234567890123456789012345678901234567890"); - return "captured-add-did-not-throw"; - } catch { - return "captured-add-threw"; - } - })(); - context.CustomSelectionTestPass.set({ - id: outcome, - }); - } -}); - -// Different options → should throw -export let mismatchedHandlerOptionsError: Error | undefined; -try { - indexer.onEvent({ contract: "Gravatar", event: "CustomSelection", wildcard: true }, async () => {}); -} catch (e) { - mismatchedHandlerOptionsError = e as Error; -} - // Handler for testing simulate block/logIndex behavior indexer.onEvent({ contract: "Gravatar", event: "EmptyEvent" }, async ({ event, context }) => { context.SimulateTestEvent.set({ @@ -988,6 +934,16 @@ indexer.onEvent({ contract: "Gravatar", event: "EmptyEvent" }, async ({ event, c }); }); +// No-op handler so Noop.EmptyEvent can be processed and simulated without +// writing any entity — used by the multichain ordering test. Pinned to chain 1: +// Noop is also configured on chain 137 (with an address), and registering it +// there would add an extra fetch partition that the rollback/reorg tests (which +// expect a single partition per chain) don't account for. +indexer.onEvent( + { contract: "Noop", event: "EmptyEvent", where: ({ chain }) => chain.id === 1 }, + async () => {}, +); + // Regression test for https://github.com/enviodev/hyperindex/issues/538: // the `contactDetails` param is a Solidity struct (`ContactDetails { name, email }`), // and the handler must see it as a named record so that `.name` / `.email` diff --git a/scenarios/test_codegen/test/EventFilters_test.res b/scenarios/test_codegen/test/EventFilters_test.res index 9527fb65c..e4cf3bb37 100644 --- a/scenarios/test_codegen/test/EventFilters_test.res +++ b/scenarios/test_codegen/test/EventFilters_test.res @@ -483,7 +483,8 @@ describe("Test eventFilters", () => { it("Where returning false drops the chain's registration entirely", t => { // WithExcessField's where returns `false` for chain 100 and a filter for - // chain 137 — the finished registrations must include it only on 137. + // chain 137 — the handler opted out of chain 100, so the event gets no + // registration there (not even a raw-events one), only on 137. let hasEvent = chainId => switch registrationsByChainId->Dict.get(chainId) { | Some({HandlerRegister.onEventRegistrations: regs}) => diff --git a/scenarios/test_codegen/test/EventHandler.test.ts b/scenarios/test_codegen/test/EventHandler.test.ts index dbd5e2392..02919f3df 100644 --- a/scenarios/test_codegen/test/EventHandler.test.ts +++ b/scenarios/test_codegen/test/EventHandler.test.ts @@ -776,109 +776,6 @@ describe("Use Envio test framework to test event handlers", () => { sets: [{ address: expectedChecksummedAddress, contract: "SimpleNft" }], }); }); - - it("composes duplicate handlers with same options", async () => { - const indexer = createTestIndexer(); - const dcAddress = "0x1234567890123456789012345678901234567890"; - - // Process CustomSelection — original handler + composed handler should both run - const result = await indexer.process({ - chains: { - 1337: { - startBlock: 1, - endBlock: 100, - simulate: [ - { - contract: "Gravatar", - event: "CustomSelection", - transaction: { from: "0xfoo" }, - block: { parentHash: "0xParentHash" }, - }, - ], - }, - }, - }); - - // Original handler sets entity with id = event.transaction.hash - // Composed handler sets entity with id = "composed-" + event.transaction.hash - const change = result.changes[0]?.CustomSelectionTestPass; - assert.equal(change?.sets?.length, 2, "Both original and composed handler should set entities"); - assert.ok( - change?.sets?.some((e: { id: string }) => e.id.startsWith("composed-")), - "Composed handler should have set an entity with 'composed-' prefix" - ); - }); - - it("composes duplicate contractRegister with same options", async () => { - const indexer = createTestIndexer(); - const dcAddress = "0x1234567890123456789012345678901234567890"; - - // Process FactoryEvent with composeContractRegister testCase — - // original contractRegister adds SimpleNft (via syncRegistration path), - // composed contractRegister adds NftFactory - const result = await indexer.process({ - chains: { - 1337: { - startBlock: 1, - endBlock: 100, - simulate: [ - { - contract: "Gravatar", - event: "FactoryEvent", - params: { contract: dcAddress, testCase: "composeContractRegister" }, - }, - ], - }, - }, - }); - - // The composed contractRegister should have registered NftFactory - const addresses = result.changes[0]?.addresses?.sets; - assert.ok( - addresses?.some((a: { contract: string }) => a.contract === "NftFactory"), - "Composed contractRegister should register NftFactory" - ); - }); - - it("captured contractRegister add() throws after handler resolved", async () => { - const indexer = createTestIndexer(); - const dcAddress = "0x1234567890123456789012345678901234567890"; - - // Two sequential FactoryEvent events: - // 1. testCase "captureAdd" — contractRegister handler stashes - // context.chain.SimpleNft.add into a module-scoped variable. - // 2. testCase "callCapturedAdd" — onEvent handler tries to call the - // captured closure. By then the first event's contractRegister params - // have isResolved=true, so the closure must throw. The handler - // records the outcome via the CustomSelectionTestPass entity id. - const result = await indexer.process({ - chains: { - 1337: { - startBlock: 1, - endBlock: 100, - simulate: [ - { - contract: "Gravatar", - event: "FactoryEvent", - params: { contract: dcAddress, testCase: "captureAdd" }, - }, - { - contract: "Gravatar", - event: "FactoryEvent", - params: { contract: dcAddress, testCase: "callCapturedAdd" }, - }, - ], - }, - }, - }); - - const sets = result.changes[0]?.CustomSelectionTestPass?.sets ?? []; - assert.ok( - sets.some((e: { id: string }) => e.id === "captured-add-threw"), - `Captured contractRegister add() should throw after handler resolved. Got entity ids: ${sets.map((e: { id: string }) => e.id).join(", ")}` - ); - }); - it("Should be able to run effect with cache", async () => { const indexer = createTestIndexer(); const dcAddress = "0x1234567890123456789012345678901234567890"; @@ -1133,9 +1030,9 @@ describe("Use Envio test framework to test event handlers", () => { 1: { startBlock: 1, endBlock: 100, - simulate: [ - { contract: "Noop", event: "EmptyEvent" }, - ], + // Noop.EmptyEvent is the only event on chain 1; it has a no-op handler + // so the chain processes an event without writing any entity. + simulate: [{ contract: "Noop", event: "EmptyEvent" }], }, }, }); @@ -1169,6 +1066,30 @@ describe("Use Envio test framework to test event handlers", () => { }); }); + // Simulating an event that has no registered handler is almost always a + // mistake (a typo'd event, or forgetting to write the handler), so it fails + // loudly rather than silently processing nothing. TestEventWithReservedKeyword + // is defined on Gravatar (chain 1337) but has no handler. + it("throws when simulating an event with no registered handler", async () => { + const indexer = createTestIndexer(); + + await assert.rejects( + () => + indexer.process({ + chains: { + 1337: { + startBlock: 1, + endBlock: 100, + simulate: [ + { contract: "Gravatar", event: "TestEventWithReservedKeyword" }, + ], + }, + }, + }), + /no handler runs for event "TestEventWithReservedKeyword" on contract "Gravatar"/ + ); + }); + // Regression for https://github.com/enviodev/hyperindex/issues/538: a struct // event param must reach the handler as a named record. If the runtime still // delivered it as a positional tuple, the handler would blow up on diff --git a/scenarios/test_codegen/test/HandlerRegisterLifecycle_test.res b/scenarios/test_codegen/test/HandlerRegisterLifecycle_test.res index cfa83ca76..6fe42b24f 100644 --- a/scenarios/test_codegen/test/HandlerRegisterLifecycle_test.res +++ b/scenarios/test_codegen/test/HandlerRegisterLifecycle_test.res @@ -1,10 +1,10 @@ open Vitest -// Covers the per-chain immediate resolution in `HandlerRegister`: duplicate -// registrations are compared on the resolved `where` structure (not the -// callback reference), invalid `where`/onBlock options throw at the user's -// registration call site, and `preRegistered` callbacks replayed by -// `startRegistration` run through the same code path. +// Covers the per-chain immediate resolution in `HandlerRegister`: every +// `onEvent` becomes its own registration (no composing, no duplicate error), +// invalid `where`/onBlock options still throw at the user's registration call +// site, and `preRegistered` callbacks replayed by `startRegistration` run +// through the same code path. // // This file must not import the handler fixtures — it drives the global // registry lifecycle itself, relying on vitest's per-file isolation. @@ -28,25 +28,21 @@ HandlerRegister.setHandler( ) HandlerRegister.startRegistration(~config) -describe("HandlerRegister — duplicate registrations compare resolved where", () => { - it("a distinct callback with an equal resolution composes instead of throwing", t => { - HandlerRegister.setHandler( - ~contractName="EventFiltersTest", - ~eventName="Transfer", - noopHandler, - ~eventOptions=eventOptions( - ~where=%raw(`({chain: _chain}) => ({params: {from: "0x0000000000000000000000000000000000000000"}})`), - ), - ) - t.expect( - HandlerRegister.getHandler( +describe("HandlerRegister — every onEvent registers separately", () => { + it("a second handler with an equal resolution registers without composing or throwing", t => { + t.expect(() => + HandlerRegister.setHandler( ~contractName="EventFiltersTest", ~eventName="Transfer", - )->Option.isSome, - ).toBe(true) + noopHandler, + ~eventOptions=eventOptions( + ~where=%raw(`({chain: _chain}) => ({params: {from: "0x0000000000000000000000000000000000000000"}})`), + ), + ) + ).not.toThrow() }) - it("a callback with a different resolution throws at the registration call site", t => { + it("a handler with a different resolution registers separately without throwing", t => { t.expect(() => HandlerRegister.setHandler( ~contractName="EventFiltersTest", @@ -56,9 +52,7 @@ describe("HandlerRegister — duplicate registrations compare resolved where", ( ~where=%raw(`({chain: _chain}) => ({params: {to: "0x0000000000000000000000000000000000000000"}})`), ), ) - ).toThrowError( - "Cannot register a second handler with different options. Make sure all handlers for the same event use identical options (wildcard, where) for EventFiltersTest.Transfer", - ) + ).not.toThrow() }) it("an invalid where throws at the registration call site", t => { diff --git a/scenarios/test_codegen/test/OnEventRegistration_test.res b/scenarios/test_codegen/test/OnEventRegistration_test.res index 4c23a221b..84d0e5df2 100644 --- a/scenarios/test_codegen/test/OnEventRegistration_test.res +++ b/scenarios/test_codegen/test/OnEventRegistration_test.res @@ -1,6 +1,6 @@ open Vitest -// Covers `HandlerRegister.buildOnEventRegistration`: the handler-state fields +// Covers the built onEvent registration: the handler-state fields // (`handler`, `contractRegister`, `isWildcard`) registered via // `indexer.onEvent` / `indexer.contractRegister` land on the built // registration, and `dependsOnAddresses` follows the shared diff --git a/scenarios/test_codegen/test/__mocks__/MockConfig.res b/scenarios/test_codegen/test/__mocks__/MockConfig.res index 27bad0872..838c37ace 100644 --- a/scenarios/test_codegen/test/__mocks__/MockConfig.res +++ b/scenarios/test_codegen/test/__mocks__/MockConfig.res @@ -17,10 +17,10 @@ let getEvmEventConfig = (~config=?, ~contractName, ~eventName, ~chainId=?) => Utils.magic: Internal.eventConfig => Internal.evmEventConfig ) -// Build the per-(event, chain) registration from the event definition + the -// registered handlers, mirroring `HandlerRegister.finishRegistration`. -// Handlers must have been registered (`HandlerLoader.registerAllHandlers`) -// before calling. +// The first per-(event, chain) registration built from the event definition + +// the registered handlers. Handlers must have been registered +// (`HandlerLoader.registerAllHandlers`) before calling; falls back to a bare +// registration when the event has none. let getOnEventRegistration = (~config=?, ~contractName, ~eventName, ~chainId=?) => { let config = switch config { | Some(c) => c @@ -31,7 +31,9 @@ let getOnEventRegistration = (~config=?, ~contractName, ~eventName, ~chainId=?) | Some(id) => id | None => config.chainMap->ChainMap.values->Array.get(0)->Option.mapOr(0, c => c.id) } - HandlerRegister.buildOnEventRegistration(~config, ~chainId=probeChainId, ~eventConfig) + HandlerRegister.getSimulateOnEventRegistrations(~config, ~chainId=probeChainId, ~eventConfig) + ->Array.get(0) + ->Option.getOrThrow } let getEvmOnEventRegistration = (~config=?, ~contractName, ~eventName, ~chainId=?) => diff --git a/scenarios/test_codegen/test/helpers/MockIndexer.res b/scenarios/test_codegen/test/helpers/MockIndexer.res index 984e8a158..87442363e 100644 --- a/scenarios/test_codegen/test/helpers/MockIndexer.res +++ b/scenarios/test_codegen/test/helpers/MockIndexer.res @@ -435,22 +435,21 @@ module Indexer = { | Some(_) => () } - // Build the final per-test config (chain overrides, enableRawEvents, ...) - // before registering handlers: `HandlerRegister.finishRegistration` now - // builds each chain's onEventRegistrations (gated by `enableRawEvents`) - // from the config it's given, so registration must see the resolved - // config rather than the raw generated one. - let config = { - let config = switch customConfig { - | Some(config) => config - | None => Config.load() - } + // The full (un-narrowed) config. Handlers register against this so every + // chain resolves once; `finishRegistration` then narrows to the per-test + // `config` below. + let baseConfig = switch customConfig { + | Some(config) => config + | None => Config.load() + } + // Build the final per-test config (chain overrides, enableRawEvents, ...). + let config = { let chainMap = chains ->Array.map(chainConfig => { let chain = ChainMap.Chain.makeUnsafe(~chainId=(chainConfig.chain :> int)) - let originalChainConfig = config.chainMap->ChainMap.get(chain) + let originalChainConfig = baseConfig.chainMap->ChainMap.get(chain) ( chain, { @@ -467,24 +466,26 @@ module Indexer = { ->ChainMap.fromArrayUnsafe { - ...config, + ...baseConfig, shouldRollbackOnReorg, shouldSaveFullHistory: saveFullHistory, enableRawEvents, chainMap, - batchSize: batchSize->Option.getOr(config.batchSize), + batchSize: batchSize->Option.getOr(baseConfig.batchSize), reorgThresholdReadyTolerance, } } - let registrationsByChainId = switch customConfig { - | None => await HandlerLoader.registerAllHandlers(~config) + // Register handlers once against the full chain set (idempotent + + // import-cached, so re-`make` reuses), then narrow to this run's chains. + switch customConfig { + | None => let _ = await HandlerLoader.registerAllHandlers(~config=baseConfig) | Some(_) => // A supplied config has no handler files on disk; register inline // handlers (if any) through the same public registry lifecycle. - HandlerRegister.startRegistration(~config) - HandlerRegister.finishRegistration(~config) + HandlerRegister.startRegistration(~config=baseConfig) } + let registrationsByChainId = HandlerRegister.finishRegistration(~config) installMockSourceRegistrations(~config, ~registrationsByChainId) let sql = PgStorage.makeClient()