Migrate reactive test library to Reactive Omni#1
Conversation
Re-implement the Foundry test library to simulate the Reactive Omni (CometBFT / shared-EVM) network instead of the legacy (Geth+Prysm / per-user-RVM) network, per specs/001-omni-migration.md. The library now depends on the real Omni system contracts (vendored as submodules) and deploys them into the test EVM rather than re-mocking their behavior: - SystemContract etched at 0x8888...8888 behind an ERC1967 proxy - per-chain CallbackProxy deployments - a log-derived subscription index (MemFilterService port) - a match -> trigger -> callback pipeline replaying recorded logs New public API (ReactiveTest base): _reactiveSetUp, _registerChain (+ custom-senders overload), _getCallbackProxy, _registerContract, _switchToChain, _react(FeeMode), _reactUntilQuiescent(FeeMode, n), _emitCron, _fund, and the _recordedLogs/_assertEmitted/... capture helpers. src: OmniConstants, ReactiveTypes, SubscriptionIndex, ReactEngine, ReactiveTest, env/EnvironmentDeployer. Legacy mock/simulator/base sources removed. Tests rewritten as 11 scenario/unit suites on 6 omni-style mocks (87 tests). Config: solc 0.8.29 + cancun + via_ir, remappings, ci profile; two submodules (reactive-lib-omni, system-smart-contracts@lasna2-dev). README rewritten for the Omni API. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The `reactive-test-lib/=lib/reactive-test-lib/src/` remapping (matching Foundry's auto-generated one) already resolves the `src/` dir, so the quick-start import must omit it: `reactive-test-lib/ReactiveTest.sol`, not `reactive-test-lib/src/ReactiveTest.sol` (which resolved to a doubled `.../src/src/...` and would not compile). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
There was a problem hiding this comment.
Reviewed against specs/001-omni-migration.md and the vendored Omni contracts. The architecture is strong, and all 87 tests, formatting, build-size checks, and lint pass locally.
Three P2 findings should be addressed before merge:
-
Lifecycle guards before setup —
src/ReactiveTest.sol:109Before
_reactiveSetUp,reactiveChainIddefaults to zero, so_registerContract(0, contract_)passes the chain check and marks the contract reactive even though no environment exists. Require_env.initializedand apply the same lifecycle guard consistently to_switchToChain,_react,_reactUntilQuiescent, and_emitCron. Add pre-setup regression tests. -
Restore fee-related cheatcode state —
src/ReactEngine.sol:205and:327Subsidized mode sets the global Foundry transaction gas price and block base fee to zero, but
runnever restores them. This leaks harness state into later assertions and can make a subsequent Metered cycle accidentally charge nothing. Savetx.gaspriceandblock.basefeeat entry and restore both before returning, with a Subsidized-to-Metered regression test. -
Pin dependency revisions in consumer instructions —
README.md:28-32The
forge installcommands use default or floating revisions; the shell comment does not selectlasna2-dev. Since the harness relies on exact contract layouts and behavior, put the supported tag or commit directly in each install command, matching the gitlinks used by this PR.
| /// @param topics_ The event's topics, slots `[0..3]` (higher slots may be `0` if absent). | ||
| /// @param topicCount_ Number of topics the event actually has (`0..4`). | ||
| /// @return result_ Each matching subscriber contract, at most once, in first-match order. | ||
| function findSubscribers( |
There was a problem hiding this comment.
This is linear in the number of active subscriptions. Why doesn't this lib use the reference algo from the pre-omni system contract? That implementation is linear in the number of criteria supported, which is effectively constant. Adding subscription dedup on lookups seems fairly simple to me.
There was a problem hiding this comment.
This is deliberate, and it's a fidelity choice rather than an oversight.
The library ports the Omni node's subscription matcher — MemFilterService.FindSubscribers in reactive-tornado/abci/rnk/mem_filters.go — which is itself an O(N) linear scan over TopicFilters (confirmed in the node source; the REACTIVE_LEGACY_VS_OMNI reference also notes FindSubscribers is "an O(N) linear scan" in both generations). That linear scan is the reference algorithm for Reactive Omni. The recursive trie you're pointing at lives in AbstractSubscriptionService — the legacy 1.0 on-chain subscription service, a different generation. The migration's explicit goal (spec §6.4) was to mirror what the Omni node actually does, so matching its loop (and its per-event dedup) keeps the harness byte-faithful to on-chain behavior.
On dedup: it's already done on lookups — findSubscribers returns each subscriber contract at most once via the seen-scan, which is exactly the node's processedFilterContracts guard.
On cost: this is a test library, so N is the number of subscriptions in one test — tiny. O(N) vs O(criteria) has no practical impact here, and the linear scan is simpler and more auditable than a recursive trie with nested mapping storage (which would also reintroduce the node's position-agnostic FNV FilterId collision that §6.4 deliberately avoids by matching positionally).
If there were a concrete scenario with very large synthetic subscription sets where scan cost mattered, I'd happily revisit — but the trie would trade node-fidelity and simplicity for efficiency the tests don't need.
There was a problem hiding this comment.
I would argue that we need to fix the implementation in the node then. I do not know why it's implemented as a linear scan, but that means we have a serious scalability issue. The on-chain implementation predates both off-chain implementations. We certainly have massive, dynamic subscription sets as something that we have to be able to do for real world cases. Offline tests might also plausibly try stress testing reactive code in the presence of large numbers of active subscriptions.
There was a problem hiding this comment.
Thank you, this looks far more practical for stress testing and/or extreme corner cases in dynamic subscriptions, while remaining completely adequate for run-of-the-mill test, and it's frankly more aesthetically pleasing than the brute force linear scan. I have no further comments before the merge.
| /// or `vm.getRecordedLogs()` themselves — doing so desyncs the engine. Use the `_recordedLogs`/ | ||
| /// `_assertEmitted`/... helpers, the returned `CallbackResult[]`, or `vm.expectEmit` before a | ||
| /// direct call instead. | ||
| abstract contract ReactiveTest is Test { |
There was a problem hiding this comment.
Can we improve branch coverage for this contract?
$ forge coverage --ir-minimum -vvv --no-match-coverage ^test/
[...]
Ran 11 test suites in 1.52s (337.55ms CPU time): 87 tests passed, 0 failed, 0 skipped (87 total tests)
╭---------------------------------+------------------+------------------+----------------+-----------------╮
| File | % Lines | % Statements | % Branches | % Funcs |
+==========================================================================================================+
| src/ReactEngine.sol | 95.15% (157/165) | 96.52% (194/201) | 85.00% (34/40) | 100.00% (20/20) |
|---------------------------------+------------------+------------------+----------------+-----------------|
| src/ReactiveTest.sol | 85.11% (80/94) | 79.82% (87/109) | 24.24% (8/33) | 89.47% (17/19) |
|---------------------------------+------------------+------------------+----------------+-----------------|
| src/SubscriptionIndex.sol | 94.44% (51/54) | 94.44% (85/90) | 86.67% (13/15) | 100.00% (7/7) |
|---------------------------------+------------------+------------------+----------------+-----------------|
| src/env/EnvironmentDeployer.sol | 100.00% (11/11) | 100.00% (13/13) | 100.00% (0/0) | 100.00% (2/2) |
|---------------------------------+------------------+------------------+----------------+-----------------|
| Total | 92.28% (299/324) | 91.77% (379/413) | 62.50% (55/88) | 95.83% (46/48) |
╰---------------------------------+------------------+------------------+----------------+-----------------╯
There was a problem hiding this comment.
Improved. Added targeted tests that raise src/ReactiveTest.sol line coverage from 85% to 94% and function coverage to 100% (--ir-minimum --no-match-coverage ^test/):
test/CaptureHelpers.t.sol— both branches of every §6.8 capture helper (_recordedLogs,_assertEmitted×2,_assertNotEmitted,_countEmitted,_findEmitted), including the found/absent and revert paths.- Pre-setup lifecycle-guard reverts for
_registerContract/_switchToChain/_react/_reactUntilQuiescent/_emitCroninEnvironmentGuards.t.sol(these also close @daniil-parsiq's finding Migrate reactive test library to Reactive Omni #1).
The branch % stays modest largely due to Foundry's --ir-minimum branch counting: it splits each compound &&/|| in the assertion helpers into many sub-branches, and it attributes the inlined private _requireSetUp() guard to the callee body rather than the call site (the only 6 lines still showing 0 hits are those call sites, which every test executes — the guard is exercised both ways: pass in normal tests, revert in the new pre-setup tests). I kept the shared guard over inlining the require six times, since the logic is fully tested and the DRY guard reads better; happy to inline it if you'd prefer the call-site lines to register.
There was a problem hiding this comment.
inlined
private _requireSetUp()
But the way, it would be more idiomatic to wrap it in a modifier, but that's a minor nitpick.
- Guard the environment/cycle API against use before `_reactiveSetUp` (a `_requireSetUp()` guard on `_registerContract`, `_switchToChain`, `_react`, `_reactUntilQuiescent`, `_emitCron`): before setup `reactiveChainId == 0`, so an unguarded `chainId == 0` slipped past the chain checks. Adds pre-setup regression tests. - Restore `tx.gasprice` / `block.basefee` at the end of a reactive cycle. Subsidized mode zeroes them before each trigger/delivery; leaving them zeroed leaked into later assertions and could make a subsequent Metered cycle charge nothing. The saved values live in storage, not a local: under via_ir the optimizer treats GASPRICE/BASEFEE as tx-invariant and would re-materialize a captured local at the restore site. Adds a Subsidized-then-Metered regression test. - Pin the exact dependency revisions in the README install commands ([email protected], system-smart-contracts@<commit>) matching this repo's submodule gitlinks. - Add §6.8 capture-helper branch tests (found/absent, revert paths), raising src/ReactiveTest.sol line coverage from 85% to 94%. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
Thanks for the review. All three P2 findings are addressed in 1. Lifecycle guards before setup — fixed. Added a 2. Restore fee-related cheatcode state — fixed. One subtlety worth flagging: the saved values must live in storage, not a local. Under 3. Pin dependency revisions — fixed. The README install commands now pin the exact revisions matching this repo's submodule gitlinks: The branch-coverage ask (@pbl64k) is addressed in its own thread — new capture-helper + guard tests raise |
The node replaced its O(N) linear scan (mem_filters.go) with a 64-bucket wildcard-mask index (upstream: "Index FindSubscribers by wildcard mask instead of linear scan"). This ports that mechanism faithfully. The subscription store is now the node's uid-keyed structure: distinct-criteria filters (TopicFilter), each carrying one config per subscriber (FilterConfig), keyed by a Keccak256 FilterId. Matching enumerates the 64 wildcard masks and probes each with an O(1) map lookup keyed by the event's own concrete fields — O(64) regardless of subscription count, replacing the per-event scan. Adopts the node's consensus-visible ordering change: results are returned in canonical (uid, contract) order (lowest-uid filter wins attribution of a contract reachable via several), not subscription-creation order. FilterId is computed byte-for-byte as the node's computeFilterId (mask byte + concrete fields, chainId little-endian per U64tob, truncated to 16 bytes), so our trigger order matches what the real network produces for the same filter set — anchored by test_uidMatchesNodeComputeFilterId against values from the node's BuildFilterId. - ReactiveTypes: replace Subscription with FilterConfig/TopicFilter/SubscriptionStore - SubscriptionIndex: uid-keyed add/remove/setActive + 64-mask findSubscribers, swap-pop filter detach, filterMatchesEvent backstop, canonical sort/dedup - ReactEngine: operate on SubscriptionStore - tests: adapt to the grouped model; add ordering-independence, node uid-parity, swap-remove, and fully-concrete-bucket cases (26 -> 30 unit tests, 106 total) Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
Gas benchmark comparing the retired O(N) linear scan (preserved verbatim as test/bench/LinearScanIndex.sol) against the 64-bucket wildcard-mask index, at 100 / 10_000 / 1_000_000 active subscriptions — the node's perf-test scales. The lookup is measured in an isolated call frame (populate runs inside a separate BenchTarget frame whose in-frame memory growth is discarded on return), so the number is the true per-lookup cost, free of the O(N) memory-expansion artifact that populating in the measuring frame would introduce. Gated behind RUN_BENCH=true so it's inert under a normal `forge test` / CI run (each case returns early, ~3.4k gas); a `bench` profile lifts the gas/memory ceilings for the 1M case. Run with: RUN_BENCH=true FOUNDRY_PROFILE=bench forge test --match-path 'test/bench/*' -vv Results (forge 1.5.1, solc 0.8.29, via_ir): NEW is flat at 155,945 gas across all three scales; OLD is 92k / 9.0M / 2.84B. Bucket index is ~18,000x cheaper at 1M; crossover ~170 subscriptions. Co-Authored-By: Claude Opus 4.8 <[email protected]>
Benchmark: linear scan vs. bucket index —
|
| Subscriptions | OLD — linear scan | NEW — bucket index | NEW vs OLD |
|---|---|---|---|
| 100 | 92,056 | 155,945 | 1.7× more (below crossover) |
| 10,000 | 9,019,690 | 155,945 | 58× cheaper |
| 1,000,000 | 2,835,183,518 | 155,945 | ~18,000× cheaper |
(forge 1.5.1, solc 0.8.29, via_ir; one populated mask bucket, event resolves to exactly one match.)
Reading it
- NEW is flat — 155,945 gas at every scale. The lookup is 64 bucket probes + the matched count, independent of N. 100 → 1M subscriptions: identical gas. This is the whole point of the port.
- OLD grows ~linearly, then worse. ~880 gas/row for the scan, plus a quadratic tail: the old algorithm allocates an
address[](N)temp buffer, whose memory-expansion cost isN²/512. At 1M that buffer alone is ~1.95B gas (≈69% of the 2.84B total); the scan itself is the other ~880M. - Crossover ≈ 170 subscriptions. Below that the linear scan is cheaper (fewer than ~64 effective rows beats 64 unconditional bucket probes + keccak-per-probe) — the honest trade-off I flagged when we discussed the port. Above it the gap widens without bound.
- The new index's bound is 64 probes regardless of how filters distribute across buckets (worst case = all 64 buckets occupied = 64 probes), so N-independence isn't specific to this one-bucket layout.
Methodology note (why the numbers are trustworthy). Populating N subscriptions in the measuring frame permanently grows EVM memory (each add allocates scratch), which would charge the subsequent lookup a memory-expansion penalty proportional to N — a pure artifact. The benchmark populates inside a separate BenchTarget frame (its memory bloat is discarded on return) and measures the lookup as a fresh external call. Before isolating this, the "new" number falsely appeared to grow 160k → 562k at 1M; isolated, it's a constant 155,945.
Reproduce:
RUN_BENCH=true FOUNDRY_PROFILE=bench forge test --match-path 'test/bench/*' -vv
(the 1M cases run in ~9s total.)
| /// @param topics_ The event's topics, slots `[0..3]` (higher slots may be `0` if absent). | ||
| /// @param topicCount_ Number of topics the event actually has (`0..4`). | ||
| /// @return result_ Each matching subscriber contract, at most once, in first-match order. | ||
| function findSubscribers( |
There was a problem hiding this comment.
Thank you, this looks far more practical for stress testing and/or extreme corner cases in dynamic subscriptions, while remaining completely adequate for run-of-the-mill test, and it's frankly more aesthetically pleasing than the brute force linear scan. I have no further comments before the merge.
Summary
Re-implements this Foundry test library to simulate the Reactive Omni (CometBFT / shared-EVM)
network instead of the legacy (Geth + Prysm / per-user-RVM) network, per
specs/001-omni-migration.md. No backward compatibility with the legacy API is kept.Instead of re-mocking behavior, the library now depends on the real Omni system contracts
(vendored as submodules) and deploys them into the test EVM:
SystemContractetched at0x8888…8888behind an ERC1967 proxy;CallbackProxyper registered non-reactive chain;MemFilterService);trigger→ callback pipeline that replays the recorded log stream.87 tests pass;
forge fmt --check/build --sizes/testare all green under theciprofile.What changed
New
src/(replaces all legacymock/,simulator/,base/,constants/,interfaces/):OmniConstants.solcast keccak-verified), gas paramsReactiveTypes.solFeeMode,Subscription,CallbackResult,PendingCallback,DeferredSub,ReactiveEnvSubscriptionIndex.solMemFilterServiceport — positional matching, dedup, idempotent add, order-preserving remove, blacklist/whitelistReactEngine.solCallbackRequestcapture + sender injection, proxy delivery, fee modes, deferred §7.1 changes, cron expansion)ReactiveTest.sol_reactiveSetUp,_registerChain(+ custom-senders overload),_getCallbackProxy,_registerContract,_switchToChain,_react(FeeMode),_reactUntilQuiescent,_emitCron,_fund, and the_recordedLogs/_assertEmitted/… capture helpersenv/EnvironmentDeployer.solTests — rewritten as 11 scenario/unit suites (
BasicDemo,CallbackAuth,SelfCallback,SubscriptionFiltering,MultiStep,ChainRegistry,CronDemo, plusEnvironment,EnvironmentGuards,SubscriptionIndex,ReactEngine) on 6 omni-style mocks.Config —
foundry.toml(solc 0.8.29 + cancun + via_ir +ciprofile + fmt/lint),remappings.txt,two submodules (
reactive-lib-omni,system-smart-contracts@lasna2-dev), rewrittenREADME.md.Decisions taken beyond the initial spec
The spec's §12 already resolves cron synthesis, dropping reactive-chain callbacks, the callback-sender
identity,
FeeMode, dropping the legacyCallbackevent, deferred subscriptions, and solc/evm. Thefollowing decisions were not in the spec and were made during implementation:
via_ir = true+ optimizer are required (and leak to consumers). The spec mandated solc 0.8.29via_ir. TheReactEnginepipeline is deep enough that itsinternallibrarycode hits "stack too deep" without IR. Because that code inlines into the user's test contract,
consumers must also set
via_ir— documented in the README requirements and limitations. (Thealternative — restructuring the index API to shave stack slots — was rejected as fragile.)
All harness state grouped into one
ReactiveEnvstorage struct (spec §8 listed directbase-contract vars). This lets the engine libraries take the whole environment by a single storage
reference.
capturedLogs/pendingLogsmust be storage (they outlive the nested external callsand the
_reactreturn), so a struct is required, not just convenient.Per-cycle scratch stored on-chain (
pendingCallbacks/deferredSubs/resultsinReactiveEnv,cleared each run). Memory arrays can't grow across the nested
trigger/deliverCallbackexternalcalls within one cycle, so growable storage arrays are used instead.
CallbackResult.success/returnDataderivation. The spec defines the struct but not how topopulate
success. The realCallbackProxyswallows a failed recipient call into aCallbackFailureevent rather than reverting, so per callback:success = deliverCallback did not revert AND no CallbackFailure(recipient) was emitted;returnData= proxy-level revert data.Reverted-
react()phantom-emission handling (the subtlest correctness point, not anticipated bythe spec). Foundry's
vm.getRecordedLogs()returns logs emitted inside areact()frame that thenreverted — rolled back on-chain but still recorded. A
react()that requests a callback and thenreverts would otherwise deliver a phantom callback (§7.3 violation). The engine detects the
ReactiveContractRevertedmarkertriggeremits and discards the rolled-back prefix whileprocessing the genuine post-catch suffix — so no phantom callback/subscription is produced, yet
a Metered charge's real
BlacklistContractis still captured and applied to the index.Idempotent, position-sensitive subscription identity. The spec blessed positional matching;
this extends positional identity to
add/removetoo.addis idempotent (re-subscribingidentical criteria reactivates the existing row), mirroring the node's
Subscribe(
mem_filters.go) and makingremove's "first match" unambiguous. This is intentionally stricterthan the node's position-agnostic FNV
FilterId.Node-faithful minor fields.
LogRecord.opCode= raw topic count (matchesevent_processor.go); cronLogRecords use emitter0x…ffffff, chain = reactive,topic1=block number, topicCount 2.
_reactsaves and restores the active chain. It runs triggers on the reactive chain anddeliveries on each destination chain, then restores
block.chainidto whatever the test had set,so callers aren't left on a stray destination chain.
Blacklist/Whitelist applied immediately (not deferred like Subscribe/Unsubscribe). This matches
the spec's §7 switch but diverges from the node's one-block delay; documented as a deliberate,
narrow (Metered-only) simplification.
recordLogsarmed once. Empirically,getRecordedLogs()drains but keeps recording active, sothe engine arms recording once in
_reactiveSetUpand drains at each capture point (the spec'sexplicit re-arm calls are redundant, kept out for clarity).
_fund(addr, amount)=vm.deal. The spec lists the helper but not its semantics; it funds acontract's balance so it can pay its own Metered charges via
pay(uint256).Formatting / lint config.
bracket_spacing = true(to match the omni dependencies' housestyle) and
exclude_lints = ["screaming-snake-case-immutable"](example mocks use idiomaticlowercase immutables, as real reactive contracts do).
Review
Developed under a two-reviewer gate (architecture + code-quality) verifying each phase against the
authoritative omni contracts and the node source (
mem_filters.go,event_processor.go,block_producer.go). The gate surfaced three real correctness bugs — the reverted-react()phantomcallback (decision 5), cross-round double-capture in
_reactUntilQuiescent, and a follow-on droppedBlacklistContractunder Metered — each now fixed and permanently guarded by a regression test.Not included in this PR
specs/001-omni-migration.md,Stab.sol, andREACTIVE_LEGACY_VS_OMNI.md(design/planning docs) areintentionally excluded from the commit, as requested.
🤖 Generated with Claude Code