diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b79c8d4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,38 @@ +name: CI + +permissions: {} + +on: + push: + pull_request: + workflow_dispatch: + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + name: Foundry project + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Show Forge version + run: forge --version + + - name: Run Forge fmt + run: forge fmt --check + + - name: Run Forge build + run: forge build --sizes + + - name: Run Forge tests + run: forge test -vvv diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c6b4b31 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env + +# IDE / OS +.idea/ +.DS_Store diff --git a/.gitmodules b/.gitmodules index 888d42d..895e299 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,10 @@ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std +[submodule "lib/reactive-lib-omni"] + path = lib/reactive-lib-omni + url = https://github.com/Reactive-Network/reactive-lib-omni +[submodule "lib/system-smart-contracts"] + path = lib/system-smart-contracts + url = https://github.com/Reactive-Network/system-smart-contracts + branch = lasna2-dev diff --git a/README.md b/README.md index 5209869..cfef5b1 100644 --- a/README.md +++ b/README.md @@ -1,551 +1,255 @@ -# Reactive Foundry Test +# Reactive Omni Test Library -A Solidity testing library for [Reactive Network](https://reactive.network) contracts. Test your reactive contracts locally with `forge test` — no testnet deployment required. +A Foundry testing library for [Reactive Network](https://reactive.network) **Omni** (Reactive 2.0) +contracts. Test reactive smart contracts locally with `forge test` — no testnet, no RPC, no +deployment. -This library simulates the full Reactive Network lifecycle (event subscriptions, `react()` invocations, cross-chain callbacks, same-chain self-callbacks, cron triggers, and multi-step reactive protocols) entirely within Foundry's testing framework. +The library deploys the **real** Omni system contracts into the test EVM (the system contract etched +at `0x8888…8888` and per-chain callback proxies), records the events your test emits, and replays that +log stream through a faithful **match → `trigger` → callback** pipeline — the same semantics the Omni +node implements. You write ordinary Foundry tests; the harness drives the reactive lifecycle. -## Installation +> **Reactive Omni vs. legacy (1.0).** Omni runs one shared EVM (no per-user RVMs), so a reactive +> contract's own address is the callback sender, subscriptions are derived from `Subscribe`/ +> `Unsubscribe` events emitted by the system contract, and callbacks are requested via +> `SystemContract.requestCallbackV_1_0(...)` (the legacy `Callback` event is **not** processed). -```bash -forge install Reactive-Network/reactive-test-lib -``` - -Add the remapping to your `remappings.txt` or `foundry.toml`: - -``` -reactive-test-lib/=lib/reactive-test-lib/src/ -``` - -### Requirements - -- Solidity >= 0.8.20 -- Foundry (any recent version with `vm.recordLogs()` / `vm.getRecordedLogs()`) -- Compatible with `reactive-lib` v0.2.0+ - -## Quick Start - -### 1. Inherit from `ReactiveTest` - -```solidity -import "reactive-test-lib/base/ReactiveTest.sol"; -import {CallbackResult} from "reactive-test-lib/interfaces/IReactiveInterfaces.sol"; - -contract MyTest is ReactiveTest { - function setUp() public override { - super.setUp(); - // Deploy your contracts here... - } -} -``` - -Calling `super.setUp()` automatically: -- Deploys `MockSystemContract` and etches it to `SERVICE_ADDR` (`0x...fffFfF`) -- Deploys `MockCallbackProxy` for cross-chain callback execution -- Sets `rvmId` to `address(this)` (the simulated deployer identity) -- Sets `reactiveChainId` to `REACTIVE_CHAIN_ID` (`0x512512`) - -Your reactive contracts can then call `subscribe()` / `unsubscribe()` in their constructors as they would on a real deployment. - -### 2. Deploy Your Contracts - -```solidity -function setUp() public override { - super.setUp(); - - // Origin contract (L1) — emits events that trigger reactions - origin = new MyL1Contract(); - - // Callback contract — pass address(proxy) as the callback sender - callback = new MyCallbackContract(address(proxy)); - - // Reactive contract — constructor calls subscribe() on the mock system contract - reactive = new MyReactiveContract( - address(sys), // system contract address - ORIGIN_CHAIN_ID, // chain to watch - DEST_CHAIN_ID, // callback destination chain - address(origin), // contract to watch - TOPIC_0, // event signature hash - address(callback) // callback target - ); - - // Optional: register contracts for auto chain ID detection - registerChain(address(origin), ORIGIN_CHAIN_ID); - registerChain(address(callback), DEST_CHAIN_ID); - registerChain(address(reactive), reactiveChainId); -} -``` - -### 3. Simulate the Reactive Lifecycle - -```solidity -function testCallbackFires() public { - // Single-step: trigger an event and run one reactive cycle - CallbackResult[] memory results = triggerAndReact( - address(origin), - abi.encodeWithSignature("doSomething()"), - ORIGIN_CHAIN_ID - ); - - assertCallbackCount(results, 1); - assertCallbackSuccess(results, 0); - assertCallbackEmitted(results, address(callback)); -} - -function testMultiStepProtocol() public { - // Full-cycle: keep processing events until quiescence - CallbackResult[] memory results = triggerFullCycle( - address(origin), - abi.encodeWithSignature("doSomething()"), - ORIGIN_CHAIN_ID, - 20 // max iterations (safety limit) - ); - - // All callbacks across all reactive hops are collected - assertGt(results.length, 1); -} -``` - -## What It Simulates - -The library replaces the three Reactive Network runtime components: - -| Real Component | Local Simulation | Purpose | -|---|---|---| -| System Contract | `MockSystemContract` | Subscription registry and matching | -| ReactVM | `ReactiveSimulator` | Event delivery and `react()` invocation | -| Callback Proxy | `MockCallbackProxy` | Cross-chain callback execution | +--- -### Data Flow (single-step) +## Requirements -``` -1. Test calls origin contract (normal Solidity call) -2. Simulator captures emitted events (vm.getRecordedLogs()) -3. Simulator matches events to subscriptions -4. For each match: builds LogRecord, calls rc.react(logRecord) -5. Simulator captures Callback events from react() -6. Simulator injects RVM ID into callback payload -7. Cross-chain callbacks → executed via MockCallbackProxy - Same-chain callbacks → executed via vm.prank(SERVICE_ADDR) -``` - -### Data Flow (full-cycle) - -``` -1-7. Same as single-step -8. Events emitted during callback execution are captured -9. Each event is tagged with the callback's destination chain ID -10. Events are fed back to step 3 for the next iteration -11. Repeats until no callbacks are produced or maxIterations is reached -``` - -## API Reference - -### `ReactiveTest` (Base Contract) - -Inherit this in your test files. Provides: - -#### State - -| Member | Type | Description | -|---|---|---| -| `sys` | `MockSystemContract` | System contract at `SERVICE_ADDR` | -| `proxy` | `MockCallbackProxy` | Callback proxy for executing cross-chain callbacks | -| `rvmId` | `address` | Simulated deployer/RVM identity (default: `address(this)`) | -| `reactiveChainId` | `uint256` | Reactive chain ID for self-callback detection (default: `0x512512`) | - -#### Chain Registry - -Register contracts with their logical chain IDs for auto chain ID detection. This eliminates the need to pass `originChainId` manually on every trigger call. - -```solidity -// Register a contract as belonging to a specific chain -registerChain(address(myContract), SEPOLIA); - -// Look up chain ID (returns fallback if not registered) -uint256 chainId = resolveChainId(address(myContract), fallbackId); -``` - -#### Single-Step Trigger Methods +This library links against the real Omni contracts and uses `transient` storage and a deep internal +pipeline, so consumers **must** configure their project as follows. -Run one reactive cycle: origin event → `react()` → callbacks. +**1. Install this library and its two contract dependencies at your project's top level.** The lib +imports the omni contracts via `@reactive/` and `@system/`, so those repos must sit directly under +your own `lib/` (not nested under `lib/reactive-test-lib/`). Pin the exact revisions this library is +built and tested against — they match its submodule gitlinks, and the harness relies on those precise +contract layouts: -```solidity -// With explicit chain ID -function triggerAndReact(address origin, bytes memory callData, uint256 originChainId) - internal returns (CallbackResult[] memory); - -function triggerAndReactWithValue(address origin, bytes memory callData, uint256 value, uint256 originChainId) - internal returns (CallbackResult[] memory); - -// With auto chain ID detection (requires registerChain) -function triggerAndReact(address origin, bytes memory callData) - internal returns (CallbackResult[] memory); - -function triggerAndReactWithValue(address origin, bytes memory callData, uint256 value) - internal returns (CallbackResult[] memory); -``` - -#### Full-Cycle Trigger Methods - -Run the complete multi-step reactive cycle until no more callbacks are produced. Callback execution produces new events, which are matched against subscriptions, triggering further `react()` calls — repeating until quiescence or `maxIterations`. - -```solidity -// With explicit chain ID -function triggerFullCycle(address origin, bytes memory callData, uint256 originChainId, uint256 maxIterations) - internal returns (CallbackResult[] memory); - -function triggerFullCycleWithValue(address origin, bytes memory callData, uint256 value, uint256 originChainId, uint256 maxIterations) - internal returns (CallbackResult[] memory); - -// With auto chain ID detection (requires registerChain) -function triggerFullCycle(address origin, bytes memory callData, uint256 maxIterations) - internal returns (CallbackResult[] memory); - -function triggerFullCycleWithValue(address origin, bytes memory callData, uint256 value, uint256 maxIterations) - internal returns (CallbackResult[] memory); +```bash +forge install Reactive-Network/reactive-test-lib +forge install Reactive-Network/reactive-lib-omni@v0.1.0 +forge install Reactive-Network/system-smart-contracts@8f7b902700dc1a545d11b87bfb575ac06485224a ``` -#### Cron Methods +**2. `remappings.txt`** — the first line lets you `import "reactive-test-lib/…"`; the rest resolve the +omni contracts to your top-level `lib/`: -```solidity -function triggerCron(CronType cronType) internal returns (CallbackResult[] memory); -function advanceAndTriggerCron(uint256 blocks, CronType cronType) internal returns (CallbackResult[] memory); ``` - -#### Assertion Helpers - -```solidity -assertCallbackCount(results, expectedCount) // Exact callback count -assertNoCallbacks(results) // Zero callbacks -assertCallbackEmitted(results, targetAddress) // Callback targets specific address -assertCallbackSuccess(results, index) // Callback at index succeeded -assertCallbackFailure(results, index) // Callback at index reverted +reactive-test-lib/=lib/reactive-test-lib/src/ +@reactive/src/=lib/reactive-lib-omni/src/ +@system/=lib/system-smart-contracts/src/ +forge-std/=lib/forge-std/src/ ``` -#### VM Mode Helper - -If your reactive contract uses the `vmOnly` modifier (from `AbstractReactive`), call this after deployment: +**3. `foundry.toml`:** -```solidity -enableVmMode(address(myReactiveContract)); +```toml +[profile.default] +solc_version = "0.8.29" # the Omni pragma is ^0.8.29 +evm_version = "cancun" # AbstractProxiedPayableBridge uses transient storage +via_ir = true # required: the engine inlines into your test contract (stack depth) +optimizer = true +optimizer_runs = 200 ``` -This sets the `vm` storage flag to `true`. Needed because etching code to `SERVICE_ADDR` causes `detectVm()` to set `vm = false`. - -### `CallbackResult` Struct - -Each callback execution returns: - -```solidity -struct CallbackResult { - uint256 chainId; // Destination chain ID - address target; // Callback target contract - uint64 gasLimit; // Gas limit specified by react() - bytes payload; // Original callback payload - bool success; // Whether the callback call succeeded - bytes returnData; // Return/revert data from the callback -} -``` +--- -### `CronType` Enum +## Quick start ```solidity -enum CronType { - Cron1, // Every block - Cron10, // Every 10 blocks - Cron100, // Every 100 blocks - Cron1000, // Every 1,000 blocks - Cron10000 // Every 10,000 blocks -} -``` +import { ReactiveTest, FeeMode, CallbackResult } from "reactive-test-lib/ReactiveTest.sol"; -## Examples - -### Event-Driven Reactive Contract +contract MyTest is ReactiveTest { + uint256 constant REACTIVE_CHAIN = 0x512512; + uint256 constant SEPOLIA = 11155111; -```solidity -contract BasicReactiveTest is ReactiveTest { MyOrigin origin; - MyReactive rc; - MyCallback cb; + MyReactive reactive; + MyCallback callbackContract; - uint256 constant SEPOLIA = 11155111; + function setUp() public { + // 1. Bring up the reactive network (etches the system contract, starts recording). + _reactiveSetUp(REACTIVE_CHAIN); - function setUp() public override { - super.setUp(); + // 2. Register each non-reactive chain — returns its callback proxy address. + address proxy = _registerChain(SEPOLIA); + // 3. Deploy your contracts and register which chain each lives on. origin = new MyOrigin(); - cb = new MyCallback(address(proxy)); - rc = new MyReactive(address(sys), SEPOLIA, address(origin), address(cb)); + reactive = new MyReactive(SEPOLIA, address(origin), /* ... */, SEPOLIA); + callbackContract = new MyCallback(proxy, address(reactive)); - registerChain(address(origin), SEPOLIA); + _registerContract(REACTIVE_CHAIN, address(reactive)); // flagged reactive + _registerContract(SEPOLIA, address(origin)); + _registerContract(SEPOLIA, address(callbackContract)); } - function testReactionTriggered() public { - // Auto chain ID detection — no need to pass SEPOLIA - CallbackResult[] memory results = triggerAndReact( - address(origin), - abi.encodeWithSignature("emitEvent()") - ); + function test_crossChainCallback() public { + // 4. Emit an origin event (the harness records it). + _switchToChain(SEPOLIA); + origin.deposit{ value: 0.002 ether }(); - assertCallbackCount(results, 1); - assertCallbackSuccess(results, 0); - } - - function testNoReactionForUnrelatedEvent() public { - address unrelated = makeAddr("unrelated"); - vm.etch(unrelated, address(origin).code); - - CallbackResult[] memory results = triggerAndReact( - unrelated, - abi.encodeWithSignature("emitEvent()"), - SEPOLIA - ); + // 5. Run one reactive cycle. + _switchToChain(REACTIVE_CHAIN); + CallbackResult[] memory results = _react(FeeMode.Subsidized); - assertNoCallbacks(results); // subscription doesn't match - } - - function testRvmIdInjection() public { - triggerAndReact( - address(origin), - abi.encodeWithSignature("emitEvent()") - ); - - // First argument of the callback payload is overwritten with rvmId - assertEq(cb.lastRvmId(), rvmId); + assertEq(results.length, 1); + assertTrue(results[0].success); } } ``` -### Cron-Driven Reactive Contract +Your contracts extend the standard Omni bases from `reactive-lib-omni`: ```solidity -import {CronType} from "reactive-test-lib/interfaces/IReactiveInterfaces.sol"; -import {ReactiveConstants} from "reactive-test-lib/constants/ReactiveConstants.sol"; - -contract CronTest is ReactiveTest { - MyCronReactive rc; - - function setUp() public override { - super.setUp(); - rc = new MyCronReactive(address(sys), ReactiveConstants.CRON_TOPIC_1); - } +import { AbstractReactive } from "@reactive/src/base/AbstractReactive.sol"; +import { AbstractCallback } from "@reactive/src/base/AbstractCallback.sol"; +import { ISystemContract } from "@reactive/src/interfaces/ISystemContract.sol"; +import { IPayable } from "@reactive/src/interfaces/IPayable.sol"; - function testCronTriggersCallback() public { - CallbackResult[] memory results = triggerCron(CronType.Cron1); - assertCallbackCount(results, 1); +contract MyReactive is AbstractReactive { + constructor(uint256 originChain, address origin, uint256 topic0, uint256 destChain) { + SYSTEM.subscribe(originChain, origin, topic0, REACTIVE_IGNORE, REACTIVE_IGNORE, REACTIVE_IGNORE); } - function testAdvanceBlocksAndTrigger() public { - uint256 startBlock = block.number; - - CallbackResult[] memory results = advanceAndTriggerCron(100, CronType.Cron1); - - assertCallbackCount(results, 1); - assertEq(block.number, startBlock + 100); - } -} -``` - -### Multi-Step Reactive Protocol (e.g. Bridge) - -For protocols that require multiple reactive cycles — like a bridge with confirmation rounds — use `triggerFullCycle`. Events emitted during callback execution are automatically captured, matched against subscriptions, and fed back through `react()`. - -```solidity -contract BridgeTest is ReactiveTest { - Bridge bridge; - ReactiveBridge reactiveBridge; - - uint256 constant SEPOLIA = 11155111; - - function setUp() public override { - super.setUp(); - - bridge = new Bridge(address(proxy), /* ... */); - reactiveBridge = new ReactiveBridge( - reactiveChainId, SEPOLIA, address(bridge), /* ... */ - ); - enableVmMode(address(reactiveBridge)); - - // Register for auto chain ID detection - registerChain(address(bridge), SEPOLIA); - registerChain(address(reactiveBridge), reactiveChainId); - } - - function testFullBridgeFlow() public { - // Full-cycle runs the entire multi-hop protocol: - // bridge() → SendMessage → react() → Callback to Bridge - // → ConfirmationRequest → react() → Callback to Bridge - // → Confirmation → react() → ... until delivered - CallbackResult[] memory results = triggerFullCycleWithValue( - address(reactiveBridge), - abi.encodeWithSignature("bridge(uint256,address)", 123, recipient), - 1 ether, - 20 // max iterations - ); - - // Verify all callbacks succeeded - for (uint256 i = 0; i < results.length; i++) { - assertCallbackSuccess(results, i); + function react(LogRecord calldata log) external onlySystem { + if (/* condition */) { + bytes memory payload = abi.encodeWithSignature("callback(address)", address(0)); + SYSTEM.requestCallbackV_1_0( + ISystemContract.CallbackConfiguration_V_1_0(destChain, callbackContract, 1e6, payload) + ); } } } -``` - -### Self-Callbacks (Same-Chain Reactive Callbacks) -When a reactive contract emits `Callback(reactiveChainId, address(this), ...)`, the callback targets the same chain. These are delivered via `vm.prank(SERVICE_ADDR)` — matching the real network where RVM-to-RN callbacks come from `SERVICE_ADDR`, not the callback proxy. +contract MyCallback is AbstractCallback { + constructor(address proxy, address reactive) AbstractCallback(IPayable(payable(proxy)), reactive) { } -This is critical for contracts like `ReactiveBridge` that use `AbstractCallback(address(SERVICE_ADDR))` and have entry points guarded by `authorizedSenderOnly`. - -```solidity -// In react(): -emit Callback(reactiveChainId, address(this), GAS_LIMIT, payload); -// → Simulator detects chainId == reactiveChainId -// → Delivers via vm.prank(SERVICE_ADDR) instead of proxy -// → authorizedSenderOnly passes because msg.sender == SERVICE_ADDR + // arg0 is the reactive contract's address, injected by the harness for authentication. + function callback(address sender) external onlyCallbackSender(sender) { /* ... */ } +} ``` -No special test setup needed — the simulator handles this automatically based on `reactiveChainId`. +See `test/` for complete, runnable examples of every scenario. -### Passing Callback Sender for `authorizedSenderOnly` +--- -For **cross-chain** callbacks, `AbstractCallback` authorizes `_callback_sender` in its constructor. Pass `address(proxy)` so the mock proxy satisfies the modifier: +## API -```solidity -myCallback = new MyCallbackContract(address(proxy)); -``` +All methods are `internal` on the `ReactiveTest` base. -For **same-chain** callbacks (reactive contracts that use `AbstractCallback(address(SERVICE_ADDR))`), the simulator delivers via `SERVICE_ADDR` automatically. +### Environment -### Passing RVM ID for `rvmIdOnly` +| Method | Description | +|---|---| +| `_reactiveSetUp(uint256 chainId_)` | One-time setup. Etches the system contract at `0x8888…8888`, starts `recordLogs()`, sets the active chain to the reactive chain. `chainId_` must be non-zero. | +| `_registerChain(uint256 chainId_) → address` | Registers a non-reactive chain and deploys its `CallbackProxy` (default lib-owned callback sender). Returns the proxy address. Reverts on `0`, the reactive chain, or a duplicate. | +| `_registerChain(uint256 chainId_, address[] senders_) → address` | Same, with a caller-supplied callback-sender set; `_react` pranks as `senders_[0]` when delivering on this chain. | +| `_getCallbackProxy(uint256 chainId_) → address` | The callback proxy for a registered chain. Reverts for the reactive chain (it has no proxy) and unknown chains. | +| `_registerContract(uint256 chainId_, address contract_)` | Assigns a contract to a chain. Contracts on the reactive chain are flagged **reactive** (only they drive the subscription index). | +| `_switchToChain(uint256 chainId_)` | Sets `block.chainid`. Does **not** isolate state (see Limitations). | -`AbstractCallback` stores `msg.sender` as `rvm_id`. Both the proxy (cross-chain) and the simulator (same-chain) inject `rvmId` into the first callback argument. Override `rvmId` in your test if needed: - -```solidity -function testWithCustomDeployer() public { - rvmId = makeAddr("deployer"); - // ... callbacks will now inject this address -} -``` +### Running the reactive cycle -## Advanced Usage +| Method | Description | +|---|---| +| `_react(FeeMode mode_) → CallbackResult[]` | Consumes the recorded logs and runs one full cycle: apply subscription changes in order, match origin events, `trigger()` each unique subscriber, deliver the resulting callbacks. Returns the delivered callbacks. | +| `_reactUntilQuiescent(FeeMode mode_, uint256 maxRounds_) → CallbackResult[]` | Repeats `_react` until nothing cascades (no callback/react emissions carry forward) or `maxRounds_` is hit. Use for multi-step protocols. | +| `_emitCron(uint256 blockNumber_)` | Drives cron-subscribed reactive contracts. On the next `_react`, expands into the `CronN` cascade (`Cron1` always; `CronN` when `blockNumber_ % N == 0`). Block `0` fires the full cascade. | -### Using the Simulator Directly - -For fine-grained control, use `ReactiveSimulator` and `CronSimulator` libraries directly: +`CallbackResult`: ```solidity -import {ReactiveSimulator} from "reactive-test-lib/simulator/ReactiveSimulator.sol"; -import {LogRecord, IReactive} from "reactive-test-lib/interfaces/IReactiveInterfaces.sol"; - -// Deliver a hand-crafted LogRecord to a specific reactive contract -LogRecord memory log = LogRecord({ - chain_id: 1, - _contract: address(origin), - topic_0: uint256(keccak256("Transfer(address,address,uint256)")), - topic_1: 0, - topic_2: 0, - topic_3: 0, - data: abi.encode(100), - block_number: block.number, - op_code: 0, - block_hash: 0, - tx_hash: 0, - log_index: 0 -}); - -ReactiveSimulator.deliverRawEvent(vm, IReactive(address(rc)), log); -``` - -### Using Fixtures Without Inheritance - -If you prefer composition over inheritance: - -```solidity -import {ReactiveFixtures} from "reactive-test-lib/base/ReactiveFixtures.sol"; -import {MockSystemContract} from "reactive-test-lib/mock/MockSystemContract.sol"; -import {MockCallbackProxy} from "reactive-test-lib/mock/MockCallbackProxy.sol"; - -contract MyCustomTest is Test { - function setUp() public { - (MockSystemContract sys, MockCallbackProxy proxy) = ReactiveFixtures.deployAll(vm); - // ... custom setup - } +struct CallbackResult { + uint256 destChainId; + address recipient; + address sender; // reactive contract injected into payload arg0 + uint64 gasLimit; + bytes payload; + bool success; // recipient call succeeded (proxy did not revert and no CallbackFailure) + bytes returnData; // proxy-level revert data, if any } ``` -## How It Works - -### Single-Chain Simulation - -Everything runs on a single EVM instance. Chain IDs are purely logical values stamped on `LogRecord.chain_id` and `Callback.chain_id`. There is no actual cross-chain communication. - -### Callback Routing +### Fee modes -The simulator routes callbacks based on the `Callback` event's `chain_id`: +`_react` takes a **required** `FeeMode` so every call states its intent: -- **Cross-chain** (`chain_id != reactiveChainId`): Executed via `MockCallbackProxy`, which injects RVM ID and calls the target. This simulates the real callback proxy on destination chains. -- **Same-chain** (`chain_id == reactiveChainId`): Executed via `vm.prank(SERVICE_ADDR)` with RVM ID injection. This simulates how RVM-to-RN callbacks are delivered by `SERVICE_ADDR` on the Reactive Network. +- **`FeeMode.Subsidized`** — charging is neutralized (`tx.gasprice` and `block.basefee` forced to `0` + before each `trigger`/`deliverCallback`), so there is no debt, no `BlacklistContract`, no kickback. + The frictionless default for functional tests. +- **`FeeMode.Metered`** — the real gas-charging machinery runs: contracts are charged from their + reserves, unpaid amounts accrue to `_debts`, `pay(uint256)` is invoked, `BlacklistContract`/ + `WhitelistContract` fire, and kickbacks go to `tx.origin`. Fund a contract's balance with + `_fund(addr, amount)` (or top up proxy/system reserves with `depositTo{value}(contract)`) and set a + gas price (`vm.txGasPrice`) to exercise the debt / blacklist / `coverDebt` flows. -### Multi-Step Cycle +### Event-capture assertions -`simulateFullCycle` orchestrates multi-hop protocols: +Because the library owns the Foundry log-recording buffer (see Limitations), it captures every event +of a cycle and exposes it instead of the raw cheatcodes: -1. Execute initial call, capture events -2. Match events against subscriptions, call `react()`, collect `Callback` specs -3. Execute each callback while recording events emitted by the target -4. Tag new events with the callback's `chain_id` (events from a Sepolia callback are Sepolia events) -5. Feed events back to step 2 -6. Stop when no callbacks are produced or `maxIterations` is reached +| Method | Description | +|---|---| +| `_recordedLogs() → Vm.Log[]` | Every log observed during the last `_react` / `_reactUntilQuiescent` call (all rounds accumulated). | +| `_assertEmitted(address emitter, bytes32 topic0)` | Reverts unless ≥1 match. | +| `_assertEmitted(address emitter, bytes32 topic0, bytes data)` | Match on data too. | +| `_assertNotEmitted(address emitter, bytes32 topic0)` | Reverts if any match. | +| `_countEmitted(address emitter, bytes32 topic0) → uint256` | Number of matches. | +| `_findEmitted(address emitter, bytes32 topic0) → (bool, Vm.Log)` | First match, for bespoke assertions on `topics[n]` / `data`. | -This handles complex protocols like bridges where a single user action triggers a chain of reactive cycles across multiple logical chains. +`vm.expectEmit` still works for events from your test's **direct** calls made *before* `_react`. -### Chain Registry +### Other helpers -The chain registry maps contract addresses to logical chain IDs. When using auto-detect trigger methods, the simulator looks up the origin's chain ID from the registry instead of requiring it as a parameter. +| Method | Description | +|---|---| +| `_fund(address account, uint256 amount)` | `vm.deal` the account's balance — convenience for funding a contract so it can cover its own `FeeMode.Metered` charges. | +| `_reactiveChainId() → uint256` | The reactive-network chain id set in `_reactiveSetUp`. | -In full-cycle mode, events captured during callback execution are automatically tagged with the correct chain ID (the callback's destination chain), so the registry is mainly useful for the initial trigger. +--- -### `vmOnly` / `rnOnly` Modifiers +## How it works -`AbstractReactive.detectVm()` checks `extcodesize(SERVICE_ADDR)`. Since we etch mock code to that address, `detectVm()` sets `vm = false` (thinks it's on Reactive Network). This allows `subscribe()` to work in constructors. If your `react()` function uses `vmOnly`, call `enableVmMode(address(rc))` after deployment to flip the flag. +1. `_reactiveSetUp` deploys the real `SystemContract` behind an ERC1967 proxy and **etches** it at + `0x8888…8888` (the address hardcoded in `AbstractReactive.SYSTEM`), then calls `vm.recordLogs()`. +2. Your contracts' `subscribe()` calls and origin events are recorded as they're emitted. +3. `_react` drains the recorded logs and, in stream order: applies `Subscribe`/`Unsubscribe` to an + in-library subscription index (a port of the node's `MemFilterService`), matches origin events + against it, and calls `SystemContract.trigger(rc, log)` — pranked as the node's injector + `0x038E…6B82` — for each unique subscriber. +4. Each `react()`'s `CallbackRequest` is captured, the reactive contract's address is injected into + the callback payload's first argument, and the callback is delivered through the destination + chain's `CallbackProxy.deliverCallback(...)`. +5. Callbacks that emit events feed the next cycle, so cascades run via `_reactUntilQuiescent`. -### RVM ID Injection +Subscription changes a reactive contract makes *inside* its own `react()` are **deferred** to the next +cycle, modelling the node's one-block delay. -The real Reactive Network overwrites the first 20 bytes of the first callback argument with the deployer's address. Both `MockCallbackProxy` (cross-chain) and the simulator's direct delivery (same-chain) replicate this behavior, so `rvmIdOnly` modifiers work correctly in tests. +--- -### Subscription Matching +## Limitations -`MockSystemContract` supports the same wildcard semantics as the real system contract: +1. **The library owns log recording.** From `_reactiveSetUp` onward it drives `vm.recordLogs()` / + `vm.getRecordedLogs()` for the whole test. **Do not call those cheatcodes yourself** — you will + desynchronize the engine. Use the `_recordedLogs` / `_assertEmitted` / … helpers, the returned + `CallbackResult[]`, or `vm.expectEmit` before a direct call. +2. **`via_ir` is required for consumers.** The engine's `internal` library code inlines into your test + contract, so your project must set `via_ir = true` (plus `evm_version = "cancun"` and + `solc_version = "0.8.29"`), or you will hit "stack too deep". +3. **Single-EVM state sharing.** All logical chains share one address space and storage. + `_switchToChain` sets `block.chainid` only — cross-chain *identity* (which proxy, which chain id on + the `LogRecord`) is modelled faithfully; cross-chain *state isolation* is not. +4. **Reactive-chain callbacks are unsupported.** Omni has no RVMs, so there is no self-callback into + the reactive network. A `CallbackRequest` whose destination is the reactive chain makes `_react` + revert, surfacing the misuse. +5. **Synthetic log fields.** `LogRecord.blockHash` / `txHash` / `logIndex` are `0` and `blockNumber` + is Foundry's `block.number`; a reactive contract that dedups on `(txHash, logIndex)` will see zeros. -| Field | Wildcard Value | Meaning | -|---|---|---| -| `chain_id` | `0` | Match any chain | -| `_contract` | `address(0)` | Match any contract | -| `topic_0..3` | `REACTIVE_IGNORE` | Match any topic value | - -## Project Structure - -``` -src/ - base/ - ReactiveTest.sol # Base test contract (extends forge-std/Test) - ReactiveFixtures.sol # Factory helpers for standalone usage - constants/ - ReactiveConstants.sol # SERVICE_ADDR, REACTIVE_IGNORE, cron topics - interfaces/ - IReactiveInterfaces.sol # LogRecord, CallbackResult, CronType, IReactive - IReactiveTest.sol # Internal interfaces - mock/ - MockSystemContract.sol # Subscription registry with wildcard matching - MockCallbackProxy.sol # Callback executor with RVM ID injection - simulator/ - ReactiveSimulator.sol # Core: event -> react() -> callback pipeline - CronSimulator.sol # Synthetic cron event triggers - ReactiveTest.sol # Convenience re-export of all library components -``` +--- ## License -MIT +MIT. diff --git a/foundry.toml b/foundry.toml index cca3639..3a091c3 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,7 +2,50 @@ src = "src" out = "out" libs = ["lib"] -solc_version = "0.8.20" +solc_version = "0.8.29" +evm_version = "cancun" +# via_ir + optimizer: the ReactEngine pipeline is deep enough to hit "stack too deep" otherwise. +via_ir = true +optimizer = true +optimizer_runs = 200 [profile.default.fuzz] runs = 256 + +# Gas-benchmark profile (test/bench/*). Lifts the gas/memory ceilings so a single test can populate +# and scan up to 1M subscriptions. Opt-in only: RUN_BENCH=true FOUNDRY_PROFILE=bench forge test. +[profile.bench] +src = "src" +out = "out" +libs = ["lib"] +solc_version = "0.8.29" +evm_version = "cancun" +via_ir = true +optimizer = true +optimizer_runs = 200 +gas_limit = "18446744073709551615" # u64 max +memory_limit = 4294967296 # 4 GiB + +# CI selects this profile via FOUNDRY_PROFILE=ci (see .github/workflows/test.yml). +[profile.ci] +src = "src" +out = "out" +libs = ["lib"] +solc_version = "0.8.29" +evm_version = "cancun" +via_ir = true +optimizer = true +optimizer_runs = 200 + +[profile.ci.fuzz] +runs = 256 + +# Match the omni dependencies' house style (`import { X } from ...`). +[fmt] +bracket_spacing = true +line_length = 120 + +# Example/mock reactive contracts use idiomatic lowercase immutables (as real reactive contracts do), +# so the opinionated SCREAMING_SNAKE_CASE immutable lint is disabled project-wide. +[lint] +exclude_lints = ["screaming-snake-case-immutable"] diff --git a/lib/reactive-lib-omni b/lib/reactive-lib-omni new file mode 160000 index 0000000..3ade0dc --- /dev/null +++ b/lib/reactive-lib-omni @@ -0,0 +1 @@ +Subproject commit 3ade0dcf1d27e67e5783ef807e73a1b9e0c0cce8 diff --git a/lib/system-smart-contracts b/lib/system-smart-contracts new file mode 160000 index 0000000..8f7b902 --- /dev/null +++ b/lib/system-smart-contracts @@ -0,0 +1 @@ +Subproject commit 8f7b902700dc1a545d11b87bfb575ac06485224a diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..e28dc20 --- /dev/null +++ b/remappings.txt @@ -0,0 +1,3 @@ +@reactive/src/=lib/reactive-lib-omni/src/ +@system/=lib/system-smart-contracts/src/ +forge-std/=lib/forge-std/src/ diff --git a/src/OmniConstants.sol b/src/OmniConstants.sol new file mode 100644 index 0000000..fc23c77 --- /dev/null +++ b/src/OmniConstants.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.29; + +/// @title OmniConstants +/// @notice Addresses, event topics, and gas parameters the Reactive Omni test harness relies on. +/// @dev All values were read from the omni source trees (see `specs/001-omni-migration.md` §3) or +/// verified with `cast keccak`. Do not "guess-fix" these — they must stay byte-identical to +/// what ships on-chain, otherwise the log-derived subscription index and triggers desync. +library OmniConstants { + // ---------------------------------------------------------------------------------------- + // Addresses (§3.1) + // ---------------------------------------------------------------------------------------- + + /// @notice System-contract proxy address, hardcoded in `AbstractReactive.SYSTEM`. + address internal constant SYSTEM_ADDR = 0x8888888888888888888888888888888888888888; + + /// @notice Legacy system-contract proxy address, kept for realism as the cron event emitter. + address internal constant LEGACY_PROXY_ADDR = 0x0000000000000000000000000000000000fffFfF; + + /// @notice Injector / initializer / reactive-tx sender (`INJ_ADDR` == `INIT_ADDR`). + address internal constant INJ_ADDR = 0x038E06667e42782E571EaB20432b9237F9bD6B82; + + /// @notice System-contract privileged owner address. + address internal constant OWNER_ADDR = 0x10be5Db673D1FEEA5d0D4C6d57A1098CDC007c89; + + /// @notice ERC1967 implementation storage slot. + bytes32 internal constant ERC1967_IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + + /// @notice Default library-owned callback-sender / validator EOA. The harness pranks as this + /// identity when triggering reactive contracts and delivering callbacks. + address internal constant CALLBACK_SENDER = 0xF308B827fC9e045f5cEE14Ec6ba92A0C20ABfb9C; + + // ---------------------------------------------------------------------------------------- + // Subscription wildcard (§3.1) + // ---------------------------------------------------------------------------------------- + + /// @notice Wildcard topic value; matches any value in a subscription topic slot. + uint256 internal constant REACTIVE_IGNORE = 0xa65f96fc951c35ead38878e0f0b7a3c744a6f5ccc1476b313353ce31712313ad; + + // ---------------------------------------------------------------------------------------- + // Event topics (§3.2, verified with `cast keccak`) + // ---------------------------------------------------------------------------------------- + + /// @notice `Subscribe(address,uint256,address,uint256,uint256,uint256,uint256)`. + bytes32 internal constant TOPIC_SUBSCRIBE = 0xe9b38458922e1af481b2244c7c2bb32e465e90c352946042d2a09472fad6c246; + + /// @notice `Unsubscribe(address,uint256,address,uint256,uint256,uint256,uint256)`. + bytes32 internal constant TOPIC_UNSUBSCRIBE = 0x15fca4b8348be4e206988055f4ba52d98f0ba260e633cf0968042fcfb004a944; + + /// @notice `CallbackRequest(uint256,address,address,uint8,bytes)`. + bytes32 internal constant TOPIC_CALLBACK_REQUEST = + 0xef94dbc611c4b95bf4b8ba34b413972180c2faf26fbb480befacedd83e07b133; + + /// @notice `Callback(uint256,address,uint64,bytes)` — legacy, intentionally NOT processed (§4). + bytes32 internal constant TOPIC_CALLBACK_LEGACY = + 0x8dd725fa9d6cd150017ab9e60318d40616439424e2fade9c1c58854950917dfc; + + /// @notice `BlacklistContract(address)`. + bytes32 internal constant TOPIC_BLACKLIST = 0x930136576d6bd679046e0c2e6212ce4cacec0401db71e9a27f906d2bab5e08f3; + + /// @notice `WhitelistContract(address)`. + bytes32 internal constant TOPIC_WHITELIST = 0xc91181be4112cf78026ec1c30b4f5ecac00faa44c8d5e9523e08325cf186d8a7; + + /// @notice `CallbackFailure(address,bytes)` — emitted by `CallbackProxy` when the recipient call + /// fails; used to derive a delivered callback's `success` (the proxy swallows the revert). + bytes32 internal constant TOPIC_CALLBACK_FAILURE = + 0xc8313f695443128e273f1edfcec40b94b7deea8dfbeafd0043290d6601d999db; + + /// @notice `ReactiveContractReverted(address,LogRecord,bytes)` — emitted by `SystemContract.trigger` + /// when a reactive contract's `react()` reverts. Its presence in a trigger's log batch means + /// that `react()`'s own (rolled-back but still-recorded) emissions must be discarded. + bytes32 internal constant TOPIC_REACTIVE_CONTRACT_REVERTED = + 0xe2ec81387b5c5afbb48878517c28d6f4edf6cb496a562e787d18ea8fca1da316; + + // ---------------------------------------------------------------------------------------- + // Cron topics (§6.7, verified with `cast keccak`) + // ---------------------------------------------------------------------------------------- + + /// @notice `Cron1(uint256)` — fires every block. + bytes32 internal constant CRON_TOPIC_1 = 0xf02d6ea5c22a71cffe930a4523fcb4f129be6c804db50e4202fb4e0b07ccb514; + + /// @notice `Cron10(uint256)` — fires when `blockNumber % 10 == 0`. + bytes32 internal constant CRON_TOPIC_10 = 0x04463f7c1651e6b9774d7f85c85bb94654e3c46ca79b0c16fb16d4183307b687; + + /// @notice `Cron100(uint256)` — fires when `blockNumber % 100 == 0`. + bytes32 internal constant CRON_TOPIC_100 = 0xb49937fb8970e19fd46d48f7e3fb00d659deac0347f79cd7cb542f0fc1503c70; + + /// @notice `Cron1000(uint256)` — fires when `blockNumber % 1000 == 0`. + bytes32 internal constant CRON_TOPIC_1000 = 0xe20b31294d84c3661ddc8f423abb9c70310d0cf172aa2714ead78029b325e3f4; + + /// @notice `Cron10000(uint256)` — fires when `blockNumber % 10000 == 0`. + bytes32 internal constant CRON_TOPIC_10000 = 0xd214e1d84db704ed42d37f538ea9bf71e44ba28bc1cc088b2f5deca654677a56; + + /// @notice `__FakeCron(uint256)` — lib-internal marker emitted by `_emitCron` (§6.7). + bytes32 internal constant FAKE_CRON_TOPIC = 0x8c39130d800864538c1418067c48914d4e26d2bd3c0709f0c8b53ec002d43461; + + // ---------------------------------------------------------------------------------------- + // Default proxy / system gas parameters (§6.2, §6.3) + // ---------------------------------------------------------------------------------------- + + /// @notice Default `maxChargeGas` for freshly deployed callback proxies. + uint256 internal constant DEFAULT_MAX_CHARGE_GAS = 50_000; + + /// @notice Default `extraGas` for freshly deployed callback proxies / the system contract. + uint256 internal constant DEFAULT_EXTRA_GAS = 100_000; + + /// @notice Default `gasPriceCoeffPer1000` for freshly deployed callback proxies / the system contract. + uint256 internal constant DEFAULT_GAS_PRICE_COEFF_PER_1000 = 1_000; +} diff --git a/src/ReactEngine.sol b/src/ReactEngine.sol new file mode 100644 index 0000000..7af64a9 --- /dev/null +++ b/src/ReactEngine.sol @@ -0,0 +1,459 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.29; + +import { Vm } from "forge-std/Vm.sol"; +import { IReactive } from "@reactive/src/interfaces/IReactive.sol"; +import { ISystemContract } from "@reactive/src/interfaces/ISystemContract.sol"; +import { SystemContract } from "@system/omni/SystemContract.sol"; +import { CallbackProxy } from "@system/omni/CallbackProxy.sol"; +import { AbstractProxiedPayableBridge } from "@system/omni/base/AbstractProxiedPayableBridge.sol"; +import { OmniConstants } from "./OmniConstants.sol"; +import { SubscriptionIndex } from "./SubscriptionIndex.sol"; +import { + FeeMode, + CallbackResult, + SubscriptionStore, + PendingCallback, + DeferredSub, + ReactiveEnv +} from "./ReactiveTypes.sol"; + +/// @title ReactEngine +/// @notice Implements the §7 reactive cycle: consume the recorded log stream, apply subscription +/// changes in order, match origin events against the index, `trigger()` each unique +/// subscriber, capture the resulting `CallbackRequest`s, and deliver them through the +/// destination callback proxies. Only `CallbackRequest` is processed — the legacy `Callback` +/// event is intentionally ignored (§4). +/// @dev A library (internal functions inline into `ReactiveTest`) operating on `ReactiveEnv storage`. +/// Kept out of the base contract to keep the pipeline split into small functions (stack depth). +library ReactEngine { + using SubscriptionIndex for SubscriptionStore; + + /// @notice Runs one reactive cycle and returns the batch of delivered callback results. + /// @dev The caller (`ReactiveTest`) resets `capturedLogs` before the first run so + /// `_reactUntilQuiescent` can accumulate across rounds; this function only appends to it. + function run(ReactiveEnv storage env, Vm vm, FeeMode mode) internal returns (CallbackResult[] memory) { + delete env.pendingCallbacks; + delete env.deferredSubs; + delete env.results; + + uint256 entryChain = env.activeChainId; + // Subsidized mode zeroes tx.gasprice / block.basefee before each trigger and delivery; save + // them so we can restore on exit and never leak that into the test's later assertions (e.g. a + // subsequent Metered cycle must still see the price the test set, not a leaked 0). + // NB: saved to 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 (reading the + // already-zeroed value). A storage load cannot be folded back into the opcode. + env.savedGasPrice = tx.gasprice; + env.savedBaseFee = block.basefee; + + Vm.Log[] memory logs = _collectLogs(env, vm); + + // Reactive contracts execute on the reactive chain. + vm.chainId(env.reactiveChainId); + + for (uint256 i = 0; i < logs.length; ++i) { + _processLog(env, vm, logs[i], mode); + } + + _deliverCallbacks(env, vm, mode); + _applyDeferred(env); + + // Restore the chain and the fee-related cheatcode state the test had on entry. + vm.chainId(entryChain); + env.activeChainId = entryChain; + vm.txGasPrice(env.savedGasPrice); + vm.fee(env.savedBaseFee); + + return _copyResults(env); + } + + // ============================================================================================ + // Stream collection & dispatch + // ============================================================================================ + + /// @dev Concatenates carried-over `pendingLogs` with the freshly recorded logs, clears the + /// carry-over, and appends everything (except the internal `__FakeCron` marker) to + /// `capturedLogs` for the §6.8 assertion helpers. + function _collectLogs(ReactiveEnv storage env, Vm vm) private returns (Vm.Log[] memory logs) { + Vm.Log[] memory fresh = vm.getRecordedLogs(); + uint256 pendingCount = env.pendingLogs.length; + + logs = new Vm.Log[](pendingCount + fresh.length); + for (uint256 i = 0; i < pendingCount; ++i) { + logs[i] = env.pendingLogs[i]; + } + for (uint256 j = 0; j < fresh.length; ++j) { + logs[pendingCount + j] = fresh[j]; + } + delete env.pendingLogs; + + // Capture only the FRESH logs. Carried-over pendingLogs were already captured at their + // emission point (in a prior round of `_reactUntilQuiescent` or a prior `_react` call), so + // re-capturing them here would double-count them in the §6.8 helpers. + for (uint256 k = 0; k < fresh.length; ++k) { + if (!_isFakeCron(fresh[k])) { + env.capturedLogs.push(fresh[k]); + } + } + } + + /// @dev Dispatches one log from the origin/setup stream (immediate subscription changes). + function _processLog(ReactiveEnv storage env, Vm vm, Vm.Log memory log, FeeMode mode) private { + if (_isFakeCron(log)) { + _expandCron(env, vm, log, mode); + return; + } + + if (log.emitter == OmniConstants.SYSTEM_ADDR) { + _handleSystemLog(env, log, false); + return; + } + + // Origin event: resolve the emitter's chain; discard unregistered emitters (§5). + if (!env.contractRegistered[log.emitter]) { + return; + } + uint256 origin = env.chainOfContract[log.emitter]; + IReactive.LogRecord memory record = _toLogRecord(log, origin); + _matchAndTrigger(env, vm, record, log.topics.length, mode); + } + + /// @dev Handles a system-emitted control event. `deferred` is honored only for + /// `Subscribe`/`Unsubscribe` (§7.1); blacklist/whitelist and callback capture are immediate. + function _handleSystemLog(ReactiveEnv storage env, Vm.Log memory log, bool deferred) private { + bytes32 topic0 = log.topics.length > 0 ? log.topics[0] : bytes32(0); + + if (topic0 == OmniConstants.TOPIC_SUBSCRIBE) { + _handleSubscription(env, log, true, deferred); + } else if (topic0 == OmniConstants.TOPIC_UNSUBSCRIBE) { + _handleSubscription(env, log, false, deferred); + } else if (topic0 == OmniConstants.TOPIC_CALLBACK_REQUEST) { + _handleCallbackRequest(env, log); + } else if (topic0 == OmniConstants.TOPIC_BLACKLIST) { + // Blacklist/Whitelist apply immediately (as in the §7 switch), even when emitted during a + // Metered charge inside `trigger` — unlike the node, which applies them with a one-block + // delay. Only Subscribe/Unsubscribe are deferred (§7.1). + env.subscriptions.setActive(_topicAddr(log, 1), false); + } else if (topic0 == OmniConstants.TOPIC_WHITELIST) { + env.subscriptions.setActive(_topicAddr(log, 1), true); + } + // Legacy `Callback` and any other system events are intentionally ignored (§4). + } + + /// @dev Parses a `Subscribe`/`Unsubscribe` log and applies (or defers) the index change. + /// Only reactive-network contracts drive the index (§7.3, Stab.sol L61). + function _handleSubscription(ReactiveEnv storage env, Vm.Log memory log, bool isSubscribe, bool deferred) private { + // topics = [sig, subscriber, chainId, contractAddress]; data = abi.encode(t0,t1,t2,t3) + address subscriber = _topicAddr(log, 1); + if (!env.isReactiveContract[subscriber]) { + return; + } + uint256 chainId = uint256(log.topics[2]); + address listenContract = _topicAddr(log, 3); + (uint256 t0, uint256 t1, uint256 t2, uint256 t3) = abi.decode(log.data, (uint256, uint256, uint256, uint256)); + + if (deferred) { + env.deferredSubs + .push( + DeferredSub({ + isSubscribe: isSubscribe, + chainId: chainId, + listenContract: listenContract, + topic0: t0, + topic1: t1, + topic2: t2, + topic3: t3, + subscriber: subscriber + }) + ); + } else if (isSubscribe) { + env.subscriptions.add(chainId, listenContract, t0, t1, t2, t3, subscriber); + } else { + env.subscriptions.remove(chainId, listenContract, t0, t1, t2, t3, subscriber); + } + } + + /// @dev Parses a `CallbackRequest` and queues it for delivery. `sender_` (topics[2]) is the + /// reactive contract whose address is later injected into the payload (§7.2). + function _handleCallbackRequest(ReactiveEnv storage env, Vm.Log memory log) private { + // topics = [sig, chainId, sender, recipient]; data = abi.encode(version, configuration) + address sender = _topicAddr(log, 2); + (, bytes memory configuration) = abi.decode(log.data, (uint8, bytes)); + ISystemContract.CallbackConfiguration_V_1_0 memory config = + abi.decode(configuration, (ISystemContract.CallbackConfiguration_V_1_0)); + + env.pendingCallbacks + .push( + PendingCallback({ + destChainId: config.chainId, + sender: sender, + recipient: config.recipient, + gasLimit: config.gasLimit, + payload: config.payload + }) + ); + } + + // ============================================================================================ + // Matching & triggering + // ============================================================================================ + + /// @dev Finds the unique active subscribers for a record and triggers each once. Emissions from + /// each `react()` are captured and dispatched with subscription changes deferred (§7.1). + function _matchAndTrigger( + ReactiveEnv storage env, + Vm vm, + IReactive.LogRecord memory record, + uint256 topicCount, + FeeMode mode + ) private { + uint256[4] memory topics; + topics[0] = record.topic0; + topics[1] = record.topic1; + topics[2] = record.topic2; + topics[3] = record.topic3; + + address[] memory subscribers = + env.subscriptions.findSubscribers(record.chainId, record.contractAddress, topics, topicCount); + + for (uint256 i = 0; i < subscribers.length; ++i) { + if (mode == FeeMode.Subsidized) { + vm.txGasPrice(0); + vm.fee(0); + } + vm.prank(OmniConstants.INJ_ADDR); + // A subscriber's `react()` reverting is swallowed inside `trigger` (emits + // ReactiveContractReverted), so `trigger` itself only reverts with `InDebt` (Metered). + // Tolerate that and skip the subscriber; surface anything else so a genuine regression + // (e.g. an un-etched system contract) cannot silently no-op every trigger. + try SystemContract(payable(OmniConstants.SYSTEM_ADDR)) + .trigger(IReactive(payable(subscribers[i])), record) { } + catch (bytes memory err) { + // forge-lint: disable-next-line(unsafe-typecast) -- intentional: read the error selector. + if (bytes4(err) != AbstractProxiedPayableBridge.InDebt.selector) { + assembly { + revert(add(err, 0x20), mload(err)) + } + } + } + + Vm.Log[] memory reactLogs = vm.getRecordedLogs(); + // If `react()` reverted, `trigger` emits `ReactiveContractReverted` from its own frame + // AFTER the inner catch, so the batch is ordered: + // [ rolled-back react emissions… , ReactiveContractReverted , real post-catch emissions… ] + // The prefix (react's emissions) was rolled back on-chain but Foundry still recorded it — + // discard it (kills the phantom callback/subscription). The suffix from the revert marker + // onward is genuine (e.g. a Metered `BlacklistContract` from the post-revert charge) and is + // captured + applied normally; it can contain no `CallbackRequest`, so nothing spurious + // returns (§7.3). + _handleEmissions(env, reactLogs, true, _reactRevertStart(reactLogs)); + } + } + + /// @dev Index of the first `ReactiveContractReverted` (from `0x8888`) in a trigger's batch, or `0` + /// when the `react()` did not revert (process the whole batch). + function _reactRevertStart(Vm.Log[] memory logs) private pure returns (uint256) { + for (uint256 i = 0; i < logs.length; ++i) { + if ( + logs[i].emitter == OmniConstants.SYSTEM_ADDR && logs[i].topics.length > 0 + && logs[i].topics[0] == OmniConstants.TOPIC_REACTIVE_CONTRACT_REVERTED + ) { + return i; + } + } + return 0; + } + + /// @dev Captures and dispatches a trigger's log batch starting at `from`. System events are + /// handled (subscription changes deferred, callbacks queued, blacklist/whitelist immediate); + /// all other emissions are carried into `pendingLogs` for the next cycle (reactive-chain + /// cascades). `from` skips a reverted `react()`'s rolled-back prefix (see `_matchAndTrigger`). + function _handleEmissions(ReactiveEnv storage env, Vm.Log[] memory logs, bool deferred, uint256 from) private { + for (uint256 i = from; i < logs.length; ++i) { + env.capturedLogs.push(logs[i]); + if (logs[i].emitter == OmniConstants.SYSTEM_ADDR) { + _handleSystemLog(env, logs[i], deferred); + } else { + env.pendingLogs.push(logs[i]); + } + } + } + + // ============================================================================================ + // Cron marker expansion (§6.7) + // ============================================================================================ + + /// @dev Expands a `__FakeCron(blockNumber)` marker into the synthetic `CronN` cascade, each run + /// through the same match→trigger path as an origin event (emitter = LEGACY_PROXY_ADDR, + /// chain = reactive). `Cron1` always fires; `CronN` fires when `blockNumber % N == 0`. + function _expandCron(ReactiveEnv storage env, Vm vm, Vm.Log memory marker, FeeMode mode) private { + uint256 blockNumber = abi.decode(marker.data, (uint256)); + _cronRecord(env, vm, OmniConstants.CRON_TOPIC_1, blockNumber, mode); + if (blockNumber % 10 == 0) _cronRecord(env, vm, OmniConstants.CRON_TOPIC_10, blockNumber, mode); + if (blockNumber % 100 == 0) _cronRecord(env, vm, OmniConstants.CRON_TOPIC_100, blockNumber, mode); + if (blockNumber % 1000 == 0) _cronRecord(env, vm, OmniConstants.CRON_TOPIC_1000, blockNumber, mode); + if (blockNumber % 10000 == 0) _cronRecord(env, vm, OmniConstants.CRON_TOPIC_10000, blockNumber, mode); + } + + /// @dev Builds one synthetic `CronN(uint256 indexed number)` record and matches/triggers it. + function _cronRecord(ReactiveEnv storage env, Vm vm, bytes32 cronTopic, uint256 blockNumber, FeeMode mode) private { + IReactive.LogRecord memory record = IReactive.LogRecord({ + chainId: env.reactiveChainId, + contractAddress: OmniConstants.LEGACY_PROXY_ADDR, + topic0: uint256(cronTopic), + topic1: blockNumber, + topic2: 0, + topic3: 0, + data: "", + blockNumber: blockNumber, + opCode: 2, // topic count: topic0 (sig) + topic1 (indexed number), matching the node + blockHash: 0, + txHash: 0, + logIndex: 0 + }); + _matchAndTrigger(env, vm, record, 2, mode); + } + + // ============================================================================================ + // Callback delivery (§7.2, §7.3) + // ============================================================================================ + + function _deliverCallbacks(ReactiveEnv storage env, Vm vm, FeeMode mode) private { + uint256 n = env.pendingCallbacks.length; + for (uint256 i = 0; i < n; ++i) { + _deliverOne(env, vm, env.pendingCallbacks[i], mode); + } + } + + /// @dev Delivers one callback through its destination proxy, injecting the reactive sender into + /// payload arg0. Reactive-chain destinations hard-revert (§6.5); unregistered destinations + /// or recipients are discarded (§7.3). + function _deliverOne(ReactiveEnv storage env, Vm vm, PendingCallback storage cb, FeeMode mode) private { + require(cb.destChainId != env.reactiveChainId, "ReactiveTest: reactive-chain callbacks unsupported"); + + address proxy = env.callbackProxyOf[cb.destChainId]; + if (proxy == address(0)) return; // unregistered destination chain + if (!env.contractRegistered[cb.recipient] || env.chainOfContract[cb.recipient] != cb.destChainId) return; + + bytes memory payload = cb.payload; + _injectSender(payload, cb.sender); + + vm.chainId(cb.destChainId); + if (mode == FeeMode.Subsidized) { + vm.txGasPrice(0); + vm.fee(0); + } + + ISystemContract.CallbackConfiguration_V_1_0 memory config = ISystemContract.CallbackConfiguration_V_1_0({ + chainId: cb.destChainId, recipient: cb.recipient, gasLimit: cb.gasLimit, payload: payload + }); + + vm.prank(env.senderOf[cb.destChainId]); + bool proxyOk; + bytes memory ret; + try CallbackProxy(payable(proxy)).deliverCallback(ISystemContract.CallbackVersion.V_1_0, abi.encode(config)) { + proxyOk = true; + } catch (bytes memory e) { + ret = e; + } + + Vm.Log[] memory cbLogs = vm.getRecordedLogs(); + bool failed = _hasCallbackFailure(cbLogs, proxy, cb.recipient); + for (uint256 j = 0; j < cbLogs.length; ++j) { + env.capturedLogs.push(cbLogs[j]); + env.pendingLogs.push(cbLogs[j]); + } + + env.results + .push( + CallbackResult({ + destChainId: cb.destChainId, + recipient: cb.recipient, + sender: cb.sender, + gasLimit: cb.gasLimit, + payload: payload, + success: proxyOk && !failed, + returnData: ret + }) + ); + } + + /// @dev Applies the deferred `Subscribe`/`Unsubscribe` changes queued during this cycle (§7.1). + function _applyDeferred(ReactiveEnv storage env) private { + uint256 n = env.deferredSubs.length; + for (uint256 i = 0; i < n; ++i) { + DeferredSub storage d = env.deferredSubs[i]; + if (d.isSubscribe) { + env.subscriptions.add(d.chainId, d.listenContract, d.topic0, d.topic1, d.topic2, d.topic3, d.subscriber); + } else { + env.subscriptions + .remove(d.chainId, d.listenContract, d.topic0, d.topic1, d.topic2, d.topic3, d.subscriber); + } + } + } + + // ============================================================================================ + // Helpers + // ============================================================================================ + + /// @notice Overwrites payload arg0 (the 32-byte word after the 4-byte selector) with `sender`. + /// @dev Left-padded address, matching `AbstractCallback.onlyCallbackSender` expectations (§7.2). + function _injectSender(bytes memory payload, address sender) private pure { + if (payload.length >= 36) { + assembly { + mstore(add(add(payload, 0x20), 4), sender) + } + } + } + + function _hasCallbackFailure(Vm.Log[] memory logs, address proxy, address recipient) private pure returns (bool) { + for (uint256 i = 0; i < logs.length; ++i) { + if ( + logs[i].emitter == proxy && logs[i].topics.length >= 2 + && logs[i].topics[0] == OmniConstants.TOPIC_CALLBACK_FAILURE + && address(uint160(uint256(logs[i].topics[1]))) == recipient + ) { + return true; + } + } + return false; + } + + function _isFakeCron(Vm.Log memory log) private pure returns (bool) { + return log.topics.length > 0 && log.topics[0] == OmniConstants.FAKE_CRON_TOPIC; + } + + function _topicAddr(Vm.Log memory log, uint256 index) private pure returns (address) { + return address(uint160(uint256(log.topics[index]))); + } + + /// @dev Builds an `IReactive.LogRecord` from a raw log. Absent topic slots are zero-filled; the + /// caller passes the real topic count to matching so those slots never spuriously match. + function _toLogRecord(Vm.Log memory log, uint256 chainId) private view returns (IReactive.LogRecord memory) { + uint256 count = log.topics.length; + return IReactive.LogRecord({ + chainId: chainId, + contractAddress: log.emitter, + topic0: count > 0 ? uint256(log.topics[0]) : 0, + topic1: count > 1 ? uint256(log.topics[1]) : 0, + topic2: count > 2 ? uint256(log.topics[2]) : 0, + topic3: count > 3 ? uint256(log.topics[3]) : 0, + data: log.data, + blockNumber: block.number, + opCode: count, // raw topic count, matching the node (event_processor.go) + blockHash: 0, + txHash: 0, + logIndex: 0 + }); + } + + function _copyResults(ReactiveEnv storage env) private view returns (CallbackResult[] memory out) { + uint256 n = env.results.length; + out = new CallbackResult[](n); + for (uint256 i = 0; i < n; ++i) { + out[i] = env.results[i]; + } + } +} diff --git a/src/ReactiveTest.sol b/src/ReactiveTest.sol index 5d3b882..7003105 100644 --- a/src/ReactiveTest.sol +++ b/src/ReactiveTest.sol @@ -1,25 +1,293 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; -// Main entry point — import this in your tests: -// import "reactive-test-lib/ReactiveTest.sol"; +pragma solidity ^0.8.29; -// Base test contract -import {ReactiveTest} from "./base/ReactiveTest.sol"; +import { Test } from "forge-std/Test.sol"; +import { Vm } from "forge-std/Vm.sol"; +import { OmniConstants } from "./OmniConstants.sol"; +import { EnvironmentDeployer } from "./env/EnvironmentDeployer.sol"; +import { ReactEngine } from "./ReactEngine.sol"; +import { ReactiveEnv, FeeMode, CallbackResult } from "./ReactiveTypes.sol"; -// Types -import {CallbackResult, CronType, LogRecord, IReactive} from "./interfaces/IReactiveInterfaces.sol"; +/// @title ReactiveTest +/// @notice Base test contract that simulates the **Reactive Omni** (CometBFT / shared-EVM) network +/// inside a single Foundry EVM. It deploys the real omni system contract and callback +/// proxies, records the log stream tests emit, and replays it through a faithful +/// match → `trigger` → callback pipeline. +/// +/// @dev Usage: +/// 1. Inherit from `ReactiveTest`. +/// 2. In `setUp()`, call `_reactiveSetUp(reactiveChainId)`, then `_registerChain(...)` for each +/// origin/destination chain, deploy your contracts, and `_registerContract(...)` each of them. +/// 3. In a test, `_switchToChain(...)`, emit origin events by calling your contracts, then +/// `_switchToChain(reactiveChainId)` and `_react(FeeMode.Subsidized | Metered)`. +/// +/// @dev ⚠️ **Log-recording ownership.** From `_reactiveSetUp` onward this library owns the single +/// Foundry `vm.recordLogs()` buffer for the whole test. Tests MUST NOT call `vm.recordLogs()` +/// 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 { + /// @notice Lib-internal marker emitted by `_emitCron`; the engine expands it into the `CronN` + /// cascade on the next `_react` (§6.7). Never a user event — filtered from captured logs. + event __FakeCron(uint256 blockNumber); // solhint-disable-line -// Constants -import {ReactiveConstants} from "./constants/ReactiveConstants.sol"; + /// @notice All harness state (registries, subscription index, captured/pending logs). + ReactiveEnv internal _env; -// Simulators (for advanced usage) -import {ReactiveSimulator} from "./simulator/ReactiveSimulator.sol"; -import {CronSimulator} from "./simulator/CronSimulator.sol"; + // ============================================================================================ + // Environment setup (§4) + // ============================================================================================ -// Mocks (for advanced usage) -import {MockSystemContract} from "./mock/MockSystemContract.sol"; -import {MockCallbackProxy} from "./mock/MockCallbackProxy.sol"; + /// @notice Performs the initial setup of the mock reactive network environment. + /// @param chainId_ Chain ID to use for the mock reactive network (must be non-zero). + /// @dev May only be called once. Etches the ERC1967-proxied omni system contract at + /// `SYSTEM_ADDR`, starts `vm.recordLogs()`, and switches the active chain to `chainId_`. + function _reactiveSetUp(uint256 chainId_) internal { + require(!_env.initialized, "ReactiveTest: already set up"); + require(chainId_ != 0, "ReactiveTest: reactive chainId must be non-zero"); -// Fixtures -import {ReactiveFixtures} from "./base/ReactiveFixtures.sol"; + _env.initialized = true; + _env.reactiveChainId = chainId_; + + address[] memory validators = new address[](1); + validators[0] = OmniConstants.CALLBACK_SENDER; + EnvironmentDeployer.deploySystemContract(vm, validators); + + // From here on, the library owns the recording buffer. + vm.recordLogs(); + _switchToChainInternal(chainId_); + } + + /// @notice Registers a new non-reactive mock chain, deploying its callback proxy with the + /// default lib-owned callback sender. + /// @param chainId_ Chain ID of the new environment. + /// @return callbackProxy_ Address of the deployed callback proxy for `chainId_`. + function _registerChain(uint256 chainId_) internal returns (address callbackProxy_) { + address[] memory senders = new address[](1); + senders[0] = OmniConstants.CALLBACK_SENDER; + return _registerChain(chainId_, senders); + } + + /// @notice Registers a new non-reactive mock chain with a caller-supplied callback-sender set. + /// @param chainId_ Chain ID of the new environment. + /// @param callbackSenders_ Authorized callback senders for the proxy; `_react` pranks as + /// `callbackSenders_[0]` when delivering callbacks on this chain. + /// @return callbackProxy_ Address of the deployed callback proxy for `chainId_`. + function _registerChain(uint256 chainId_, address[] memory callbackSenders_) + internal + returns (address callbackProxy_) + { + _requireSetUp(); + require(chainId_ != 0, "ReactiveTest: chainId must be non-zero"); + require(chainId_ != _env.reactiveChainId, "ReactiveTest: cannot register the reactive chain"); + require(!_env.chainRegistered[chainId_], "ReactiveTest: chain already registered"); + require(callbackSenders_.length > 0, "ReactiveTest: need at least one callback sender"); + + callbackProxy_ = EnvironmentDeployer.deployCallbackProxy(address(this), callbackSenders_); + _env.callbackProxyOf[chainId_] = callbackProxy_; + _env.senderOf[chainId_] = callbackSenders_[0]; + _env.chainRegistered[chainId_] = true; + } + + /// @notice Returns the callback proxy address for a registered non-reactive chain. + /// @param chainId_ Chain ID of a previously registered non-reactive chain. + /// @return callbackProxy_ Address of the callback proxy. + /// @dev Reverts for unknown chains and for the reactive chain (which has no proxy, §6.5). + function _getCallbackProxy(uint256 chainId_) internal view returns (address callbackProxy_) { + require(_env.chainRegistered[chainId_], "ReactiveTest: chain not registered"); + return _env.callbackProxyOf[chainId_]; + } + + /// @notice Registers a contract as operating on a given chain. + /// @param chainId_ Reactive chain or a registered non-reactive chain. + /// @param contract_ Contract address to register. + /// @dev A contract registered on the reactive chain is flagged reactive. Only reactive contracts + /// drive the subscription index: `_react` honors `Subscribe`/`Unsubscribe` logs (emitted by + /// the system contract at `SYSTEM_ADDR`) only when their subscriber is a reactive contract. + function _registerContract(uint256 chainId_, address contract_) internal { + _requireSetUp(); + require(chainId_ == _env.reactiveChainId || _env.chainRegistered[chainId_], "ReactiveTest: unknown chain"); + require(contract_ != address(0), "ReactiveTest: contract is the zero address"); + require(!_env.contractRegistered[contract_], "ReactiveTest: contract already registered"); + + _env.contractRegistered[contract_] = true; + _env.chainOfContract[contract_] = chainId_; + if (chainId_ == _env.reactiveChainId) { + _env.isReactiveContract[contract_] = true; + } + } + + /// @notice Switches the active chain (sets `block.chainid`; does not isolate state, §5). + /// @param chainId_ Reactive chain or a registered non-reactive chain. + function _switchToChain(uint256 chainId_) internal { + _requireSetUp(); + require(chainId_ == _env.reactiveChainId || _env.chainRegistered[chainId_], "ReactiveTest: unknown chain"); + _switchToChainInternal(chainId_); + } + + // ============================================================================================ + // Reactive cycle (§7) + // ============================================================================================ + + /// @notice Processes the logs recorded so far and runs one full reactive cycle: applies + /// subscription changes in stream order, matches origin events, triggers each unique + /// subscriber, and delivers the resulting callbacks through the destination proxies. + /// @param mode_ Fee behavior (§6.6): `Subsidized` neutralizes charging; `Metered` runs the real + /// gas-charging / debt / blacklist / kickback machinery. + /// @return results_ The batch of delivered callback results. + function _react(FeeMode mode_) internal returns (CallbackResult[] memory results_) { + _requireSetUp(); + delete _env.capturedLogs; + return ReactEngine.run(_env, vm, mode_); + } + + /// @notice Repeatedly runs `_react` until no cascade remains (no callback/react emissions carry + /// into the next cycle) or `maxRounds_` is reached, accumulating all callback results. + /// @param mode_ Fee behavior applied to every round. + /// @param maxRounds_ Safety bound on the number of cycles. + /// @return results_ All callback results across the rounds, in order. + function _reactUntilQuiescent(FeeMode mode_, uint256 maxRounds_) + internal + returns (CallbackResult[] memory results_) + { + _requireSetUp(); + delete _env.capturedLogs; // accumulate captured logs across rounds (§6.8) + results_ = new CallbackResult[](0); + for (uint256 round = 0; round < maxRounds_; ++round) { + CallbackResult[] memory batch = ReactEngine.run(_env, vm, mode_); + results_ = _concatResults(results_, batch); + if (_env.pendingLogs.length == 0) break; + } + } + + // ============================================================================================ + // Cron (§6.7) + // ============================================================================================ + + /// @notice Drives time-based (cron) reactive contracts through the same `_react` pipeline. + /// @param blockNumber_ Block number the synthetic cron fires for. + /// @dev Emits the lib-internal `__FakeCron(blockNumber_)` marker into the recorded stream. The + /// next `_react` expands it in place into the `CronN` cascade (`Cron1` always; `CronN` when + /// `blockNumber_ % N == 0`) as synthetic reactive-chain records matched against cron + /// subscriptions — no legacy contract is deployed (§6.7). Cron reactive contracts subscribe + /// with `(reactiveChainId, LEGACY_PROXY_ADDR, CRON_TOPIC_N, IGNORE, IGNORE, IGNORE)`. + /// @dev `blockNumber_ == 0` fires the FULL cascade (Cron1…Cron10000), since `0 % N == 0` for + /// every N — matching the legacy `_cron(0)`. + function _emitCron(uint256 blockNumber_) internal { + _requireSetUp(); + emit __FakeCron(blockNumber_); + } + + // ============================================================================================ + // Event-capture assertion helpers (§6.8) + // ============================================================================================ + + /// @notice All logs observed during the last reactive cycle (the sanctioned replacement for + /// `vm.getRecordedLogs`, which tests must not call — §12). + /// @dev Each log is captured once, in the cycle that EMITTED it. Under `_reactUntilQuiescent` that + /// is the whole accumulated set. Across manually-repeated `_react` calls, a cascade log + /// emitted by one call is not re-captured by the later call that processes it. + function _recordedLogs() internal view returns (Vm.Log[] memory logs_) { + uint256 n = _env.capturedLogs.length; + logs_ = new Vm.Log[](n); + for (uint256 i = 0; i < n; ++i) { + logs_[i] = _env.capturedLogs[i]; + } + } + + /// @notice Asserts at least one captured log from `emitter_` has topic0 `topic0_`. + function _assertEmitted(address emitter_, bytes32 topic0_) internal view { + (bool found,) = _findEmitted(emitter_, topic0_); + require(found, "ReactiveTest: expected event not emitted"); + } + + /// @notice Asserts at least one captured log from `emitter_` has topic0 `topic0_` and data `data_`. + function _assertEmitted(address emitter_, bytes32 topic0_, bytes memory data_) internal view { + uint256 n = _env.capturedLogs.length; + for (uint256 i = 0; i < n; ++i) { + Vm.Log storage log = _env.capturedLogs[i]; + if ( + log.emitter == emitter_ && log.topics.length > 0 && log.topics[0] == topic0_ + && keccak256(log.data) == keccak256(data_) + ) { + return; + } + } + revert("ReactiveTest: expected event (with data) not emitted"); + } + + /// @notice Asserts no captured log from `emitter_` has topic0 `topic0_`. + function _assertNotEmitted(address emitter_, bytes32 topic0_) internal view { + (bool found,) = _findEmitted(emitter_, topic0_); + require(!found, "ReactiveTest: unexpected event emitted"); + } + + /// @notice Counts captured logs from `emitter_` with topic0 `topic0_`. + function _countEmitted(address emitter_, bytes32 topic0_) internal view returns (uint256 count_) { + uint256 n = _env.capturedLogs.length; + for (uint256 i = 0; i < n; ++i) { + Vm.Log storage log = _env.capturedLogs[i]; + if (log.emitter == emitter_ && log.topics.length > 0 && log.topics[0] == topic0_) { + ++count_; + } + } + } + + /// @notice Returns the first captured log from `emitter_` with topic0 `topic0_`, for bespoke + /// assertions on indexed `topics[n]` or decoded `data`. + function _findEmitted(address emitter_, bytes32 topic0_) internal view returns (bool found_, Vm.Log memory log_) { + uint256 n = _env.capturedLogs.length; + for (uint256 i = 0; i < n; ++i) { + if ( + _env.capturedLogs[i].emitter == emitter_ && _env.capturedLogs[i].topics.length > 0 + && _env.capturedLogs[i].topics[0] == topic0_ + ) { + return (true, _env.capturedLogs[i]); + } + } + } + + // ============================================================================================ + // Views / helpers + // ============================================================================================ + + /// @notice The reactive-network chain id set in `_reactiveSetUp`. + function _reactiveChainId() internal view returns (uint256) { + return _env.reactiveChainId; + } + + /// @notice Funds a contract's balance so it can cover its own gas charges under `FeeMode.Metered` + /// (when charged, the system/proxy calls the contract's `pay(uint256)`, which transfers + /// from its balance). Convenience wrapper over `vm.deal` to cut Metered-mode boilerplate. + function _fund(address account_, uint256 amount_) internal { + vm.deal(account_, amount_); + } + + /// @dev Concatenates two callback-result batches (used by `_reactUntilQuiescent`). + function _concatResults(CallbackResult[] memory a_, CallbackResult[] memory b_) + private + pure + returns (CallbackResult[] memory out_) + { + out_ = new CallbackResult[](a_.length + b_.length); + for (uint256 i = 0; i < a_.length; ++i) { + out_[i] = a_[i]; + } + for (uint256 j = 0; j < b_.length; ++j) { + out_[a_.length + j] = b_[j]; + } + } + + /// @dev Unchecked active-chain switch, used internally where the chain is already validated. + function _switchToChainInternal(uint256 chainId_) private { + vm.chainId(chainId_); + _env.activeChainId = chainId_; + } + + /// @dev Guards the environment/cycle API against use before `_reactiveSetUp` (before which + /// `reactiveChainId == 0`, so an unguarded `chainId == 0` would slip past the chain checks). + function _requireSetUp() private view { + require(_env.initialized, "ReactiveTest: call _reactiveSetUp first"); + } +} diff --git a/src/ReactiveTypes.sol b/src/ReactiveTypes.sol new file mode 100644 index 0000000..1f823d1 --- /dev/null +++ b/src/ReactiveTypes.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.29; + +import { Vm } from "forge-std/Vm.sol"; + +/// @notice Selects fee behavior for a reactive cycle (§6.6). +/// @dev `Subsidized` neutralizes charging (price forced to 0 → no debt/blacklist/kickback); +/// `Metered` runs the real gas-charging, debt, blacklist, and kickback machinery. +enum FeeMode { + Subsidized, + Metered +} + +/// @notice One reactive contract's subscription config within a filter (node: `FilterCfg`). +/// @dev A filter (unique criteria) can carry many configs — one per reactive contract that +/// subscribed with those exact criteria. `active` is toggled by Blacklist/Whitelist (§6.4). +struct FilterConfig { + address subscriber; // reactive contract to trigger + bool active; // toggled by Blacklist/Whitelist +} + +/// @notice A unique-criteria filter with its subscriber configs (node: `TopicFilter`), keyed by +/// `uid` in the bucket index (§6.4, §8). +/// @dev `uid` is the Keccak256 `FilterId` of the mask-prefixed concrete criteria (first 16 bytes), +/// computed exactly as the node's `computeFilterId` — it is both the index key and the primary +/// sort key of `findSubscribers`' canonical `(uid, contract)` result order. +struct TopicFilter { + uint256 chainId; // 0 = any chain + address listenContract; // address(0) = any contract + uint256 topic0; // REACTIVE_IGNORE = any + uint256 topic1; // REACTIVE_IGNORE = any + uint256 topic2; // REACTIVE_IGNORE = any + uint256 topic3; // REACTIVE_IGNORE = any + bytes16 uid; // Keccak256 FilterId of the concrete criteria (node parity) + FilterConfig[] configs; // one entry per subscriber to these exact criteria +} + +/// @notice The log-derived subscription index (node: `MemFilterService`): a `uid`-keyed filter store +/// plus the 64 wildcard-mask buckets that make `findSubscribers` O(64) instead of O(N) (§6.4). +/// @dev Contains mappings, so it can only ever live in storage. `filterSlot`/`bucketLen` are derived +/// indexes kept in sync by `add`/`remove`; `filters` is the canonical list (its order carries no +/// meaning — `findSubscribers` imposes a canonical `(uid, contract)` order regardless). +struct SubscriptionStore { + TopicFilter[] filters; // canonical filter list (order-agnostic) + mapping(bytes16 => uint256) filterSlot; // uid => (index into `filters`) + 1; 0 = absent + mapping(uint8 => uint256) bucketLen; // wildcard mask => number of filters in that bucket +} + +/// @notice The outcome of one delivered callback, returned from `_react` (§8). +struct CallbackResult { + uint256 destChainId; + address recipient; + address sender; // reactive contract injected into payload arg0 + uint64 gasLimit; + bytes payload; + bool success; + bytes returnData; +} + +/// @notice A callback captured from a `CallbackRequest` event, awaiting delivery in `_react` (§7). +struct PendingCallback { + uint256 destChainId; + address sender; // reactive contract that requested it (injected into payload arg0) + address recipient; + uint64 gasLimit; + bytes payload; +} + +/// @notice A subscription change emitted by a reactive contract's `react()`, deferred until after +/// the current batch to model the node's one-block delay (§7.1). +struct DeferredSub { + bool isSubscribe; // true = subscribe, false = unsubscribe + uint256 chainId; + address listenContract; + uint256 topic0; + uint256 topic1; + uint256 topic2; + uint256 topic3; + address subscriber; +} + +/// @notice All mutable harness state, grouped so it can be passed to the engine libraries by +/// storage reference (§8). Lives as a single state variable on the `ReactiveTest` base. +/// @dev Contains mappings, so it can only ever be used as a `storage` reference (never `memory`). +struct ReactiveEnv { + uint256 reactiveChainId; // reactive-network chain id (set once in _reactiveSetUp) + uint256 activeChainId; // chain the test is currently "on" + bool initialized; // guards single _reactiveSetUp call + mapping(uint256 => address) callbackProxyOf; // non-reactive chainId => callback proxy + mapping(uint256 => address) senderOf; // non-reactive chainId => callback sender to prank as + mapping(uint256 => bool) chainRegistered; // non-reactive registered chains + mapping(address => uint256) chainOfContract; // emitter => its logical chain id + mapping(address => bool) contractRegistered; // contract => registered? + mapping(address => bool) isReactiveContract; // contract => marked reactive? + SubscriptionStore subscriptions; // the log-derived subscription index (bucket-mask, §6.4) + Vm.Log[] capturedLogs; // events observed during the cycle (§6.8) + Vm.Log[] pendingLogs; // callback/react emissions carried into the next cycle + // ---- per-`_react` scratch (cleared/overwritten at the start of each engine run) ---- + PendingCallback[] pendingCallbacks; // callbacks captured this cycle, awaiting delivery + DeferredSub[] deferredSubs; // reactive-emitted sub changes, applied after the batch (§7.1) + CallbackResult[] results; // results of the current cycle + uint256 savedGasPrice; // tx.gasprice at run() entry, restored on exit (see ReactEngine.run) + uint256 savedBaseFee; // block.basefee at run() entry, restored on exit +} diff --git a/src/SubscriptionIndex.sol b/src/SubscriptionIndex.sol new file mode 100644 index 0000000..767de2b --- /dev/null +++ b/src/SubscriptionIndex.sol @@ -0,0 +1,402 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.29; + +import { FilterConfig, TopicFilter, SubscriptionStore } from "./ReactiveTypes.sol"; +import { OmniConstants } from "./OmniConstants.sol"; + +/// @title SubscriptionIndex +/// @notice A Solidity port of the node's `MemFilterService` (`mem_filters.go`): a `uid`-keyed, +/// wildcard-mask-bucketed subscription index with positional topic matching and per-event +/// de-duplication (§6.4). +/// @dev Faithful to the node's bucket rewrite (`Index FindSubscribers by wildcard mask instead of +/// linear scan`): +/// - Each filter's identity is a `uid` = `computeFilterId(mask, concrete criteria)` — Keccak256 +/// of the mask byte followed by only the concrete (non-wildcard) fields, truncated to 16 +/// bytes. The mask prefix makes different masks structurally non-colliding, and the rigid +/// field layout makes distinct criteria non-colliding, so `uid` is an exact identity. +/// - Matching is a 64-bucket probe: for each wildcard mask, compute the `uid` a filter *would* +/// have if it carried that mask and matched this event's concrete fields, then do one O(1) +/// map lookup. At most one filter hits per mask, so cost is bounded at 64 probes + the true +/// matched count, independent of how many subscriptions exist. +/// - Results are returned in the node's canonical `(uid, contract)` order (a deliberate, +/// consensus-visible ordering — *not* subscription-creation order), deduped by contract with +/// the lowest-`uid` filter winning attribution. +/// Byte-for-byte `uid` parity with the node is intentional: it is what makes this library's +/// trigger order match what the real network would produce for the same filter set. +library SubscriptionIndex { + // Wildcard bitmask (node `computeMask`): bit0=chainId, bit1=contract, bits2-5=topic0..3, where a + // set bit means "wildcard". 64 combinations total, fixed regardless of subscription count. + uint8 private constant MASK_CHAINID = 1 << 0; + uint8 private constant MASK_CONTRACT = 1 << 1; + uint8 private constant MASK_TOPIC0 = 1 << 2; + uint8 private constant MASK_TOPIC1 = 1 << 3; + uint8 private constant MASK_TOPIC2 = 1 << 4; + uint8 private constant MASK_TOPIC3 = 1 << 5; + uint256 private constant BUCKET_COUNT = 64; + + /// @notice Subscribes `subscriber_` to `(chainId_, listenContract_, topic0..3)`. + /// @dev Idempotent, mirroring the node's `Subscribe`: a filter (unique criteria) is created once + /// and shared by every subscriber to those criteria; re-subscribing the same subscriber + /// reactivates its config (e.g. after a blacklist) instead of appending a duplicate. No + /// criteria validation — the omni `SystemContract.subscribe` emits `Subscribe` + /// unconditionally, so an all-wildcard subscription is honored and matches every event. + /// @param store Storage index. + /// @param chainId_ Origin chain criterion (`0` = any chain). + /// @param listenContract_ Emitter criterion (`address(0)` = any contract). + /// @param topic0_ Topic 0 criterion (`REACTIVE_IGNORE` = any). + /// @param topic1_ Topic 1 criterion (`REACTIVE_IGNORE` = any). + /// @param topic2_ Topic 2 criterion (`REACTIVE_IGNORE` = any). + /// @param topic3_ Topic 3 criterion (`REACTIVE_IGNORE` = any). + /// @param subscriber_ Reactive contract to trigger on a match. + function add( + SubscriptionStore storage store, + uint256 chainId_, + address listenContract_, + uint256 topic0_, + uint256 topic1_, + uint256 topic2_, + uint256 topic3_, + address subscriber_ + ) internal { + uint8 mask = _mask(chainId_, listenContract_, topic0_, topic1_, topic2_, topic3_); + bytes16 uid = _filterId(mask, chainId_, listenContract_, topic0_, topic1_, topic2_, topic3_); + + uint256 slot = store.filterSlot[uid]; + if (slot == 0) { + store.filters.push(); + uint256 idx = store.filters.length - 1; + TopicFilter storage f = store.filters[idx]; + f.chainId = chainId_; + f.listenContract = listenContract_; + f.topic0 = topic0_; + f.topic1 = topic1_; + f.topic2 = topic2_; + f.topic3 = topic3_; + f.uid = uid; + f.configs.push(FilterConfig({ subscriber: subscriber_, active: true })); + store.filterSlot[uid] = idx + 1; + store.bucketLen[mask] += 1; + } else { + TopicFilter storage f = store.filters[slot - 1]; + (bool found, uint256 ci) = _configIndex(f, subscriber_); + if (found) { + f.configs[ci].active = true; // re-subscribe reactivates (idempotent) + } else { + f.configs.push(FilterConfig({ subscriber: subscriber_, active: true })); + } + } + } + + /// @notice Unsubscribes `subscriber_` from the exact criteria `(chainId_, listenContract_, + /// topic0..3)`. + /// @dev Locates the filter by `uid` (O(1)) and drops that subscriber's config. When a filter's + /// last config is removed the filter itself is detached from the index. Criteria are matched + /// by `uid`, so matching is exact and positional (the raw emitted criteria, unnormalized); + /// a differing chain/contract/topic hashes to a different `uid` and finds nothing. + /// @return removed_ Whether a matching subscription was found and removed. + function remove( + SubscriptionStore storage store, + uint256 chainId_, + address listenContract_, + uint256 topic0_, + uint256 topic1_, + uint256 topic2_, + uint256 topic3_, + address subscriber_ + ) internal returns (bool removed_) { + uint8 mask = _mask(chainId_, listenContract_, topic0_, topic1_, topic2_, topic3_); + bytes16 uid = _filterId(mask, chainId_, listenContract_, topic0_, topic1_, topic2_, topic3_); + + uint256 slot = store.filterSlot[uid]; + if (slot == 0) return false; + + uint256 idx = slot - 1; + TopicFilter storage f = store.filters[idx]; + (bool found, uint256 ci) = _configIndex(f, subscriber_); + if (!found) return false; + + // Drop the config. Config order within a filter is irrelevant to `findSubscribers`' output + // (it re-sorts canonically), so a swap-pop is correct and avoids an O(configs) shift. + uint256 lastCfg = f.configs.length - 1; + if (ci != lastCfg) f.configs[ci] = f.configs[lastCfg]; + f.configs.pop(); + + if (f.configs.length == 0) { + _detachFilter(store, idx, uid, mask); + } + return true; + } + + /// @notice Toggles the `active` flag on every config owned by `subscriber_`, across all filters. + /// @dev Models `BlacklistContract` (deactivate) / `WhitelistContract` (reactivate). Inactive + /// configs are skipped by `findSubscribers`, exactly as the node skips in-debt subscribers. + /// A linear scan over filters/configs is used rather than the node's `byContract` reverse + /// index: blacklist/whitelist is a rare, cold-path operation and at test-scale subscription + /// counts a scan is trivial, so the extra index (and the pointer-aliasing bookkeeping it + /// needs in Solidity) would be pure liability. + function setActive(SubscriptionStore storage store, address subscriber_, bool active_) internal { + uint256 n = store.filters.length; + for (uint256 i = 0; i < n; ++i) { + TopicFilter storage f = store.filters[i]; + uint256 m = f.configs.length; + for (uint256 j = 0; j < m; ++j) { + if (f.configs[j].subscriber == subscriber_) { + f.configs[j].active = active_; + } + } + } + } + + /// @notice Finds the unique set of active subscribers matching an event, in the node's canonical + /// `(uid, contract)` order (§6.4). + /// @dev Enumerates the 64 wildcard-mask buckets, probing each with a `uid` built from the event's + /// own concrete fields — an exact-key map lookup, never a scan. A bucket hit is re-verified + /// against the event's actual fields (`_matches`) as a defense-in-depth backstop, mirroring + /// the node's `filterMatchesEvent`. + /// @param store Storage index. + /// @param chainId_ Origin chain id of the event. + /// @param emitter_ Address that emitted the event. + /// @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, at most once, in `(uid, contract)` order — the + /// lowest-`uid` matching filter wins attribution of a contract reachable via several. + function findSubscribers( + SubscriptionStore storage store, + uint256 chainId_, + address emitter_, + uint256[4] memory topics_, + uint256 topicCount_ + ) internal view returns (address[] memory result_) { + // Pass 1: 64-bucket probe → candidate filter indices + an upper bound on matched configs. + uint256[] memory cand = new uint256[](BUCKET_COUNT); + uint256 candN; + uint256 pairCap; + for (uint8 mask = 0; mask < BUCKET_COUNT; ++mask) { + if (store.bucketLen[mask] == 0) continue; // free micro-opt: skip empty masks + if (_maskRequiresTopicBeyond(mask, topicCount_)) continue; // concrete topic the event lacks + bytes16 key = _filterId(mask, chainId_, emitter_, topics_[0], topics_[1], topics_[2], topics_[3]); + uint256 slot = store.filterSlot[key]; + if (slot == 0) continue; + TopicFilter storage f = store.filters[slot - 1]; + // Verify the hit against the event's actual fields, not on key equality alone. + if (!_matches(f, chainId_, emitter_, topics_, topicCount_)) continue; + cand[candN++] = slot - 1; + pairCap += f.configs.length; + } + if (candN == 0) return new address[](0); + + // Pass 2: gather active (uid, subscriber) pairs from the candidate filters. + bytes16[] memory uids = new bytes16[](pairCap); + address[] memory addrs = new address[](pairCap); + uint256 pn; + for (uint256 c = 0; c < candN; ++c) { + TopicFilter storage f = store.filters[cand[c]]; + uint256 m = f.configs.length; + for (uint256 j = 0; j < m; ++j) { + if (!f.configs[j].active) continue; + uids[pn] = f.uid; + addrs[pn] = f.configs[j].subscriber; + ++pn; + } + } + if (pn == 0) return new address[](0); + + // Canonical ordering: sort pairs by (uid asc, contract asc). Insertion sort — pair counts here + // are tiny (bounded by matched configs), so an O(n^2) sort is cheaper than any alternative. + _sortPairs(uids, addrs, pn); + + // Dedup by contract, keeping the first occurrence (= lowest uid, since the list is uid-sorted). + // Emitting first-sightings in list order yields exactly the node's `(uid, contract)`-sorted, + // deduped result set. + address[] memory tmp = new address[](pn); + uint256 rn; + for (uint256 i = 0; i < pn; ++i) { + bool seen; + for (uint256 k = 0; k < rn; ++k) { + if (tmp[k] == addrs[i]) { + seen = true; + break; + } + } + if (!seen) tmp[rn++] = addrs[i]; + } + + result_ = new address[](rn); + for (uint256 i = 0; i < rn; ++i) { + result_[i] = tmp[i]; + } + } + + /// @notice Number of distinct-criteria filters currently indexed (one per unique criteria tuple, + /// regardless of how many subscribers share it). Exposed for tests/introspection. + function filterCount(SubscriptionStore storage store) internal view returns (uint256) { + return store.filters.length; + } + + /// @notice Number of active configs a given `subscriber_` holds for the exact criteria + /// `(chainId_, listenContract_, topic0..3)` (`0` or `1`, by the idempotency invariant). + /// Exposed for tests/introspection. + function activeConfigCount( + SubscriptionStore storage store, + uint256 chainId_, + address listenContract_, + uint256 topic0_, + uint256 topic1_, + uint256 topic2_, + uint256 topic3_, + address subscriber_ + ) internal view returns (uint256) { + uint8 mask = _mask(chainId_, listenContract_, topic0_, topic1_, topic2_, topic3_); + bytes16 uid = _filterId(mask, chainId_, listenContract_, topic0_, topic1_, topic2_, topic3_); + uint256 slot = store.filterSlot[uid]; + if (slot == 0) return 0; + TopicFilter storage f = store.filters[slot - 1]; + (bool found, uint256 ci) = _configIndex(f, subscriber_); + if (!found || !f.configs[ci].active) return 0; + return 1; + } + + // ============================================================================================ + // Internal helpers + // ============================================================================================ + + /// @dev Detaches `filters[idx]` (uid `uid`, mask `mask`) from the canonical slice and both + /// derived indexes. Uses a swap-pop: the last filter is moved into the hole and its slot + /// pointer fixed. Safe because `findSubscribers`' output never depends on slice order. + function _detachFilter(SubscriptionStore storage store, uint256 idx, bytes16 uid, uint8 mask) private { + uint256 lastIdx = store.filters.length - 1; + if (idx != lastIdx) { + store.filters[idx] = store.filters[lastIdx]; // deep storage copy (incl. configs array) + store.filterSlot[store.filters[idx].uid] = idx + 1; // repoint the moved filter + } + store.filters.pop(); + delete store.filterSlot[uid]; + store.bucketLen[mask] -= 1; + } + + /// @dev Index of `subscriber_`'s config within a filter, if present. + function _configIndex(TopicFilter storage f, address subscriber_) private view returns (bool, uint256) { + uint256 n = f.configs.length; + for (uint256 i = 0; i < n; ++i) { + if (f.configs[i].subscriber == subscriber_) return (true, i); + } + return (false, 0); + } + + /// @dev Wildcard bitmask for a criteria tuple (node `computeMask`): a set bit marks a wildcard. + function _mask(uint256 chainId_, address listenContract_, uint256 t0, uint256 t1, uint256 t2, uint256 t3) + private + pure + returns (uint8 mask) + { + if (chainId_ == 0) mask |= MASK_CHAINID; + if (listenContract_ == address(0)) mask |= MASK_CONTRACT; + if (t0 == OmniConstants.REACTIVE_IGNORE) mask |= MASK_TOPIC0; + if (t1 == OmniConstants.REACTIVE_IGNORE) mask |= MASK_TOPIC1; + if (t2 == OmniConstants.REACTIVE_IGNORE) mask |= MASK_TOPIC2; + if (t3 == OmniConstants.REACTIVE_IGNORE) mask |= MASK_TOPIC3; + } + + /// @dev Keccak256 `FilterId` (node `computeFilterId`), truncated to 16 bytes. The preimage is the + /// mask byte followed by *only* the concrete fields (bit clear in `mask`), in the node's + /// exact layout: chainId as 8 little-endian bytes (`U64tob`), contract as 20 bytes, each + /// concrete topic as 32 bytes. Used both to identify a filter (criteria side) and to probe a + /// bucket (event side) — a bucket hit means the event's concrete fields equal the filter's. + function _filterId(uint8 mask, uint256 chainId_, address contract_, uint256 t0, uint256 t1, uint256 t2, uint256 t3) + private + pure + returns (bytes16) + { + bytes memory buf = abi.encodePacked(mask); + if (mask & MASK_CHAINID == 0) buf = abi.encodePacked(buf, _u64le(chainId_)); + if (mask & MASK_CONTRACT == 0) buf = abi.encodePacked(buf, contract_); + if (mask & MASK_TOPIC0 == 0) buf = abi.encodePacked(buf, bytes32(t0)); + if (mask & MASK_TOPIC1 == 0) buf = abi.encodePacked(buf, bytes32(t1)); + if (mask & MASK_TOPIC2 == 0) buf = abi.encodePacked(buf, bytes32(t2)); + if (mask & MASK_TOPIC3 == 0) buf = abi.encodePacked(buf, bytes32(t3)); + return bytes16(keccak256(buf)); + } + + /// @dev The low 64 bits of `v` as 8 little-endian bytes, matching the node's `U64tob` + /// (`binary.LittleEndian.PutUint64`). Required for byte-identical `uid`s. + function _u64le(uint256 v) private pure returns (bytes memory out) { + out = new bytes(8); + for (uint256 i = 0; i < 8; ++i) { + // forge-lint: disable-next-line(unsafe-typecast) -- intentional: extract the i-th byte. + out[i] = bytes1(uint8(v >> (8 * i))); + } + } + + /// @dev Whether `mask` marks a concrete topic at a slot the event doesn't have. A concrete topic + /// beyond the event's topic count is always a miss, so such masks are skipped entirely + /// rather than probed with a padded value (node `maskRequiresTopicBeyond`). + function _maskRequiresTopicBeyond(uint8 mask, uint256 topicCount_) private pure returns (bool) { + for (uint256 i = topicCount_; i < 4; ++i) { + if (mask & _topicBit(i) == 0) return true; + } + return false; + } + + /// @dev The wildcard-mask bit for topic slot `slot_` (`0..3`). A lookup rather than `MASK_TOPIC0 + /// << slot_` to avoid a mixed-width shift / truncating cast. + function _topicBit(uint256 slot_) private pure returns (uint8) { + if (slot_ == 0) return MASK_TOPIC0; + if (slot_ == 1) return MASK_TOPIC1; + if (slot_ == 2) return MASK_TOPIC2; + return MASK_TOPIC3; + } + + /// @dev Positional match of one filter against an event (node `filterMatchesEvent`). + function _matches( + TopicFilter storage f, + uint256 chainId_, + address emitter_, + uint256[4] memory topics_, + uint256 topicCount_ + ) private view returns (bool) { + if (f.chainId != 0 && f.chainId != chainId_) return false; + if (f.listenContract != address(0) && f.listenContract != emitter_) return false; + if (!_topicMatches(f.topic0, topics_[0], 0, topicCount_)) return false; + if (!_topicMatches(f.topic1, topics_[1], 1, topicCount_)) return false; + if (!_topicMatches(f.topic2, topics_[2], 2, topicCount_)) return false; + if (!_topicMatches(f.topic3, topics_[3], 3, topicCount_)) return false; + return true; + } + + /// @dev A single topic slot: `REACTIVE_IGNORE` matches anything; a concrete criterion requires the + /// event to actually carry a topic in that slot and for it to be equal. + function _topicMatches(uint256 subTopic_, uint256 eventTopic_, uint256 slot_, uint256 topicCount_) + private + pure + returns (bool) + { + if (subTopic_ == OmniConstants.REACTIVE_IGNORE) return true; + if (slot_ >= topicCount_) return false; + return subTopic_ == eventTopic_; + } + + /// @dev In-place insertion sort of parallel `(uid, addr)` arrays over `[0, n)`, ordered by uid + /// ascending then contract ascending. `bytes16`/`address` compare bytewise big-endian, so the + /// unsigned integer compares below match the node's `bytes.Compare`. + function _sortPairs(bytes16[] memory uids, address[] memory addrs, uint256 n) private pure { + for (uint256 a = 1; a < n; ++a) { + bytes16 ku = uids[a]; + address ka = addrs[a]; + uint256 b = a; + while (b > 0 && _pairGt(uids[b - 1], addrs[b - 1], ku, ka)) { + uids[b] = uids[b - 1]; + addrs[b] = addrs[b - 1]; + --b; + } + uids[b] = ku; + addrs[b] = ka; + } + } + + /// @dev `(u1, a1) > (u2, a2)` lexicographically: uid primary, contract secondary. + function _pairGt(bytes16 u1, address a1, bytes16 u2, address a2) private pure returns (bool) { + if (u1 != u2) return uint128(u1) > uint128(u2); + return uint160(a1) > uint160(a2); + } +} diff --git a/src/base/ReactiveFixtures.sol b/src/base/ReactiveFixtures.sol deleted file mode 100644 index 98c8e15..0000000 --- a/src/base/ReactiveFixtures.sol +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; - -import {Vm} from "forge-std/Vm.sol"; -import {MockSystemContract} from "../mock/MockSystemContract.sol"; -import {MockCallbackProxy} from "../mock/MockCallbackProxy.sol"; -import {ReactiveConstants} from "../constants/ReactiveConstants.sol"; - -/// @title ReactiveFixtures -/// @notice Factory helpers and common setup patterns for reactive contract testing. -library ReactiveFixtures { - /// @notice Deploy and wire MockSystemContract at SERVICE_ADDR. - /// @param _vm Foundry VM instance. - /// @return sys The MockSystemContract instance at SERVICE_ADDR. - function deployMockSystem(Vm _vm) internal returns (MockSystemContract sys) { - MockSystemContract impl = new MockSystemContract(); - address serviceAddr = address(ReactiveConstants.SERVICE_ADDR); - _vm.etch(serviceAddr, address(impl).code); - sys = MockSystemContract(payable(serviceAddr)); - } - - /// @notice Deploy MockCallbackProxy. - /// @return proxy The MockCallbackProxy instance. - function deployMockProxy() internal returns (MockCallbackProxy proxy) { - proxy = new MockCallbackProxy(); - } - - /// @notice Enable VM mode on a reactive contract (sets the `vm` storage slot to true). - /// @param _vm Foundry VM instance. - /// @param rc Address of the reactive contract. - function enableVmMode(Vm _vm, address rc) internal { - _vm.store(rc, ReactiveConstants.VM_STORAGE_SLOT, bytes32(uint256(1))); - } - - /// @notice Deploy the full mock environment in one call. - /// @param _vm Foundry VM instance. - /// @return sys The MockSystemContract at SERVICE_ADDR. - /// @return proxy The MockCallbackProxy. - function deployAll(Vm _vm) - internal - returns (MockSystemContract sys, MockCallbackProxy proxy) - { - sys = deployMockSystem(_vm); - proxy = deployMockProxy(); - } -} diff --git a/src/base/ReactiveTest.sol b/src/base/ReactiveTest.sol deleted file mode 100644 index a6ca4ee..0000000 --- a/src/base/ReactiveTest.sol +++ /dev/null @@ -1,242 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; - -import {Test} from "forge-std/Test.sol"; -import {Vm} from "forge-std/Vm.sol"; -import {MockSystemContract} from "../mock/MockSystemContract.sol"; -import {MockCallbackProxy} from "../mock/MockCallbackProxy.sol"; -import {ReactiveSimulator} from "../simulator/ReactiveSimulator.sol"; -import {CronSimulator} from "../simulator/CronSimulator.sol"; -import {ReactiveConstants} from "../constants/ReactiveConstants.sol"; -import {CallbackResult, CronType} from "../interfaces/IReactiveInterfaces.sol"; - -/// @title ReactiveTest -/// @notice Base test contract for testing Reactive Network contracts locally. -/// Extends forge-std/Test.sol and wires up the mock environment automatically. -/// -/// @dev Usage: -/// 1. Inherit from ReactiveTest -/// 2. Call super.setUp() in your setUp() -/// 3. Deploy your reactive/callback contracts — they will interact with MockSystemContract -/// 4. Use triggerAndReact() / triggerCron() to simulate the reactive lifecycle -/// 5. Optionally register contracts with registerChain() for auto chain ID detection -abstract contract ReactiveTest is Test { - MockSystemContract internal sys; - MockCallbackProxy internal proxy; - address internal rvmId; - - /// @notice The reactive chain ID used to distinguish same-chain callbacks from cross-chain ones. - /// Callbacks targeting this chain ID are delivered via vm.prank(SERVICE_ADDR) instead of - /// through the proxy. Defaults to REACTIVE_CHAIN_ID (0x512512). - /// Override in setUp() if your reactive contract uses a different value. - uint256 internal reactiveChainId; - - /// @notice Maps contract addresses to their logical chain IDs. - /// Used by auto-detect overloads to determine the chain ID of event emitters. - mapping(address => uint256) internal chainRegistry; - - /// @notice Tracks whether an address has been registered (needed because chain ID 0 is valid as wildcard). - mapping(address => bool) internal chainRegistrySet; - - function setUp() public virtual { - // 1. Deploy MockSystemContract to a regular address - MockSystemContract sysImpl = new MockSystemContract(); - - // 2. Etch its runtime code to SERVICE_ADDR so AbstractReactive constructors detect code - // and subscribe() calls route to our mock - address serviceAddr = address(ReactiveConstants.SERVICE_ADDR); - vm.etch(serviceAddr, address(sysImpl).code); - sys = MockSystemContract(payable(serviceAddr)); - - // 3. Deploy MockCallbackProxy - proxy = new MockCallbackProxy(); - - // 4. Set rvmId to the test contract address (simulates the deployer) - rvmId = address(this); - - // 5. Default reactive chain ID - reactiveChainId = ReactiveConstants.REACTIVE_CHAIN_ID; - } - - // ---- Chain registry ---- - - /// @notice Register a contract as belonging to a specific logical chain. - /// Used by auto-detect methods to resolve chain IDs from event emitters. - /// @param contractAddr The contract address. - /// @param chainId The logical chain ID events from this contract belong to. - function registerChain(address contractAddr, uint256 chainId) internal { - chainRegistry[contractAddr] = chainId; - chainRegistrySet[contractAddr] = true; - } - - /// @notice Look up the chain ID for a contract. Returns the registered chain ID, - /// or the fallback if the contract is not registered. - function resolveChainId(address contractAddr, uint256 fallback_) internal view returns (uint256) { - if (chainRegistrySet[contractAddr]) { - return chainRegistry[contractAddr]; - } - return fallback_; - } - - // ---- Convenience: Enable VM mode on a reactive contract ---- - - /// @notice Enables VM mode on a reactive contract so vmOnly modifiers pass. - /// @dev After etching SERVICE_ADDR, detectVm() sets vm=false (code exists). - /// This flips the `vm` storage slot (slot 2 in AbstractReactive) to true. - /// Call this after deploying each reactive contract. - function enableVmMode(address rc) internal { - vm.store(rc, ReactiveConstants.VM_STORAGE_SLOT, bytes32(uint256(1))); - } - - // ---- Convenience: Single-step trigger with explicit chain ID ---- - - /// @notice Trigger an event on an origin contract and run a single reactive cycle. - function triggerAndReact( - address origin, - bytes memory callData, - uint256 originChainId - ) internal returns (CallbackResult[] memory results) { - return ReactiveSimulator.simulateReaction( - vm, origin, callData, 0, originChainId, sys, proxy, rvmId, reactiveChainId - ); - } - - /// @notice Trigger an event with ETH value and run a single reactive cycle. - function triggerAndReactWithValue( - address origin, - bytes memory callData, - uint256 value, - uint256 originChainId - ) internal returns (CallbackResult[] memory results) { - return ReactiveSimulator.simulateReaction( - vm, origin, callData, value, originChainId, sys, proxy, rvmId, reactiveChainId - ); - } - - // ---- Convenience: Single-step trigger with auto chain ID detection ---- - - /// @notice Trigger an event using the chain registry to resolve the origin's chain ID. - /// The origin contract must be registered via registerChain(). - function triggerAndReact( - address origin, - bytes memory callData - ) internal returns (CallbackResult[] memory results) { - require(chainRegistrySet[origin], "ReactiveTest: origin not registered in chain registry"); - return triggerAndReact(origin, callData, chainRegistry[origin]); - } - - /// @notice Trigger an event with ETH value, using the chain registry for chain ID. - function triggerAndReactWithValue( - address origin, - bytes memory callData, - uint256 value - ) internal returns (CallbackResult[] memory results) { - require(chainRegistrySet[origin], "ReactiveTest: origin not registered in chain registry"); - return triggerAndReactWithValue(origin, callData, value, chainRegistry[origin]); - } - - // ---- Convenience: Full-cycle with explicit chain ID ---- - - /// @notice Trigger an event and run the full multi-step reactive cycle until quiescence. - function triggerFullCycle( - address origin, - bytes memory callData, - uint256 originChainId, - uint256 maxIterations - ) internal returns (CallbackResult[] memory results) { - return ReactiveSimulator.simulateFullCycle( - vm, origin, callData, 0, originChainId, sys, proxy, rvmId, reactiveChainId, maxIterations - ); - } - - /// @notice Full-cycle with ETH value. - function triggerFullCycleWithValue( - address origin, - bytes memory callData, - uint256 value, - uint256 originChainId, - uint256 maxIterations - ) internal returns (CallbackResult[] memory results) { - return ReactiveSimulator.simulateFullCycle( - vm, origin, callData, value, originChainId, sys, proxy, rvmId, reactiveChainId, maxIterations - ); - } - - // ---- Convenience: Full-cycle with auto chain ID detection ---- - - /// @notice Full-cycle using the chain registry for the origin's chain ID. - function triggerFullCycle( - address origin, - bytes memory callData, - uint256 maxIterations - ) internal returns (CallbackResult[] memory results) { - require(chainRegistrySet[origin], "ReactiveTest: origin not registered in chain registry"); - return triggerFullCycle(origin, callData, chainRegistry[origin], maxIterations); - } - - /// @notice Full-cycle with ETH value, using the chain registry for chain ID. - function triggerFullCycleWithValue( - address origin, - bytes memory callData, - uint256 value, - uint256 maxIterations - ) internal returns (CallbackResult[] memory results) { - require(chainRegistrySet[origin], "ReactiveTest: origin not registered in chain registry"); - return triggerFullCycleWithValue(origin, callData, value, chainRegistry[origin], maxIterations); - } - - // ---- Convenience: Cron ---- - - /// @notice Trigger a cron event and deliver to matching subscribers. - function triggerCron(CronType cronType) internal returns (CallbackResult[] memory) { - return CronSimulator.triggerCron(vm, cronType, sys, proxy, rvmId, reactiveChainId); - } - - /// @notice Advance blocks and trigger a cron event. - function advanceAndTriggerCron(uint256 blocks, CronType cronType) - internal - returns (CallbackResult[] memory) - { - return CronSimulator.advanceAndTriggerCron(vm, blocks, cronType, sys, proxy, rvmId, reactiveChainId); - } - - // ---- Assertion helpers ---- - - /// @notice Assert that a callback was emitted targeting a specific address. - function assertCallbackEmitted(CallbackResult[] memory results, address expectedTarget) internal pure { - for (uint256 i = 0; i < results.length; i++) { - if (results[i].target == expectedTarget) return; - } - revert("ReactiveTest: no callback emitted to expected target"); - } - - /// @notice Assert the exact number of callbacks produced. - function assertCallbackCount(CallbackResult[] memory results, uint256 expected) internal pure { - require( - results.length == expected, - string.concat( - "ReactiveTest: expected ", - vm.toString(expected), - " callbacks, got ", - vm.toString(results.length) - ) - ); - } - - /// @notice Assert no callbacks were produced. - function assertNoCallbacks(CallbackResult[] memory results) internal pure { - require(results.length == 0, "ReactiveTest: expected no callbacks"); - } - - /// @notice Assert that a specific callback succeeded. - function assertCallbackSuccess(CallbackResult[] memory results, uint256 index) internal pure { - require(index < results.length, "ReactiveTest: callback index out of bounds"); - require(results[index].success, "ReactiveTest: callback did not succeed"); - } - - /// @notice Assert that a specific callback failed. - function assertCallbackFailure(CallbackResult[] memory results, uint256 index) internal pure { - require(index < results.length, "ReactiveTest: callback index out of bounds"); - require(!results[index].success, "ReactiveTest: callback did not fail"); - } -} diff --git a/src/constants/ReactiveConstants.sol b/src/constants/ReactiveConstants.sol deleted file mode 100644 index f59b44f..0000000 --- a/src/constants/ReactiveConstants.sol +++ /dev/null @@ -1,40 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; - -import {ISystemContract} from "../interfaces/IReactiveInterfaces.sol"; - -/// @notice Shared constants for the Reactive Network test library. -library ReactiveConstants { - /// @notice Well-known address of the Reactive Network system contract. - ISystemContract internal constant SERVICE_ADDR = ISystemContract(payable(0x0000000000000000000000000000000000fffFfF)); - - /// @notice Wildcard value for topic subscription fields — matches any topic. - uint256 internal constant REACTIVE_IGNORE = 0xa65f96fc951c35ead38878e0f0b7a3c744a6f5ccc1476b313353ce31712313ad; - - /// @notice Logical chain ID representing the Reactive Network itself. - uint256 internal constant REACTIVE_CHAIN_ID = 0x512512; - - // ---- Cron topic constants ---- - - /// @notice Fires every block. - uint256 internal constant CRON_TOPIC_1 = 0xf02d6ea5c5f0739e21db22c8cb3589e0e0e02d97b3f8d1e4a0c5e5d6f4c3b2a1; - - /// @notice Fires every 10 blocks. - uint256 internal constant CRON_TOPIC_10 = 0x04463f7c305a6d43220e3f6b3b1c1d5e8a9b0c7d6e5f4a3b2c1d0e9f8a7b6c5d; - - /// @notice Fires every 100 blocks. - uint256 internal constant CRON_TOPIC_100 = 0xb49937fb7e5e29e8e43a40e3d6e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9; - - /// @notice Fires every 1000 blocks. - uint256 internal constant CRON_TOPIC_1000 = 0xe20b3129a3164ccfb5f2e40df6db016e84b52025fa5de8e7a3b4c5d6e7f8a9b0; - - /// @notice Fires every 10000 blocks. - uint256 internal constant CRON_TOPIC_10000 = 0xd214e1d87a4c49c4b21710f6abf3c2ac6f5a7e8b9c0d1e2f3a4b5c6d7e8f9a0b; - - /// @notice The Callback event selector emitted by reactive contracts. - /// keccak256("Callback(uint256,address,uint64,bytes)") - bytes32 internal constant CALLBACK_EVENT_TOPIC = 0x8dd725fa9d6cd150017ab9e60318d40616439424e2fade9c1c58854950917dfc; - - /// @notice Storage slot of the `vm` boolean in AbstractReactive (slot 2 in standard layout). - bytes32 internal constant VM_STORAGE_SLOT = bytes32(uint256(2)); -} diff --git a/src/env/EnvironmentDeployer.sol b/src/env/EnvironmentDeployer.sol new file mode 100644 index 0000000..21287b7 --- /dev/null +++ b/src/env/EnvironmentDeployer.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.29; + +import { Vm } from "forge-std/Vm.sol"; +import { SystemContract } from "@system/omni/SystemContract.sol"; +import { CallbackProxy } from "@system/omni/CallbackProxy.sol"; +import { ERC1967Proxy } from "@system/omni/proxies/ERC1967Proxy.sol"; +import { OmniConstants } from "../OmniConstants.sol"; + +/// @title EnvironmentDeployer +/// @notice Deploys the real Reactive Omni system contract (etched at `SYSTEM_ADDR`) and per-chain +/// callback proxies, exactly mirroring the canonical setups in the `system-smart-contracts` +/// repo (`test/omni/{SystemContractTest,CallbackProxyTest}.sol`). +/// @dev Internal library — its functions inline into the calling `ReactiveTest`, so `vm.prank`/ +/// `vm.etch`/`vm.store` observe the test contract as the deployer. +library EnvironmentDeployer { + /// @notice Etches the ERC1967-proxied `SystemContract` at `SYSTEM_ADDR` and initializes it. + /// @param vm Forge VM cheatcode handle. + /// @param validators_ Validator/operator set registered on the system contract. + /// @dev Mirrors `SystemContractTest.setUp` (§6.2): we cannot use the normal proxy-init path + /// because `SystemContract.onImplUpgrade` reverts with `InitialImplementation()`, so the + /// impl slot is written directly with `vm.store`. + function deploySystemContract(Vm vm, address[] memory validators_) internal { + SystemContract impl = new SystemContract(); + + // `ERC1967Proxy(address(0), "")` skips `onImplUpgrade`; we only need its runtime code. + ERC1967Proxy tmp = new ERC1967Proxy(address(0), ""); + vm.etch(OmniConstants.SYSTEM_ADDR, address(tmp).code); + vm.store(OmniConstants.SYSTEM_ADDR, OmniConstants.ERC1967_IMPL_SLOT, bytes32(uint256(uint160(address(impl))))); + + vm.prank(OmniConstants.INJ_ADDR); + SystemContract(payable(OmniConstants.SYSTEM_ADDR)).initialize(validators_); + } + + /// @notice Deploys a fresh ERC1967-proxied `CallbackProxy` for a non-reactive chain. + /// @param owner_ Owner of the callback proxy (able to manage its callback-sender set). + /// @param callbackSenders_ Initial authorized callback senders (operators). + /// @return proxy_ Address of the deployed callback proxy. + /// @dev `CallbackProxy.onImplUpgrade` doubles as its initializer, so a normal proxy construction + /// works (no etch, arbitrary address). + function deployCallbackProxy(address owner_, address[] memory callbackSenders_) internal returns (address proxy_) { + CallbackProxy impl = new CallbackProxy(); + + ERC1967Proxy proxy = new ERC1967Proxy( + address(impl), + abi.encode( + CallbackProxy.InitialConfig({ + owner: owner_, + maxChargeGas: OmniConstants.DEFAULT_MAX_CHARGE_GAS, + extraGas: OmniConstants.DEFAULT_EXTRA_GAS, + gasPriceCoeffPer1000: OmniConstants.DEFAULT_GAS_PRICE_COEFF_PER_1000, + callbackSenders: callbackSenders_ + }) + ) + ); + + return address(proxy); + } +} diff --git a/src/interfaces/IReactiveInterfaces.sol b/src/interfaces/IReactiveInterfaces.sol deleted file mode 100644 index 73387a0..0000000 --- a/src/interfaces/IReactiveInterfaces.sol +++ /dev/null @@ -1,84 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; - -/// @notice Minimal IPayable interface (ABI-compatible with reactive-lib). -interface IPayable { - receive() external payable; - function debt(address _contract) external view returns (uint256); -} - -/// @notice Minimal ISubscriptionService interface (ABI-compatible with reactive-lib). -interface ISubscriptionService is IPayable { - function subscribe( - uint256 chain_id, - address _contract, - uint256 topic_0, - uint256 topic_1, - uint256 topic_2, - uint256 topic_3 - ) external; - - function unsubscribe( - uint256 chain_id, - address _contract, - uint256 topic_0, - uint256 topic_1, - uint256 topic_2, - uint256 topic_3 - ) external; -} - -/// @notice Minimal ISystemContract interface (ABI-compatible with reactive-lib). -interface ISystemContract is IPayable, ISubscriptionService {} - -/// @notice Minimal IPayer interface (ABI-compatible with reactive-lib). -interface IPayer { - function pay(uint256 amount) external; - receive() external payable; -} - -/// @notice LogRecord struct matching reactive-lib's IReactive.LogRecord. -struct LogRecord { - uint256 chain_id; - address _contract; - uint256 topic_0; - uint256 topic_1; - uint256 topic_2; - uint256 topic_3; - bytes data; - uint256 block_number; - uint256 op_code; - uint256 block_hash; - uint256 tx_hash; - uint256 log_index; -} - -/// @notice Interface for calling react() on reactive contracts. -interface IReactive { - function react(LogRecord calldata log) external; -} - -/// @notice Result of a callback execution. -struct CallbackResult { - uint256 chainId; - address target; - uint64 gasLimit; - bytes payload; - bool success; - bytes returnData; -} - -/// @notice Cron event trigger frequencies. -enum CronType { - Cron1, - Cron10, - Cron100, - Cron1000, - Cron10000 -} - -/// @notice Interface for the chain registry used by the simulator for auto chain ID detection. -interface IChainRegistry { - /// @notice Returns the chain ID for a given contract address, or 0 if not registered. - function getChainId(address contractAddr) external view returns (uint256); -} diff --git a/src/interfaces/IReactiveTest.sol b/src/interfaces/IReactiveTest.sol deleted file mode 100644 index c69ef03..0000000 --- a/src/interfaces/IReactiveTest.sol +++ /dev/null @@ -1,16 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; - -import {CallbackResult, CronType} from "./IReactiveInterfaces.sol"; - -/// @title IReactiveTest -/// @notice Internal interface defining the test harness contract API. -interface IReactiveTest { - function triggerAndReact( - address origin, - bytes memory callData, - uint256 originChainId - ) external returns (CallbackResult[] memory); - - function triggerCron(CronType cronType) external returns (CallbackResult[] memory); -} diff --git a/src/mock/MockCallbackProxy.sol b/src/mock/MockCallbackProxy.sol deleted file mode 100644 index faa5179..0000000 --- a/src/mock/MockCallbackProxy.sol +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; - -import {CallbackResult} from "../interfaces/IReactiveInterfaces.sol"; - -/// @title MockCallbackProxy -/// @notice Simulates the Reactive Network callback proxy for local Foundry testing. -/// Executes callback payloads on destination contracts, injecting the RVM ID -/// into the first argument of the payload (replicating real network behavior). -contract MockCallbackProxy { - /// @notice Executes a callback on the target contract. - /// @param target Destination contract address. - /// @param payload ABI-encoded function call data. - /// @param gasLimit Gas limit for the callback execution. - /// @param rvmId Deployer address to inject as the first argument (RVM ID). - /// @return success Whether the call succeeded. - /// @return result Return data from the call. - function executeCallback( - address target, - bytes memory payload, - uint64 gasLimit, - address rvmId - ) external returns (bool success, bytes memory result) { - // Overwrite first 160 bits of the first argument with rvmId. - // In ABI encoding, function selector is 4 bytes, then the first argument - // starts at byte 4. The address is right-aligned in a 32-byte word, - // so it occupies bytes 16-35 (0-indexed from start of the argument word). - // We overwrite bytes 4+12 through 4+31 (the last 20 bytes of the first 32-byte arg). - if (payload.length >= 36) { - assembly { - // payload data starts at payload + 0x20 (skip length prefix) - // first arg word starts at offset 4 (after selector) - // address is in the low 20 bytes of the 32-byte word - let argStart := add(add(payload, 0x20), 4) - // Clear and set: store rvmId as a 256-bit value (left-padded with zeros) - mstore(argStart, rvmId) - } - } - - (success, result) = target.call{gas: gasLimit}(payload); - } - - /// @notice Allows receiving ETH (for IPayable compatibility). - receive() external payable {} - - /// @notice No-op debt function for IPayable compatibility. - function debt(address) external pure returns (uint256) { - return 0; - } -} diff --git a/src/mock/MockSystemContract.sol b/src/mock/MockSystemContract.sol deleted file mode 100644 index 31bfb05..0000000 --- a/src/mock/MockSystemContract.sol +++ /dev/null @@ -1,143 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; - -import {ISystemContract, LogRecord} from "../interfaces/IReactiveInterfaces.sol"; -import {ReactiveConstants} from "../constants/ReactiveConstants.sol"; - -/// @title MockSystemContract -/// @notice Simulates the Reactive Network system contract for local Foundry testing. -/// Stores subscriptions and provides matching logic used by the simulator. -contract MockSystemContract { - struct Subscription { - uint256 chainId; - address contractAddr; - uint256 topic0; - uint256 topic1; - uint256 topic2; - uint256 topic3; - address subscriber; - } - - Subscription[] public subscriptions; - - // ---- ISubscriptionService ---- - - function subscribe( - uint256 chain_id, - address _contract, - uint256 topic_0, - uint256 topic_1, - uint256 topic_2, - uint256 topic_3 - ) external { - subscriptions.push(Subscription({ - chainId: chain_id, - contractAddr: _contract, - topic0: topic_0, - topic1: topic_1, - topic2: topic_2, - topic3: topic_3, - subscriber: msg.sender - })); - } - - function unsubscribe( - uint256 chain_id, - address _contract, - uint256 topic_0, - uint256 topic_1, - uint256 topic_2, - uint256 topic_3 - ) external { - uint256 len = subscriptions.length; - for (uint256 i = 0; i < len; i++) { - Subscription storage sub = subscriptions[i]; - if ( - sub.subscriber == msg.sender && - sub.chainId == chain_id && - sub.contractAddr == _contract && - sub.topic0 == topic_0 && - sub.topic1 == topic_1 && - sub.topic2 == topic_2 && - sub.topic3 == topic_3 - ) { - // Swap with last and pop - subscriptions[i] = subscriptions[len - 1]; - subscriptions.pop(); - return; - } - } - revert("MockSystemContract: subscription not found"); - } - - // ---- IPayable (no-op stubs) ---- - - function debt(address) external pure returns (uint256) { - return 0; - } - - receive() external payable {} - - // ---- Query helpers for the simulator ---- - - /// @notice Returns all subscriber addresses whose subscriptions match the given event. - function getMatchingSubscribers( - uint256 chainId, - address contractAddr, - uint256 topic0, - uint256 topic1, - uint256 topic2, - uint256 topic3 - ) external view returns (address[] memory) { - uint256 len = subscriptions.length; - // First pass: count matches - uint256 count = 0; - for (uint256 i = 0; i < len; i++) { - if (_matches(subscriptions[i], chainId, contractAddr, topic0, topic1, topic2, topic3)) { - count++; - } - } - // Second pass: collect - address[] memory result = new address[](count); - uint256 idx = 0; - for (uint256 i = 0; i < len; i++) { - if (_matches(subscriptions[i], chainId, contractAddr, topic0, topic1, topic2, topic3)) { - result[idx++] = subscriptions[i].subscriber; - } - } - return result; - } - - /// @notice Returns total number of active subscriptions. - function subscriptionCount() external view returns (uint256) { - return subscriptions.length; - } - - // ---- Internal matching logic ---- - - function _matches( - Subscription storage sub, - uint256 chainId, - address contractAddr, - uint256 topic0, - uint256 topic1, - uint256 topic2, - uint256 topic3 - ) internal view returns (bool) { - uint256 RI = ReactiveConstants.REACTIVE_IGNORE; - - // Chain ID: 0 = wildcard - if (sub.chainId != 0 && sub.chainId != chainId) return false; - - // Contract address: address(0) = wildcard - if (sub.contractAddr != address(0) && sub.contractAddr != contractAddr) return false; - - // Topics: REACTIVE_IGNORE = wildcard - if (sub.topic0 != RI && sub.topic0 != topic0) return false; - if (sub.topic1 != RI && sub.topic1 != topic1) return false; - if (sub.topic2 != RI && sub.topic2 != topic2) return false; - if (sub.topic3 != RI && sub.topic3 != topic3) return false; - - return true; - } -} diff --git a/src/simulator/CronSimulator.sol b/src/simulator/CronSimulator.sol deleted file mode 100644 index 91445d7..0000000 --- a/src/simulator/CronSimulator.sol +++ /dev/null @@ -1,67 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; - -import {Vm} from "forge-std/Vm.sol"; -import {LogRecord, CallbackResult, CronType} from "../interfaces/IReactiveInterfaces.sol"; -import {MockSystemContract} from "../mock/MockSystemContract.sol"; -import {MockCallbackProxy} from "../mock/MockCallbackProxy.sol"; -import {ReactiveConstants} from "../constants/ReactiveConstants.sol"; -import {ReactiveSimulator} from "./ReactiveSimulator.sol"; - -/// @title CronSimulator -/// @notice Simulates cron-based event triggers for time-based reactive contracts. -library CronSimulator { - /// @notice Emit a synthetic cron event and deliver it to all matching subscribers. - function triggerCron( - Vm _vm, - CronType cronType, - MockSystemContract sys, - MockCallbackProxy proxy, - address rvmId, - uint256 reactiveChainId - ) internal returns (CallbackResult[] memory results) { - uint256 cronTopic = _getCronTopic(cronType); - - LogRecord memory log = LogRecord({ - chain_id: reactiveChainId, - _contract: address(ReactiveConstants.SERVICE_ADDR), - topic_0: cronTopic, - topic_1: 0, - topic_2: 0, - topic_3: 0, - data: abi.encode(block.number), - block_number: block.number, - op_code: 0, - block_hash: 0, - tx_hash: 0, - log_index: 0 - }); - - return ReactiveSimulator.deliverEvent(_vm, log, sys, proxy, rvmId, reactiveChainId); - } - - /// @notice Advance block number, then trigger a cron event. - function advanceAndTriggerCron( - Vm _vm, - uint256 blocks, - CronType cronType, - MockSystemContract sys, - MockCallbackProxy proxy, - address rvmId, - uint256 reactiveChainId - ) internal returns (CallbackResult[] memory results) { - _vm.roll(block.number + blocks); - _vm.warp(block.timestamp + blocks * 12); // ~12s per block - return triggerCron(_vm, cronType, sys, proxy, rvmId, reactiveChainId); - } - - /// @notice Maps CronType enum to the corresponding topic constant. - function _getCronTopic(CronType cronType) internal pure returns (uint256) { - if (cronType == CronType.Cron1) return ReactiveConstants.CRON_TOPIC_1; - if (cronType == CronType.Cron10) return ReactiveConstants.CRON_TOPIC_10; - if (cronType == CronType.Cron100) return ReactiveConstants.CRON_TOPIC_100; - if (cronType == CronType.Cron1000) return ReactiveConstants.CRON_TOPIC_1000; - if (cronType == CronType.Cron10000) return ReactiveConstants.CRON_TOPIC_10000; - revert("CronSimulator: invalid cron type"); - } -} diff --git a/src/simulator/ReactiveSimulator.sol b/src/simulator/ReactiveSimulator.sol deleted file mode 100644 index 4f9b5b2..0000000 --- a/src/simulator/ReactiveSimulator.sol +++ /dev/null @@ -1,400 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; - -import {Vm} from "forge-std/Vm.sol"; -import {LogRecord, CallbackResult, IReactive} from "../interfaces/IReactiveInterfaces.sol"; -import {MockSystemContract} from "../mock/MockSystemContract.sol"; -import {MockCallbackProxy} from "../mock/MockCallbackProxy.sol"; -import {ReactiveConstants} from "../constants/ReactiveConstants.sol"; - -/// @notice Bundled parameters for simulation to avoid stack-too-deep. -struct SimulationParams { - Vm _vm; - MockSystemContract sys; - MockCallbackProxy proxy; - address rvmId; - uint256 reactiveChainId; -} - -/// @notice A pending event awaiting subscription matching, tagged with its origin chain ID. -struct PendingEvent { - uint256 chainId; - address emitter; - uint256 t0; - uint256 t1; - uint256 t2; - uint256 t3; - bytes data; -} - -/// @notice Parsed Callback event (not yet executed). -struct CallbackSpec { - uint256 chainId; - address target; - uint64 gasLimit; - bytes payload; -} - -/// @title ReactiveSimulator -/// @notice Orchestrates the full reactive lifecycle (event -> react() -> callback) in a Foundry test. -library ReactiveSimulator { - uint256 internal constant DEFAULT_MAX_ITERATIONS = 20; - - // ---- Single-step simulation (original API) ---- - - /// @notice Simulate a single event -> react() -> callback cycle. - function simulateReaction( - Vm _vm, - address origin, - bytes memory callData, - uint256 value, - uint256 originChainId, - MockSystemContract sys, - MockCallbackProxy proxy, - address rvmId, - uint256 reactiveChainId - ) internal returns (CallbackResult[] memory) { - SimulationParams memory p = SimulationParams(_vm, sys, proxy, rvmId, reactiveChainId); - - _vm.recordLogs(); - (bool ok,) = origin.call{value: value}(callData); - require(ok, "ReactiveSimulator: origin call failed"); - Vm.Log[] memory logs = _vm.getRecordedLogs(); - - PendingEvent[] memory pending = _vmLogsToPending(logs, originChainId); - // Single-step: process events, execute callbacks, don't capture further events - return _processAndExecute(p, pending); - } - - // ---- Full-cycle simulation (multi-step) ---- - - /// @notice Simulate the full multi-step reactive cycle until quiescence. - /// Keeps processing: events -> react() -> callbacks -> new events -> ... - /// Stops when no callbacks are produced or maxIterations is reached. - function simulateFullCycle( - Vm _vm, - address origin, - bytes memory callData, - uint256 value, - uint256 originChainId, - MockSystemContract sys, - MockCallbackProxy proxy, - address rvmId, - uint256 reactiveChainId, - uint256 maxIterations - ) internal returns (CallbackResult[] memory) { - SimulationParams memory p = SimulationParams(_vm, sys, proxy, rvmId, reactiveChainId); - - // Execute initial call and capture events - _vm.recordLogs(); - (bool ok,) = origin.call{value: value}(callData); - require(ok, "ReactiveSimulator: origin call failed"); - Vm.Log[] memory logs = _vm.getRecordedLogs(); - - PendingEvent[] memory pending = _vmLogsToPending(logs, originChainId); - - CallbackResult[] memory allResults = new CallbackResult[](0); - - for (uint256 iter = 0; iter < maxIterations; iter++) { - if (pending.length == 0) break; - - // 1. Match pending events → call react() → collect CallbackSpecs (not yet executed) - CallbackSpec[] memory specs = _matchAndReact(p, pending); - - if (specs.length == 0) break; - - // 2. Execute each callback, capturing events emitted by the target - CallbackResult[] memory batchResults = new CallbackResult[](specs.length); - PendingEvent[] memory tempPending = new PendingEvent[](specs.length * 8); - uint256 pendingCount = 0; - - for (uint256 i = 0; i < specs.length; i++) { - (CallbackResult memory result, PendingEvent[] memory newEvents) = - _executeCallbackWithCapture(p, specs[i]); - batchResults[i] = result; - for (uint256 j = 0; j < newEvents.length; j++) { - tempPending[pendingCount++] = newEvents[j]; - } - } - - allResults = _concatResults(allResults, batchResults); - pending = _trimPending(tempPending, pendingCount); - } - - return allResults; - } - - // ---- Event delivery (for cron and manual use) ---- - - /// @notice Deliver a specific LogRecord to all matching subscribers and execute callbacks. - function deliverEvent( - Vm _vm, - LogRecord memory log, - MockSystemContract sys, - MockCallbackProxy proxy, - address rvmId, - uint256 reactiveChainId - ) internal returns (CallbackResult[] memory) { - SimulationParams memory p = SimulationParams(_vm, sys, proxy, rvmId, reactiveChainId); - - PendingEvent[] memory pending = new PendingEvent[](1); - pending[0] = PendingEvent({ - chainId: log.chain_id, - emitter: log._contract, - t0: log.topic_0, - t1: log.topic_1, - t2: log.topic_2, - t3: log.topic_3, - data: log.data - }); - - return _processAndExecute(p, pending); - } - - /// @notice Manually deliver a LogRecord to a specific reactive contract (no callback processing). - function deliverRawEvent( - Vm _vm, - IReactive target, - LogRecord memory log - ) internal { - _vm.prank(address(ReactiveConstants.SERVICE_ADDR)); - target.react(log); - } - - // ---- Internal: single-step processing (match → react → execute callbacks) ---- - - /// @dev Process pending events: match → react() → execute callbacks. No event capture from callbacks. - function _processAndExecute( - SimulationParams memory p, - PendingEvent[] memory pending - ) private returns (CallbackResult[] memory) { - // Collect callback specs from react() calls - CallbackSpec[] memory specs = _matchAndReact(p, pending); - - // Execute all callbacks - CallbackResult[] memory results = new CallbackResult[](specs.length); - for (uint256 i = 0; i < specs.length; i++) { - results[i] = _executeCallback(p, specs[i]); - } - return results; - } - - // ---- Internal: match events → react() → collect CallbackSpecs ---- - - /// @dev For each pending event, find matching subscribers, call react(), collect Callback events. - function _matchAndReact( - SimulationParams memory p, - PendingEvent[] memory pending - ) private returns (CallbackSpec[] memory) { - CallbackSpec[] memory tempSpecs = new CallbackSpec[](pending.length * 8); - uint256 specCount = 0; - - for (uint256 i = 0; i < pending.length; i++) { - address[] memory subscribers = p.sys.getMatchingSubscribers( - pending[i].chainId, pending[i].emitter, - pending[i].t0, pending[i].t1, pending[i].t2, pending[i].t3 - ); - - if (subscribers.length == 0) continue; - - LogRecord memory log = _pendingToLogRecord(pending[i]); - - for (uint256 j = 0; j < subscribers.length; j++) { - CallbackSpec[] memory specs = _reactAndExtractSpecs(p, subscribers[j], log); - for (uint256 k = 0; k < specs.length; k++) { - tempSpecs[specCount++] = specs[k]; - } - } - } - - return _trimSpecs(tempSpecs, specCount); - } - - /// @dev Call react() on a subscriber and parse emitted Callback events into CallbackSpecs. - function _reactAndExtractSpecs( - SimulationParams memory p, - address subscriber, - LogRecord memory log - ) private returns (CallbackSpec[] memory) { - p._vm.recordLogs(); - p._vm.prank(address(ReactiveConstants.SERVICE_ADDR)); - IReactive(subscriber).react(log); - Vm.Log[] memory reactLogs = p._vm.getRecordedLogs(); - - bytes32 cbTopic = ReactiveConstants.CALLBACK_EVENT_TOPIC; - - uint256 cbCount = 0; - for (uint256 i = 0; i < reactLogs.length; i++) { - if (reactLogs[i].topics.length >= 4 && reactLogs[i].topics[0] == cbTopic) { - cbCount++; - } - } - - CallbackSpec[] memory specs = new CallbackSpec[](cbCount); - uint256 idx = 0; - - for (uint256 i = 0; i < reactLogs.length; i++) { - if (reactLogs[i].topics.length < 4 || reactLogs[i].topics[0] != cbTopic) continue; - - specs[idx++] = CallbackSpec({ - chainId: uint256(reactLogs[i].topics[1]), - target: address(uint160(uint256(reactLogs[i].topics[2]))), - gasLimit: uint64(uint256(reactLogs[i].topics[3])), - payload: abi.decode(reactLogs[i].data, (bytes)) - }); - } - - return specs; - } - - // ---- Internal: callback execution ---- - - /// @dev Execute a callback (no event capture). Used by single-step mode. - function _executeCallback( - SimulationParams memory p, - CallbackSpec memory spec - ) private returns (CallbackResult memory) { - bool success; - bytes memory returnData; - - if (spec.chainId == p.reactiveChainId) { - _injectRvmId(spec.payload, p.rvmId); - p._vm.prank(address(ReactiveConstants.SERVICE_ADDR)); - (success, returnData) = spec.target.call{gas: spec.gasLimit}(spec.payload); - } else { - (success, returnData) = p.proxy.executeCallback( - spec.target, spec.payload, spec.gasLimit, p.rvmId - ); - } - - return CallbackResult({ - chainId: spec.chainId, - target: spec.target, - gasLimit: spec.gasLimit, - payload: spec.payload, - success: success, - returnData: returnData - }); - } - - /// @dev Execute a callback while recording events emitted by the target. Used by full-cycle mode. - /// Events emitted during execution are tagged with the callback's chain ID. - function _executeCallbackWithCapture( - SimulationParams memory p, - CallbackSpec memory spec - ) private returns (CallbackResult memory, PendingEvent[] memory) { - bool success; - bytes memory returnData; - - p._vm.recordLogs(); - - if (spec.chainId == p.reactiveChainId) { - _injectRvmId(spec.payload, p.rvmId); - p._vm.prank(address(ReactiveConstants.SERVICE_ADDR)); - (success, returnData) = spec.target.call{gas: spec.gasLimit}(spec.payload); - } else { - (success, returnData) = p.proxy.executeCallback( - spec.target, spec.payload, spec.gasLimit, p.rvmId - ); - } - - Vm.Log[] memory logs = p._vm.getRecordedLogs(); - PendingEvent[] memory newEvents = _vmLogsToPending(logs, spec.chainId); - - return ( - CallbackResult({ - chainId: spec.chainId, - target: spec.target, - gasLimit: spec.gasLimit, - payload: spec.payload, - success: success, - returnData: returnData - }), - newEvents - ); - } - - // ---- Internal: helpers ---- - - function _injectRvmId(bytes memory payload, address rvmId) private pure { - if (payload.length >= 36) { - assembly { - let argStart := add(add(payload, 0x20), 4) - mstore(argStart, rvmId) - } - } - } - - function _vmLogsToPending( - Vm.Log[] memory logs, - uint256 chainId - ) private pure returns (PendingEvent[] memory) { - PendingEvent[] memory pending = new PendingEvent[](logs.length); - for (uint256 i = 0; i < logs.length; i++) { - pending[i] = PendingEvent({ - chainId: chainId, - emitter: logs[i].emitter, - t0: logs[i].topics.length > 0 ? uint256(logs[i].topics[0]) : 0, - t1: logs[i].topics.length > 1 ? uint256(logs[i].topics[1]) : 0, - t2: logs[i].topics.length > 2 ? uint256(logs[i].topics[2]) : 0, - t3: logs[i].topics.length > 3 ? uint256(logs[i].topics[3]) : 0, - data: logs[i].data - }); - } - return pending; - } - - function _pendingToLogRecord(PendingEvent memory evt) private view returns (LogRecord memory) { - return LogRecord({ - chain_id: evt.chainId, - _contract: evt.emitter, - topic_0: evt.t0, - topic_1: evt.t1, - topic_2: evt.t2, - topic_3: evt.t3, - data: evt.data, - block_number: block.number, - op_code: 0, - block_hash: 0, - tx_hash: 0, - log_index: 0 - }); - } - - function _trimResults( - CallbackResult[] memory temp, - uint256 count - ) private pure returns (CallbackResult[] memory) { - CallbackResult[] memory result = new CallbackResult[](count); - for (uint256 i = 0; i < count; i++) result[i] = temp[i]; - return result; - } - - function _trimSpecs( - CallbackSpec[] memory temp, - uint256 count - ) private pure returns (CallbackSpec[] memory) { - CallbackSpec[] memory result = new CallbackSpec[](count); - for (uint256 i = 0; i < count; i++) result[i] = temp[i]; - return result; - } - - function _trimPending( - PendingEvent[] memory temp, - uint256 count - ) private pure returns (PendingEvent[] memory) { - PendingEvent[] memory result = new PendingEvent[](count); - for (uint256 i = 0; i < count; i++) result[i] = temp[i]; - return result; - } - - function _concatResults( - CallbackResult[] memory a, - CallbackResult[] memory b - ) private pure returns (CallbackResult[] memory) { - CallbackResult[] memory result = new CallbackResult[](a.length + b.length); - for (uint256 i = 0; i < a.length; i++) result[i] = a[i]; - for (uint256 i = 0; i < b.length; i++) result[a.length + i] = b[i]; - return result; - } -} diff --git a/test/BasicDemo.t.sol b/test/BasicDemo.t.sol index 174af0e..91af774 100644 --- a/test/BasicDemo.t.sol +++ b/test/BasicDemo.t.sol @@ -1,99 +1,79 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; -import {ReactiveTest} from "../src/base/ReactiveTest.sol"; -import {CallbackResult} from "../src/interfaces/IReactiveInterfaces.sol"; -import {SampleOrigin} from "./mocks/SampleOrigin.sol"; -import {SampleReactiveContract} from "./mocks/SampleReactiveContract.sol"; -import {SampleCallback} from "./mocks/SampleCallback.sol"; +pragma solidity ^0.8.29; -contract BasicDemoTest is ReactiveTest { - SampleOrigin origin; - SampleReactiveContract rc; - SampleCallback cb; - - uint256 constant ORIGIN_CHAIN = 11155111; // Sepolia - uint256 constant DEST_CHAIN = 11155111; +import { ReactiveTest, FeeMode, CallbackResult } from "../src/ReactiveTest.sol"; +import { OmniConstants } from "../src/OmniConstants.sol"; +import { SampleOrigin } from "./mocks/SampleOrigin.sol"; +import { SampleReactive } from "./mocks/SampleReactive.sol"; +import { SampleCallback } from "./mocks/SampleCallback.sol"; - // topic0 for Received(address,address,uint256) - uint256 receivedTopic; +/// @notice §10 BasicDemo: the canonical cross-chain flow. An origin event above a threshold makes a +/// reactive contract request a callback, which the harness delivers to a callback contract on +/// the destination chain. This is the "hello world" of the library. +contract BasicDemoTest is ReactiveTest { + uint256 constant REACTIVE_CHAIN = 0x512512; + uint256 constant SEPOLIA = 11155111; + bytes32 constant RECEIVED = keccak256("Received(address,address,uint256)"); - function setUp() public override { - super.setUp(); + SampleOrigin origin; + SampleReactive reactive; + SampleCallback callbackContract; - receivedTopic = uint256(keccak256("Received(address,address,uint256)")); + function setUp() public { + _reactiveSetUp(REACTIVE_CHAIN); + address callbackProxy = _registerChain(SEPOLIA); - // Deploy origin contract origin = new SampleOrigin(); + reactive = new SampleReactive(SEPOLIA, address(origin), uint256(RECEIVED), SEPOLIA, 0.001 ether); + callbackContract = new SampleCallback(callbackProxy, address(reactive)); + reactive.setCallback(address(callbackContract)); - // Deploy callback contract — pass proxy address as callback_sender - cb = new SampleCallback(address(proxy)); - - // Deploy reactive contract — constructor calls subscribe() on MockSystemContract - rc = new SampleReactiveContract( - address(sys), - ORIGIN_CHAIN, - DEST_CHAIN, - address(origin), - receivedTopic, - address(cb) - ); + _registerContract(reactiveChainId(), address(reactive)); + _registerContract(SEPOLIA, address(origin)); + _registerContract(SEPOLIA, address(callbackContract)); } - function testCallbackTriggeredAboveThreshold() public { - // Send 0.002 ETH to origin — emits Received event with value > 0.001 ether - CallbackResult[] memory results = triggerAndReactWithValue( - address(origin), - abi.encodeWithSignature("deposit()"), - 0.002 ether, - ORIGIN_CHAIN - ); - - // Callback should have fired - assertCallbackCount(results, 1); - assertCallbackSuccess(results, 0); - assertCallbackEmitted(results, address(cb)); - - // Verify the callback contract received the call - assertEq(cb.callbackCount(), 1); - assertEq(cb.lastAmount(), 0.002 ether); + /// @dev Convenience accessor mirroring the `reactiveChainId` used in the spec sketch. + function reactiveChainId() internal view returns (uint256) { + return _reactiveChainId(); } - function testNoCallbackBelowThreshold() public { - // Send 0.0005 ETH — below 0.001 threshold - CallbackResult[] memory results = triggerAndReactWithValue( - address(origin), - abi.encodeWithSignature("deposit()"), - 0.0005 ether, - ORIGIN_CHAIN - ); - - assertNoCallbacks(results); - assertEq(cb.callbackCount(), 0); + function test_callbackFiresAboveThreshold() public { + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + _switchToChain(REACTIVE_CHAIN); + + CallbackResult[] memory r = _react(FeeMode.Subsidized); + + assertEq(r.length, 1, "one callback delivered"); + assertTrue(r[0].success, "callback succeeded"); + assertEq(r[0].destChainId, SEPOLIA); + assertEq(r[0].recipient, address(callbackContract)); + assertEq(r[0].sender, address(reactive), "injected sender is the reactive contract"); + assertEq(callbackContract.count(), 1); } - function testReactCalledOnMatchingEvent() public { - triggerAndReactWithValue( - address(origin), - abi.encodeWithSignature("deposit()"), - 0.002 ether, - ORIGIN_CHAIN - ); + function test_noCallbackBelowThreshold() public { + _switchToChain(SEPOLIA); + origin.emitReceived(0.0005 ether); + _switchToChain(REACTIVE_CHAIN); - // react() should have been called - assertEq(rc.reactCallCount(), 1); + CallbackResult[] memory r = _react(FeeMode.Subsidized); + + assertEq(r.length, 0, "no callback below threshold"); + assertEq(reactive.reactCount(), 1, "reacted, but did not request a callback"); + assertEq(callbackContract.count(), 0); } - function testRvmIdInjection() public { - // Verify the RVM ID (deployer address) is injected into callback payload - triggerAndReactWithValue( - address(origin), - abi.encodeWithSignature("deposit()"), - 0.002 ether, - ORIGIN_CHAIN - ); - - // The callback should have received rvmId (address(this)) as the first arg - assertEq(cb.lastRvmId(), rvmId); + function test_capturedEventsAreQueryable() public { + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + _switchToChain(REACTIVE_CHAIN); + _react(FeeMode.Subsidized); + + _assertEmitted(address(origin), RECEIVED); + _assertEmitted(OmniConstants.SYSTEM_ADDR, OmniConstants.TOPIC_CALLBACK_REQUEST); + assertEq(_countEmitted(OmniConstants.SYSTEM_ADDR, OmniConstants.TOPIC_CALLBACK_REQUEST), 1); } } diff --git a/test/CallbackAuth.t.sol b/test/CallbackAuth.t.sol index d671be0..bf7d780 100644 --- a/test/CallbackAuth.t.sol +++ b/test/CallbackAuth.t.sol @@ -1,77 +1,94 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; -import {ReactiveTest} from "../src/base/ReactiveTest.sol"; -import {CallbackResult} from "../src/interfaces/IReactiveInterfaces.sol"; -import {MockCallbackProxy} from "../src/mock/MockCallbackProxy.sol"; -import {SampleOrigin} from "./mocks/SampleOrigin.sol"; -import {SampleReactiveContract} from "./mocks/SampleReactiveContract.sol"; -import {SampleCallback} from "./mocks/SampleCallback.sol"; +pragma solidity ^0.8.29; +import { ReactiveTest, FeeMode, CallbackResult } from "../src/ReactiveTest.sol"; +import { OmniConstants } from "../src/OmniConstants.sol"; +import { CallbackProxy } from "@system/omni/CallbackProxy.sol"; +import { SampleOrigin } from "./mocks/SampleOrigin.sol"; +import { SampleReactive } from "./mocks/SampleReactive.sol"; +import { SampleCallback } from "./mocks/SampleCallback.sol"; + +/// @notice §10 CallbackAuth: the engine injects the reactive contract's address into payload arg0, +/// so `onlyCallbackSender` passes for the right recipient and reverts for a mismatched one; +/// plus the callback contract's Metered charging vs Subsidized neutralization. contract CallbackAuthTest is ReactiveTest { - SampleOrigin origin; - SampleReactiveContract rc; - SampleCallback cb; + uint256 constant RC = 0x512512; + uint256 constant SEPOLIA = 11155111; + bytes32 constant RECEIVED = keccak256("Received(address,address,uint256)"); - uint256 constant ORIGIN_CHAIN = 11155111; - uint256 constant DEST_CHAIN = 11155111; + SampleOrigin origin; + SampleReactive reactive; + SampleCallback goodCb; + address proxy; - function setUp() public override { - super.setUp(); + function setUp() public { + _reactiveSetUp(RC); + proxy = _registerChain(SEPOLIA); origin = new SampleOrigin(); - cb = new SampleCallback(address(proxy)); - - uint256 receivedTopic = uint256(keccak256("Received(address,address,uint256)")); - - rc = new SampleReactiveContract( - address(sys), - ORIGIN_CHAIN, - DEST_CHAIN, - address(origin), - receivedTopic, - address(cb) - ); + reactive = new SampleReactive(SEPOLIA, address(origin), uint256(RECEIVED), SEPOLIA, 0.001 ether); + goodCb = new SampleCallback(proxy, address(reactive)); // expects the reactive contract + reactive.setCallback(address(goodCb)); + + _registerContract(RC, address(reactive)); + _registerContract(SEPOLIA, address(origin)); + _registerContract(SEPOLIA, address(goodCb)); + } + + function _fire(FeeMode mode) internal returns (CallbackResult[] memory) { + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + _switchToChain(RC); + return _react(mode); } - function testRvmIdOverwrite() public { - // Trigger a callback and verify the RVM ID is correctly injected - CallbackResult[] memory results = triggerAndReactWithValue( - address(origin), - abi.encodeWithSignature("deposit()"), - 0.002 ether, - ORIGIN_CHAIN - ); - - assertCallbackCount(results, 1); - // The first argument in the callback should be rvmId (address(this)) - assertEq(cb.lastRvmId(), rvmId); + function test_injectedSenderAuthorizesTheRightRecipient() public { + CallbackResult[] memory r = _fire(FeeMode.Subsidized); + assertEq(r.length, 1); + assertTrue(r[0].success, "authorized callback succeeds"); + assertEq(r[0].sender, address(reactive), "arg0 injected with the reactive address"); + assertEq(goodCb.lastSender(), address(reactive), "onlyCallbackSender received the reactive address"); + assertEq(goodCb.count(), 1); } - function testCallbackExecutedByProxy() public { - // Verify callback is executed via the proxy (not direct) - triggerAndReactWithValue( - address(origin), - abi.encodeWithSignature("deposit()"), - 0.002 ether, - ORIGIN_CHAIN - ); + function test_wrongExpectedSenderReverts() public { + // A recipient that expects a DIFFERENT sender than the reactive contract. + SampleCallback wrongCb = new SampleCallback(proxy, address(0xBEEF)); + reactive.setCallback(address(wrongCb)); + _registerContract(SEPOLIA, address(wrongCb)); + + CallbackResult[] memory r = _fire(FeeMode.Subsidized); + assertEq(r.length, 1); + assertFalse(r[0].success, "onlyCallbackSender rejects the injected reactive address"); + assertEq(wrongCb.count(), 0, "callback body never executed"); + _assertEmitted(proxy, OmniConstants.TOPIC_CALLBACK_FAILURE); + } + + function test_meteredChargesUnfundedCallbackVsSubsidized() public { + CallbackProxy cbProxy = CallbackProxy(payable(proxy)); + + // Subsidized never charges, even with a gas price set. + vm.txGasPrice(1 gwei); + _fire(FeeMode.Subsidized); + assertEq(cbProxy.debts(address(goodCb)), 0, "subsidized never charges the callback contract"); - assertEq(cb.callbackCount(), 1); + // Metered charges the unfunded callback -> debt + BlacklistContract from the proxy. + vm.txGasPrice(1 gwei); + _fire(FeeMode.Metered); + assertGt(cbProxy.debts(address(goodCb)), 0, "metered charges the unfunded callback contract"); + _assertEmitted(proxy, OmniConstants.TOPIC_BLACKLIST); } - function testCustomRvmId() public { - // Override rvmId and verify it propagates - address customRvmId = makeAddr("customDeployer"); - rvmId = customRvmId; + function test_fundedCallbackNotBlacklistedUnderMetered() public { + // Fund the callback contract's reserves so Metered charging draws down reserves, no debt. + CallbackProxy cbProxy = CallbackProxy(payable(proxy)); + cbProxy.depositTo{ value: 1 ether }(address(goodCb)); - triggerAndReactWithValue( - address(origin), - abi.encodeWithSignature("deposit()"), - 0.002 ether, - ORIGIN_CHAIN - ); + vm.txGasPrice(1 gwei); + CallbackResult[] memory r = _fire(FeeMode.Metered); - assertEq(cb.lastRvmId(), customRvmId); + assertTrue(r[0].success, "funded callback delivered"); + assertEq(cbProxy.debts(address(goodCb)), 0, "no debt when funded"); } } diff --git a/test/CaptureHelpers.t.sol b/test/CaptureHelpers.t.sol new file mode 100644 index 0000000..a13f84b --- /dev/null +++ b/test/CaptureHelpers.t.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.29; + +import { ReactiveTest, FeeMode } from "../src/ReactiveTest.sol"; +import { Vm } from "forge-std/Vm.sol"; +import { OmniConstants } from "../src/OmniConstants.sol"; +import { SampleOrigin } from "./mocks/SampleOrigin.sol"; +import { SampleReactive } from "./mocks/SampleReactive.sol"; +import { SampleCallback } from "./mocks/SampleCallback.sol"; + +/// @notice Covers both branches of the §6.8 event-capture helpers (found / not-found, present / +/// absent), including the revert paths the assertion helpers take. +contract CaptureHelpersTest is ReactiveTest { + uint256 constant RC = 0x512512; + uint256 constant SEPOLIA = 11155111; + bytes32 constant RECEIVED = keccak256("Received(address,address,uint256)"); + bytes32 constant ABSENT = keccak256("NeverEmitted()"); + + SampleOrigin origin; + SampleReactive reactive; + SampleCallback cb; + + function setUp() public { + _reactiveSetUp(RC); + address proxy = _registerChain(SEPOLIA); + origin = new SampleOrigin(); + reactive = new SampleReactive(SEPOLIA, address(origin), uint256(RECEIVED), SEPOLIA, 0.001 ether); + cb = new SampleCallback(proxy, address(reactive)); + reactive.setCallback(address(cb)); + _registerContract(RC, address(reactive)); + _registerContract(SEPOLIA, address(origin)); + _registerContract(SEPOLIA, address(cb)); + + // Fire once so `capturedLogs` is populated for the helpers below. + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + _switchToChain(RC); + _react(FeeMode.Subsidized); + } + + // External wrappers so `vm.expectRevert` observes the internal helpers' reverts. + function assertEmittedExt(address e, bytes32 t) external view { + _assertEmitted(e, t); + } + + function assertEmittedDataExt(address e, bytes32 t, bytes memory d) external view { + _assertEmitted(e, t, d); + } + + function assertNotEmittedExt(address e, bytes32 t) external view { + _assertNotEmitted(e, t); + } + + function test_recordedLogsIsPopulated() public view { + assertGt(_recordedLogs().length, 0, "capture buffer non-empty after a cycle"); + } + + function test_assertEmittedFound() public view { + _assertEmitted(address(origin), RECEIVED); + _assertEmitted(OmniConstants.SYSTEM_ADDR, OmniConstants.TOPIC_CALLBACK_REQUEST); + } + + function test_assertEmittedRevertsWhenAbsent() public { + vm.expectRevert(bytes("ReactiveTest: expected event not emitted")); + this.assertEmittedExt(address(origin), ABSENT); + } + + function test_assertEmittedWithDataMatches() public view { + // `Received`'s args are all indexed, so its data is empty. + _assertEmitted(address(origin), RECEIVED, ""); + } + + function test_assertEmittedWithDataRevertsOnMismatch() public { + vm.expectRevert(bytes("ReactiveTest: expected event (with data) not emitted")); + this.assertEmittedDataExt(address(origin), RECEIVED, hex"deadbeef"); + } + + function test_assertNotEmittedPasses() public view { + _assertNotEmitted(address(origin), ABSENT); + } + + function test_assertNotEmittedRevertsWhenPresent() public { + vm.expectRevert(bytes("ReactiveTest: unexpected event emitted")); + this.assertNotEmittedExt(address(origin), RECEIVED); + } + + function test_countEmitted() public view { + assertEq(_countEmitted(address(origin), RECEIVED), 1, "one Received captured"); + assertEq(_countEmitted(address(origin), ABSENT), 0, "absent topic counts zero"); + } + + function test_findEmittedFoundAndNotFound() public view { + (bool found, Vm.Log memory log) = _findEmitted(address(origin), RECEIVED); + assertTrue(found, "found"); + assertEq(log.emitter, address(origin)); + assertEq(log.topics[0], RECEIVED); + + (bool found2,) = _findEmitted(address(origin), ABSENT); + assertFalse(found2, "not found"); + } +} diff --git a/test/ChainRegistry.t.sol b/test/ChainRegistry.t.sol index 5c0567f..f02196d 100644 --- a/test/ChainRegistry.t.sol +++ b/test/ChainRegistry.t.sol @@ -1,138 +1,72 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; -import {ReactiveTest} from "../src/base/ReactiveTest.sol"; -import {CallbackResult} from "../src/interfaces/IReactiveInterfaces.sol"; -import {ReactiveConstants} from "../src/constants/ReactiveConstants.sol"; -import {SampleOrigin} from "./mocks/SampleOrigin.sol"; -import {SampleReactiveContract} from "./mocks/SampleReactiveContract.sol"; -import {SampleCallback} from "./mocks/SampleCallback.sol"; -import {SampleSelfCallbackContract} from "./mocks/SampleSelfCallbackContract.sol"; -import {MiniOrigin, MiniBridge, MiniReactive} from "./mocks/SampleMultiStepContracts.sol"; +pragma solidity ^0.8.29; -/// @notice Tests the chain registry for auto chain ID detection. -contract ChainRegistryTest is ReactiveTest { - SampleOrigin origin; - SampleReactiveContract rc; - SampleCallback cb; +import { ReactiveTest, FeeMode, CallbackResult } from "../src/ReactiveTest.sol"; +import { SampleOrigin } from "./mocks/SampleOrigin.sol"; +import { SampleReactive } from "./mocks/SampleReactive.sol"; +import { SampleCallback } from "./mocks/SampleCallback.sol"; +/// @notice §10 ChainRegistry: an event's origin chain is intrinsic (looked up from the emitter's +/// registration), and events emitted by unregistered contracts are discarded. +contract ChainRegistryTest is ReactiveTest { + uint256 constant REACTIVE_CHAIN = 0x512512; uint256 constant SEPOLIA = 11155111; - uint256 constant DEST_CHAIN = 11155111; - - function setUp() public override { - super.setUp(); - - origin = new SampleOrigin(); - cb = new SampleCallback(address(proxy)); - - uint256 receivedTopic = uint256(keccak256("Received(address,address,uint256)")); - rc = new SampleReactiveContract( - address(sys), SEPOLIA, DEST_CHAIN, address(origin), receivedTopic, address(cb) - ); - - // Register origin on Sepolia - registerChain(address(origin), SEPOLIA); - } - - function testAutoDetectChainId() public { - // Use the 2-arg overload — chain ID is resolved from registry - CallbackResult[] memory results = triggerAndReactWithValue( - address(origin), - abi.encodeWithSignature("deposit()"), - 0.002 ether - ); - - assertCallbackCount(results, 1); - assertCallbackSuccess(results, 0); - assertEq(cb.callbackCount(), 1); - } - - function testAutoDetectMatchesExplicit() public { - // Auto-detect should produce the same results as explicit chain ID - CallbackResult[] memory autoResults = triggerAndReactWithValue( - address(origin), - abi.encodeWithSignature("deposit()"), - 0.002 ether - ); - - // Reset state for comparison - // (can't easily reset, so just verify the auto-detect worked) - assertCallbackCount(autoResults, 1); - assertEq(autoResults[0].target, address(cb)); + uint256 constant BASE = 8453; + bytes32 constant RECEIVED = keccak256("Received(address,address,uint256)"); + + SampleOrigin sepoliaOrigin; + SampleReactive reactive; + SampleCallback callbackContract; + + function setUp() public { + _reactiveSetUp(REACTIVE_CHAIN); + address proxy = _registerChain(SEPOLIA); + _registerChain(BASE); + + sepoliaOrigin = new SampleOrigin(); + // Reactive listens for Received from sepoliaOrigin specifically, on SEPOLIA. + reactive = new SampleReactive(SEPOLIA, address(sepoliaOrigin), uint256(RECEIVED), SEPOLIA, 0.001 ether); + callbackContract = new SampleCallback(proxy, address(reactive)); + reactive.setCallback(address(callbackContract)); + + _registerContract(REACTIVE_CHAIN, address(reactive)); + _registerContract(SEPOLIA, address(sepoliaOrigin)); + _registerContract(SEPOLIA, address(callbackContract)); } - function testUnregisteredOriginReverts() public { - address unregistered = makeAddr("unregistered"); + function test_registeredEmitterResolvesToItsChainAndFires() public { + _switchToChain(SEPOLIA); + sepoliaOrigin.emitReceived(0.002 ether); + _switchToChain(REACTIVE_CHAIN); - vm.expectRevert("ReactiveTest: origin not registered in chain registry"); - this.externalTriggerAutoDetect(unregistered); + CallbackResult[] memory r = _react(FeeMode.Subsidized); + assertEq(r.length, 1, "registered SEPOLIA emitter triggered the reactive contract"); } - // Helper to test revert (vm.expectRevert needs external call) - function externalTriggerAutoDetect(address target) external { - triggerAndReact(target, abi.encodeWithSignature("deposit()")); - } -} - -/// @notice Tests chain registry with multi-step full-cycle simulation. -contract ChainRegistryMultiStepTest is ReactiveTest { - MiniOrigin origin; - MiniBridge bridge; - MiniReactive rc; - - uint256 constant ORIGIN_CHAIN = 11155111; - uint256 constant DEST_CHAIN = 42161; - - function setUp() public override { - super.setUp(); - - origin = new MiniOrigin(); - bridge = new MiniBridge(address(proxy)); - rc = new MiniReactive( - address(sys), ORIGIN_CHAIN, DEST_CHAIN, reactiveChainId, address(origin), address(bridge) - ); + function test_unregisteredEmitterIsIgnored() public { + // An identical event from an UNREGISTERED contract must be discarded. + SampleOrigin rogue = new SampleOrigin(); + _switchToChain(SEPOLIA); + rogue.emitReceived(0.002 ether); + _switchToChain(REACTIVE_CHAIN); - // Register both contracts with their logical chains - registerChain(address(origin), ORIGIN_CHAIN); - registerChain(address(bridge), DEST_CHAIN); - registerChain(address(rc), reactiveChainId); + CallbackResult[] memory r = _react(FeeMode.Subsidized); + assertEq(r.length, 0, "unregistered emitter discarded"); + assertEq(reactive.reactCount(), 0, "reactive never triggered"); } - function testFullCycleWithAutoDetect() public { - // Use 3-arg overload (no explicit chainId) - CallbackResult[] memory results = triggerFullCycle( - address(origin), - abi.encodeWithSignature("deposit()"), - 10 - ); + function test_sameContractDifferentRegisteredChainDoesNotMatch() public { + // A different origin registered on BASE emitting Received must not match a SEPOLIA-scoped sub. + SampleOrigin baseOrigin = new SampleOrigin(); + _registerContract(BASE, address(baseOrigin)); - assertCallbackCount(results, 2); - assertEq(rc.deliveryCount(), 1); - } - - function testFullCycleWithValueAutoDetect() public { - deal(address(this), 1 ether); - CallbackResult[] memory results = triggerFullCycleWithValue( - address(origin), - abi.encodeWithSignature("deposit()"), - 0.05 ether, - 10 - ); - - assertCallbackCount(results, 2); - assertEq(rc.deliveryCount(), 1); - assertEq(rc.deliveredAmount(), 0.05 ether); - } - - function testResolveChainIdFallback() public { - // Unregistered address returns fallback - address unknown = makeAddr("unknown"); - assertEq(resolveChainId(unknown, 999), 999); - } + _switchToChain(BASE); + baseOrigin.emitReceived(0.002 ether); + _switchToChain(REACTIVE_CHAIN); - function testResolveChainIdRegistered() public view { - assertEq(resolveChainId(address(origin), 0), ORIGIN_CHAIN); - assertEq(resolveChainId(address(bridge), 0), DEST_CHAIN); - assertEq(resolveChainId(address(rc), 0), reactiveChainId); + CallbackResult[] memory r = _react(FeeMode.Subsidized); + assertEq(r.length, 0, "BASE emitter does not satisfy a SEPOLIA + specific-contract sub"); + assertEq(reactive.reactCount(), 0); } } diff --git a/test/CronDemo.t.sol b/test/CronDemo.t.sol index 91df483..25cd752 100644 --- a/test/CronDemo.t.sol +++ b/test/CronDemo.t.sol @@ -1,55 +1,109 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; -import {ReactiveTest} from "../src/base/ReactiveTest.sol"; -import {CallbackResult, CronType} from "../src/interfaces/IReactiveInterfaces.sol"; -import {ReactiveConstants} from "../src/constants/ReactiveConstants.sol"; -import {SampleCronContract} from "./mocks/SampleCronContract.sol"; -import {SampleCallback} from "./mocks/SampleCallback.sol"; +pragma solidity ^0.8.29; +import { ReactiveTest, FeeMode } from "../src/ReactiveTest.sol"; +import { OmniConstants } from "../src/OmniConstants.sol"; +import { SampleCron } from "./mocks/SampleCron.sol"; + +/// @notice §10 CronDemo: `_emitCron(blockNumber_)` drives cron-subscribed reactive contracts through +/// the same `_react` pipeline. `Cron1` fires every block; `CronN` only on modulo boundaries (§6.7). contract CronDemoTest is ReactiveTest { - SampleCronContract rc; - SampleCallback cb; + uint256 constant RC = 0x512512; - uint256 constant DEST_CHAIN = 11155111; + SampleCron cron1; + SampleCron cron10; + SampleCron cron100; + SampleCron cron1000; + SampleCron cron10000; - function setUp() public override { - super.setUp(); + function setUp() public { + _reactiveSetUp(RC); + cron1 = new SampleCron(RC, OmniConstants.LEGACY_PROXY_ADDR, uint256(OmniConstants.CRON_TOPIC_1)); + cron10 = new SampleCron(RC, OmniConstants.LEGACY_PROXY_ADDR, uint256(OmniConstants.CRON_TOPIC_10)); + cron100 = new SampleCron(RC, OmniConstants.LEGACY_PROXY_ADDR, uint256(OmniConstants.CRON_TOPIC_100)); + cron1000 = new SampleCron(RC, OmniConstants.LEGACY_PROXY_ADDR, uint256(OmniConstants.CRON_TOPIC_1000)); + cron10000 = new SampleCron(RC, OmniConstants.LEGACY_PROXY_ADDR, uint256(OmniConstants.CRON_TOPIC_10000)); + _registerContract(RC, address(cron1)); + _registerContract(RC, address(cron10)); + _registerContract(RC, address(cron100)); + _registerContract(RC, address(cron1000)); + _registerContract(RC, address(cron10000)); + } - cb = new SampleCallback(address(proxy)); + function test_cron1FiresEveryBlockOnly() public { + _emitCron(7); + _react(FeeMode.Subsidized); + assertEq(cron1.tickCount(), 1, "Cron1 fires at block 7"); + assertEq(cron1.lastBlock(), 7, "block number carried in topic1"); + assertEq(cron10.tickCount(), 0, "Cron10 does not fire (7 % 10 != 0)"); + assertEq(cron100.tickCount(), 0, "Cron100 does not fire"); - rc = new SampleCronContract( - address(sys), - ReactiveConstants.CRON_TOPIC_1, - DEST_CHAIN, - address(cb) - ); + _emitCron(8); + _react(FeeMode.Subsidized); + assertEq(cron1.tickCount(), 2, "Cron1 fires again at block 8"); } - function testCronTriggersCallback() public { - CallbackResult[] memory results = triggerCron(CronType.Cron1); + function test_cron10FiresOnMultiplesOf10() public { + _emitCron(20); + _react(FeeMode.Subsidized); + assertEq(cron1.tickCount(), 1, "Cron1 always"); + assertEq(cron10.tickCount(), 1, "Cron10 fires at 20"); + assertEq(cron10.lastBlock(), 20); + assertEq(cron100.tickCount(), 0, "Cron100 does not fire (20 % 100 != 0)"); + } - assertCallbackCount(results, 1); - assertCallbackSuccess(results, 0); - assertEq(rc.lastCronBlock(), block.number); + function test_cronCascadeAtBlock100() public { + // At block 100: Cron1, Cron10, and Cron100 all fire. + _emitCron(100); + _react(FeeMode.Subsidized); + assertEq(cron1.tickCount(), 1, "Cron1 at 100"); + assertEq(cron10.tickCount(), 1, "Cron10 at 100 (100 % 10 == 0)"); + assertEq(cron100.tickCount(), 1, "Cron100 at 100 (100 % 100 == 0)"); + assertEq(cron100.lastBlock(), 100); } - function testCronPauseResume() public { - rc.pause(); - CallbackResult[] memory results = triggerCron(CronType.Cron1); - assertNoCallbacks(results); + function test_cronMarkerIsNotCapturedAsUserEvent() public { + _emitCron(10); + _react(FeeMode.Subsidized); + // The internal marker must not surface as a user event... + _assertNotEmitted(address(this), OmniConstants.FAKE_CRON_TOPIC); + assertEq(_countEmitted(address(this), OmniConstants.FAKE_CRON_TOPIC), 0); + // ...but the cron ticks still happened. + assertEq(cron1.tickCount(), 1); + assertEq(cron10.tickCount(), 1); + } - rc.resume(); - results = triggerCron(CronType.Cron1); - assertCallbackCount(results, 1); + function test_multipleCronsAcrossBlocks() public { + for (uint256 b = 1; b <= 10; ++b) { + _emitCron(b); + _react(FeeMode.Subsidized); + } + assertEq(cron1.tickCount(), 10, "Cron1 every block 1..10"); + assertEq(cron10.tickCount(), 1, "Cron10 only at block 10"); + assertEq(cron100.tickCount(), 0, "Cron100 never in 1..10"); } - function testAdvanceAndTriggerCron() public { - uint256 startBlock = block.number; - CallbackResult[] memory results = advanceAndTriggerCron(10, CronType.Cron1); + function test_fullCascadeAtBlock10000() public { + // At block 10000 all five levels fire (10000 % 1000 == 0 and % 10000 == 0). + _emitCron(10000); + _react(FeeMode.Subsidized); + assertEq(cron1.tickCount(), 1, "Cron1"); + assertEq(cron10.tickCount(), 1, "Cron10"); + assertEq(cron100.tickCount(), 1, "Cron100"); + assertEq(cron1000.tickCount(), 1, "Cron1000"); + assertEq(cron10000.tickCount(), 1, "Cron10000"); + assertEq(cron10000.lastBlock(), 10000); + } - assertCallbackCount(results, 1); - assertEq(block.number, startBlock + 10); - assertEq(rc.lastCronBlock(), startBlock + 10); + function test_blockZeroFiresFullCascade() public { + // 0 % N == 0 for every N, so block 0 fires all five levels (matches legacy `_cron(0)`). + _emitCron(0); + _react(FeeMode.Subsidized); + assertEq(cron1.tickCount(), 1, "Cron1 at block 0"); + assertEq(cron10.tickCount(), 1, "Cron10 at block 0"); + assertEq(cron100.tickCount(), 1, "Cron100 at block 0"); + assertEq(cron1000.tickCount(), 1, "Cron1000 at block 0"); + assertEq(cron10000.tickCount(), 1, "Cron10000 at block 0"); } } diff --git a/test/Environment.t.sol b/test/Environment.t.sol new file mode 100644 index 0000000..8eaf9aa --- /dev/null +++ b/test/Environment.t.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.29; + +import { Vm } from "forge-std/Vm.sol"; +import { ReactiveTest, FeeMode } from "../src/ReactiveTest.sol"; +import { OmniConstants } from "../src/OmniConstants.sol"; +import { AbstractReactive } from "@reactive/src/base/AbstractReactive.sol"; + +/// @dev Minimal reactive contract whose constructor subscribes via the real system contract. +contract SmokeReactive is AbstractReactive { + constructor(uint256 originChain_, address origin_, uint256 topic0_) { + SYSTEM.subscribe(originChain_, origin_, topic0_, REACTIVE_IGNORE, REACTIVE_IGNORE, REACTIVE_IGNORE); + } + + function react(LogRecord calldata) external onlySystem { } +} + +/// @notice Environment setup: the etched system contract accepts a reactive contract's constructor +/// `subscribe()`, the callback proxy deploys, and the reverts guard the reactive chain. +contract EnvironmentTest is ReactiveTest { + uint256 constant REACTIVE_CHAIN = 0x512512; + uint256 constant SEPOLIA = 11155111; + + function setUp() public { + _reactiveSetUp(REACTIVE_CHAIN); + } + + function test_systemContractEtched() public view { + assertGt(OmniConstants.SYSTEM_ADDR.code.length, 0, "system contract not etched"); + } + + function test_registerChainDeploysProxy() public { + address proxy = _registerChain(SEPOLIA); + assertGt(proxy.code.length, 0, "callback proxy has no code"); + assertEq(_getCallbackProxy(SEPOLIA), proxy, "proxy lookup mismatch"); + } + + /// @dev External wrappers so `vm.expectRevert` sees the revert at a deeper call depth. + function getCallbackProxyExt(uint256 chainId_) external view returns (address) { + return _getCallbackProxy(chainId_); + } + + function registerChainExt(uint256 chainId_) external returns (address) { + return _registerChain(chainId_); + } + + function test_getCallbackProxyRevertsForReactiveChain() public { + vm.expectRevert(bytes("ReactiveTest: chain not registered")); + this.getCallbackProxyExt(REACTIVE_CHAIN); + } + + function test_cannotRegisterReactiveChain() public { + vm.expectRevert(bytes("ReactiveTest: cannot register the reactive chain")); + this.registerChainExt(REACTIVE_CHAIN); + } + + function test_subscribeEmitsCapturedLog() public { + address origin = address(0xABCD); + bytes32 topic = keccak256("Received(address,address,uint256)"); + + // Deploying the reactive contract on the reactive chain: its constructor subscribes. + SmokeReactive rc = new SmokeReactive(SEPOLIA, origin, uint256(topic)); + _registerContract(REACTIVE_CHAIN, address(rc)); + + // Drain + capture through the sanctioned pipeline, then use the §6.8 helpers (no raw + // getRecordedLogs). The constructor's Subscribe log flows through `_react` into capturedLogs. + _react(FeeMode.Subsidized); + + (bool found, Vm.Log memory log) = _findEmitted(OmniConstants.SYSTEM_ADDR, OmniConstants.TOPIC_SUBSCRIBE); + assertTrue(found, "Subscribe log not captured"); + assertEq(log.topics.length, 4, "Subscribe has 4 topics"); + assertEq(address(uint160(uint256(log.topics[1]))), address(rc), "subscriber"); + assertEq(uint256(log.topics[2]), SEPOLIA, "chainId"); + assertEq(address(uint160(uint256(log.topics[3]))), origin, "listen contract"); + } +} diff --git a/test/EnvironmentGuards.t.sol b/test/EnvironmentGuards.t.sol new file mode 100644 index 0000000..c067725 --- /dev/null +++ b/test/EnvironmentGuards.t.sol @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.29; + +import { ReactiveTest, FeeMode } from "../src/ReactiveTest.sol"; +import { OmniConstants } from "../src/OmniConstants.sol"; + +/// @notice Exercises every guard/revert branch of the environment API (spec §4). This harness +/// deliberately does NOT auto-setup so the pre-setup and setup guards are reachable. +contract EnvironmentGuardsTest is ReactiveTest { + uint256 constant RC = 0x512512; + uint256 constant SEPOLIA = 11155111; + uint256 constant BASE = 8453; + + // External wrappers so `vm.expectRevert` sees reverts of `internal` methods at a deeper depth. + function setUpExt(uint256 c) external { + _reactiveSetUp(c); + } + + function registerChainExt(uint256 c) external returns (address) { + return _registerChain(c); + } + + function registerChainSendersExt(uint256 c, address[] memory s) external returns (address) { + return _registerChain(c, s); + } + + function registerContractExt(uint256 c, address a) external { + _registerContract(c, a); + } + + function switchExt(uint256 c) external { + _switchToChain(c); + } + + function getProxyExt(uint256 c) external view returns (address) { + return _getCallbackProxy(c); + } + + function reactExt(FeeMode m) external { + _react(m); + } + + function reactUntilExt(FeeMode m, uint256 n) external { + _reactUntilQuiescent(m, n); + } + + function emitCronExt(uint256 b) external { + _emitCron(b); + } + + // ---- lifecycle guard: the environment/cycle API is unusable before `_reactiveSetUp` ---- + + function test_registerChainBeforeSetupReverts() public { + vm.expectRevert(bytes("ReactiveTest: call _reactiveSetUp first")); + this.registerChainExt(SEPOLIA); + } + + function test_registerContractBeforeSetupReverts() public { + vm.expectRevert(bytes("ReactiveTest: call _reactiveSetUp first")); + this.registerContractExt(RC, address(0x1234)); + } + + function test_switchToChainBeforeSetupReverts() public { + vm.expectRevert(bytes("ReactiveTest: call _reactiveSetUp first")); + this.switchExt(RC); + } + + function test_reactBeforeSetupReverts() public { + vm.expectRevert(bytes("ReactiveTest: call _reactiveSetUp first")); + this.reactExt(FeeMode.Subsidized); + } + + function test_reactUntilQuiescentBeforeSetupReverts() public { + vm.expectRevert(bytes("ReactiveTest: call _reactiveSetUp first")); + this.reactUntilExt(FeeMode.Subsidized, 3); + } + + function test_emitCronBeforeSetupReverts() public { + vm.expectRevert(bytes("ReactiveTest: call _reactiveSetUp first")); + this.emitCronExt(10); + } + + function test_setupZeroChainReverts() public { + vm.expectRevert(bytes("ReactiveTest: reactive chainId must be non-zero")); + this.setUpExt(0); + } + + function test_doubleSetupReverts() public { + _reactiveSetUp(RC); + vm.expectRevert(bytes("ReactiveTest: already set up")); + this.setUpExt(RC); + } + + // ---- _registerChain ---- + + function test_registerChainZeroReverts() public { + _reactiveSetUp(RC); + vm.expectRevert(bytes("ReactiveTest: chainId must be non-zero")); + this.registerChainExt(0); + } + + function test_registerChainDuplicateReverts() public { + _reactiveSetUp(RC); + _registerChain(SEPOLIA); + vm.expectRevert(bytes("ReactiveTest: chain already registered")); + this.registerChainExt(SEPOLIA); + } + + function test_registerChainEmptySendersReverts() public { + _reactiveSetUp(RC); + address[] memory empty = new address[](0); + vm.expectRevert(bytes("ReactiveTest: need at least one callback sender")); + this.registerChainSendersExt(SEPOLIA, empty); + } + + function test_registerChainDefaultSender() public { + _reactiveSetUp(RC); + address p = _registerChain(SEPOLIA); + assertEq(_env.senderOf[SEPOLIA], OmniConstants.CALLBACK_SENDER, "default sender"); + assertEq(_env.callbackProxyOf[SEPOLIA], p, "proxy stored"); + } + + function test_registerChainCustomSendersUsesFirst() public { + _reactiveSetUp(RC); + address[] memory s = new address[](2); + s[0] = address(0x111); + s[1] = address(0x222); + _registerChain(BASE, s); + assertEq(_env.senderOf[BASE], address(0x111), "prank identity is senders[0]"); + } + + // ---- _registerContract ---- + + function test_registerContractUnknownChainReverts() public { + _reactiveSetUp(RC); + vm.expectRevert(bytes("ReactiveTest: unknown chain")); + this.registerContractExt(BASE, address(0x1234)); + } + + function test_registerContractZeroReverts() public { + _reactiveSetUp(RC); + vm.expectRevert(bytes("ReactiveTest: contract is the zero address")); + this.registerContractExt(RC, address(0)); + } + + function test_registerContractDuplicateReverts() public { + _reactiveSetUp(RC); + _registerChain(SEPOLIA); + address c = address(0x1234); + _registerContract(SEPOLIA, c); + vm.expectRevert(bytes("ReactiveTest: contract already registered")); + this.registerContractExt(SEPOLIA, c); + } + + function test_registerContractReactiveFlag() public { + _reactiveSetUp(RC); + _registerChain(SEPOLIA); + address rc = address(0xAAAA); + address origin = address(0xBBBB); + _registerContract(RC, rc); + _registerContract(SEPOLIA, origin); + assertTrue(_env.isReactiveContract[rc], "reactive-chain contract flagged reactive"); + assertFalse(_env.isReactiveContract[origin], "origin-chain contract not reactive"); + assertEq(_env.chainOfContract[origin], SEPOLIA, "origin chain recorded"); + assertEq(_env.chainOfContract[rc], RC, "reactive chain recorded"); + } + + // ---- _switchToChain ---- + + function test_switchUnknownReverts() public { + _reactiveSetUp(RC); + vm.expectRevert(bytes("ReactiveTest: unknown chain")); + this.switchExt(BASE); + } + + function test_switchSetsBlockChainId() public { + _reactiveSetUp(RC); + _registerChain(SEPOLIA); + _switchToChain(SEPOLIA); + assertEq(block.chainid, SEPOLIA, "switched to sepolia"); + _switchToChain(RC); + assertEq(block.chainid, RC, "switched back to reactive"); + } + + // ---- _getCallbackProxy ---- + + function test_getProxyUnknownChainReverts() public { + _reactiveSetUp(RC); + vm.expectRevert(bytes("ReactiveTest: chain not registered")); + this.getProxyExt(BASE); + } +} diff --git a/test/MultiStep.t.sol b/test/MultiStep.t.sol index 721fabf..82488e9 100644 --- a/test/MultiStep.t.sol +++ b/test/MultiStep.t.sol @@ -1,126 +1,92 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; -import {ReactiveTest} from "../src/base/ReactiveTest.sol"; -import {CallbackResult} from "../src/interfaces/IReactiveInterfaces.sol"; -import {ReactiveConstants} from "../src/constants/ReactiveConstants.sol"; -import {MiniOrigin, MiniBridge, MiniReactive} from "./mocks/SampleMultiStepContracts.sol"; +pragma solidity ^0.8.29; -/// @notice Tests multi-step reactive cycles where callback execution produces new events -/// that trigger further react() calls and callbacks. +import { ReactiveTest, FeeMode, CallbackResult } from "../src/ReactiveTest.sol"; +import { SampleOrigin } from "./mocks/SampleOrigin.sol"; +import { SampleReactive } from "./mocks/SampleReactive.sol"; +import { SampleCallback } from "./mocks/SampleCallback.sol"; +import { EmittingCallback } from "./mocks/EmittingCallback.sol"; + +/// @notice §10 MultiStep: a callback emits an event on a registered chain that triggers a SECOND +/// reactive contract, whose callback fires in turn. Verified with `_reactUntilQuiescent`. /// -/// Flow: Deposit → react() → Callback to Bridge → Confirmation event -/// → react() → self-Callback → deliver() +/// Chain of events: +/// origin.Received --(hop 1)--> reactive1 --> hop1(EmittingCallback) emits Step2 +/// hop1.Step2 --(hop 2)--> reactive2 --> hop2(SampleCallback) contract MultiStepTest is ReactiveTest { - MiniOrigin origin; - MiniBridge bridge; - MiniReactive rc; - - uint256 constant ORIGIN_CHAIN = 11155111; // Sepolia - uint256 constant DEST_CHAIN = 42161; // Arbitrum - - function setUp() public override { - super.setUp(); - - origin = new MiniOrigin(); - bridge = new MiniBridge(address(proxy)); - - rc = new MiniReactive( - address(sys), - ORIGIN_CHAIN, - DEST_CHAIN, - reactiveChainId, - address(origin), - address(bridge) - ); + uint256 constant RC = 0x512512; + uint256 constant SEPOLIA = 11155111; + bytes32 constant RECEIVED = keccak256("Received(address,address,uint256)"); + bytes32 constant STEP2 = keccak256("Step2(uint256)"); + + SampleOrigin origin; + SampleReactive reactive1; + EmittingCallback hop1; + SampleReactive reactive2; + SampleCallback hop2; + + function setUp() public { + _reactiveSetUp(RC); + address proxy = _registerChain(SEPOLIA); + + origin = new SampleOrigin(); + + // hop 1: origin.Received -> reactive1 -> hop1 (which emits Step2 on delivery). + reactive1 = new SampleReactive(SEPOLIA, address(origin), uint256(RECEIVED), SEPOLIA, 0.001 ether); + hop1 = new EmittingCallback(proxy, address(reactive1)); + reactive1.setCallback(address(hop1)); + + // hop 2: hop1.Step2 -> reactive2 -> hop2. threshold 0 so reactive2 always requests a callback. + reactive2 = new SampleReactive(SEPOLIA, address(hop1), uint256(STEP2), SEPOLIA, 0); + hop2 = new SampleCallback(proxy, address(reactive2)); + reactive2.setCallback(address(hop2)); + + _registerContract(RC, address(reactive1)); + _registerContract(RC, address(reactive2)); + _registerContract(SEPOLIA, address(origin)); + _registerContract(SEPOLIA, address(hop1)); + _registerContract(SEPOLIA, address(hop2)); } - function testSingleStepOnlyGetsFirstHop() public { - // Single-step should only produce the first callback (to bridge) - deal(address(this), 1 ether); - CallbackResult[] memory results = triggerAndReactWithValue( - address(origin), - abi.encodeWithSignature("deposit()"), - 0.1 ether, - ORIGIN_CHAIN - ); - - // Only the first hop: Callback to bridge - assertCallbackCount(results, 1); - assertCallbackEmitted(results, address(bridge)); - assertCallbackSuccess(results, 0); - - // Bridge received the callback - assertEq(bridge.confirmationCount(), 1); - - // But delivery did NOT happen (needs second hop) - assertEq(rc.deliveryCount(), 0); + function test_twoHopCascade() public { + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + _switchToChain(RC); + + CallbackResult[] memory r = _reactUntilQuiescent(FeeMode.Subsidized, 5); + + assertEq(r.length, 2, "two callbacks across the two hops"); + assertTrue(r[0].success, "hop 1 callback ok"); + assertTrue(r[1].success, "hop 2 callback ok"); + assertEq(hop1.count(), 1, "hop 1 recipient invoked once"); + assertEq(hop2.count(), 1, "hop 2 recipient invoked once"); + assertEq(hop2.lastSender(), address(reactive2), "hop 2 authed by reactive2"); + // Step2 was emitted once and must be captured exactly once (double-capture regression guard). + assertEq(_countEmitted(address(hop1), STEP2), 1, "Step2 captured exactly once"); } - function testFullCycleCompletesAllHops() public { - // Full-cycle should process both hops - deal(address(this), 1 ether); - CallbackResult[] memory results = triggerFullCycleWithValue( - address(origin), - abi.encodeWithSignature("deposit()"), - 0.1 ether, - ORIGIN_CHAIN, - 10 // max iterations - ); - - // Both callbacks should have been produced - assertGt(results.length, 1, "Expected multiple callbacks"); - assertCallbackEmitted(results, address(bridge)); - assertCallbackEmitted(results, address(rc)); - - // Bridge received the first callback - assertEq(bridge.confirmationCount(), 1); - - // Delivery completed (second hop via self-callback) - assertEq(rc.deliveryCount(), 1); - assertEq(rc.deliveredAmount(), 0.1 ether); - } + function test_singleReactDoesOnlyHopOne() public { + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + _switchToChain(RC); - function testFullCycleStopsWhenQuiescent() public { - deal(address(this), 1 ether); - CallbackResult[] memory results = triggerFullCycleWithValue( - address(origin), - abi.encodeWithSignature("deposit()"), - 0.1 ether, - ORIGIN_CHAIN, - 100 // high max iterations — should still stop early - ); - - // Exactly 2 callbacks: one to bridge, one self-callback for delivery - assertCallbackCount(results, 2); - } + CallbackResult[] memory r = _react(FeeMode.Subsidized); - function testFullCycleAllCallbacksSucceed() public { - deal(address(this), 1 ether); - CallbackResult[] memory results = triggerFullCycleWithValue( - address(origin), - abi.encodeWithSignature("deposit()"), - 0.1 ether, - ORIGIN_CHAIN, - 10 - ); - - for (uint256 i = 0; i < results.length; i++) { - assertCallbackSuccess(results, i); - } + assertEq(r.length, 1, "a single _react performs hop 1 only"); + assertEq(hop1.count(), 1, "hop 1 delivered"); + assertEq(hop2.count(), 0, "hop 2 waits for the next _react (its Step2 is carried over)"); } - function testFullCycleWithZeroValue() public { - // Zero-value deposit should still trigger the full cycle - CallbackResult[] memory results = triggerFullCycle( - address(origin), - abi.encodeWithSignature("deposit()"), - ORIGIN_CHAIN, - 10 - ); - - assertCallbackCount(results, 2); - assertEq(rc.deliveryCount(), 1); - assertEq(rc.deliveredAmount(), 0); + function test_secondReactCompletesTheCascade() public { + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + _switchToChain(RC); + + _react(FeeMode.Subsidized); // hop 1 (carries Step2) + CallbackResult[] memory r2 = _react(FeeMode.Subsidized); // hop 2 processes the carried Step2 + + assertEq(r2.length, 1, "second _react delivers hop 2"); + assertEq(hop2.count(), 1, "hop 2 completed"); } } diff --git a/test/ReactEngine.t.sol b/test/ReactEngine.t.sol new file mode 100644 index 0000000..4392a23 --- /dev/null +++ b/test/ReactEngine.t.sol @@ -0,0 +1,383 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.29; + +import { ReactiveTest, FeeMode, CallbackResult } from "../src/ReactiveTest.sol"; +import { Vm } from "forge-std/Vm.sol"; +import { OmniConstants } from "../src/OmniConstants.sol"; +import { SystemContract } from "@system/omni/SystemContract.sol"; +import { AbstractReactive } from "@reactive/src/base/AbstractReactive.sol"; +import { ISystemContract } from "@reactive/src/interfaces/ISystemContract.sol"; +import { SampleOrigin } from "./mocks/SampleOrigin.sol"; +import { SampleReactive } from "./mocks/SampleReactive.sol"; +import { SampleCallback } from "./mocks/SampleCallback.sol"; +import { EmittingCallback } from "./mocks/EmittingCallback.sol"; + +/// @dev Reactive contract that subscribes to a SECOND origin during its own `react()` — used to +/// verify that reactive-emitted subscriptions are deferred and never retroactive (§7.1). +contract ResubscribingReactive is AbstractReactive { + address public immutable secondOrigin; + uint256 public immutable topic0; + uint256 public reactCount; + + constructor(uint256 chain_, address firstOrigin_, uint256 topic0_, address secondOrigin_) { + secondOrigin = secondOrigin_; + topic0 = topic0_; + SYSTEM.subscribe(chain_, firstOrigin_, topic0_, REACTIVE_IGNORE, REACTIVE_IGNORE, REACTIVE_IGNORE); + } + + function react(LogRecord calldata log) external onlySystem { + ++reactCount; + SYSTEM.subscribe(log.chainId, secondOrigin, topic0, REACTIVE_IGNORE, REACTIVE_IGNORE, REACTIVE_IGNORE); + } +} + +/// @dev A callback recipient whose entry point always reverts — exercises the delivery-failure path +/// (proxy swallows it into `CallbackFailure`, so the result is `success == false`). +contract RevertingCallback { + function callback(address) external pure { + revert("callback boom"); + } +} + +/// @dev A reactive contract whose `react()` reverts — `trigger` swallows it into +/// `ReactiveContractReverted`, produces no callback, and `_react` continues. +contract RevertingReactive is AbstractReactive { + uint256 public reactAttempts; + + constructor(uint256 chain_, address origin_, uint256 topic0_) { + SYSTEM.subscribe(chain_, origin_, topic0_, REACTIVE_IGNORE, REACTIVE_IGNORE, REACTIVE_IGNORE); + } + + function react(LogRecord calldata) external onlySystem { + ++reactAttempts; // work happens, then the handler fails (rolled back with the revert) + revert("react boom"); + } +} + +/// @dev A reactive contract that requests a callback and THEN reverts — the callback request is +/// rolled back on-chain, so the engine must deliver 0 callbacks (§7.3, reverted-frame handling). +contract CallbackThenRevertReactive is AbstractReactive { + uint256 public immutable destChain; + address public callbackContract; + + constructor(uint256 chain_, address origin_, uint256 topic0_, uint256 destChain_) { + destChain = destChain_; + SYSTEM.subscribe(chain_, origin_, topic0_, REACTIVE_IGNORE, REACTIVE_IGNORE, REACTIVE_IGNORE); + } + + function setCallback(address callbackContract_) external { + callbackContract = callbackContract_; + } + + function react(LogRecord calldata) external onlySystem { + bytes memory payload = abi.encodeWithSignature("callback(address)", address(0)); + SYSTEM.requestCallbackV_1_0( + ISystemContract.CallbackConfiguration_V_1_0(destChain, callbackContract, 1e6, payload) + ); + revert("changed my mind"); // rolls back the callback request above + } +} + +/// @notice Engine-level edge and regression tests for the §7 pipeline: fee modes, deferred +/// subscriptions (§7.1), delivery-failure / discard paths (§7.3), reverted-react handling, +/// and quiescence. The user-facing scenarios live in the *Demo/*Registry/*Callback files. +contract ReactEngineTest is ReactiveTest { + uint256 constant RC = 0x512512; + uint256 constant SEPOLIA = 11155111; + uint256 constant BASE = 8453; + bytes32 constant RECEIVED = keccak256("Received(address,address,uint256)"); + bytes32 constant REACTIVE_REVERTED = 0xe2ec81387b5c5afbb48878517c28d6f4edf6cb496a562e787d18ea8fca1da316; + + SampleOrigin origin; + SampleReactive reactive; + SampleCallback callbackContract; + address proxy; + + function setUp() public { + _reactiveSetUp(RC); + proxy = _registerChain(SEPOLIA); + + origin = new SampleOrigin(); + reactive = new SampleReactive(SEPOLIA, address(origin), uint256(RECEIVED), SEPOLIA, 0.001 ether); + callbackContract = new SampleCallback(proxy, address(reactive)); + reactive.setCallback(address(callbackContract)); + + _registerContract(RC, address(reactive)); + _registerContract(SEPOLIA, address(origin)); + _registerContract(SEPOLIA, address(callbackContract)); + } + + // ---- fee modes (§6.6) ---- + + function test_subsidizedNeverChargesButMeteredDoes() public { + SystemContract sys = SystemContract(payable(OmniConstants.SYSTEM_ADDR)); + + // Subsidized neutralizes charging even with a gas price set. + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + _switchToChain(RC); + vm.txGasPrice(1 gwei); + _react(FeeMode.Subsidized); + assertEq(sys.debts(address(reactive)), 0, "subsidized never charges the reactive contract"); + + // Metered charges the unfunded reactive contract -> debt + BlacklistContract. + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + _switchToChain(RC); + vm.txGasPrice(1 gwei); + _react(FeeMode.Metered); + assertGt(sys.debts(address(reactive)), 0, "metered charges the unfunded reactive contract"); + _assertEmitted(OmniConstants.SYSTEM_ADDR, OmniConstants.TOPIC_BLACKLIST); + } + + function test_subsidizedRestoresGasPriceSoLaterMeteredStillCharges() public { + SystemContract sys = SystemContract(payable(OmniConstants.SYSTEM_ADDR)); + + // Set the gas price ONCE. A Subsidized cycle zeroes tx.gasprice/basefee internally; it must + // restore them on exit so the following Metered cycle still sees the price the test set. + vm.txGasPrice(1 gwei); + + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + _switchToChain(RC); + _react(FeeMode.Subsidized); + assertEq(sys.debts(address(reactive)), 0, "subsidized charged nothing"); + + // Deliberately NO re-set of the gas price — Metered must still charge (price not leaked to 0). + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + _switchToChain(RC); + _react(FeeMode.Metered); + assertGt(sys.debts(address(reactive)), 0, "metered still charges: subsidized restored the gas price"); + } + + function test_fundedReactiveNotChargedIntoDebtUnderMetered() public { + // `_fund` gives the reactive a balance, so a Metered charge is paid (via its `pay`) instead + // of accruing debt — and the contract is not blacklisted. + SystemContract sys = SystemContract(payable(OmniConstants.SYSTEM_ADDR)); + _fund(address(reactive), 1 ether); + + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + _switchToChain(RC); + vm.txGasPrice(1 gwei); + _react(FeeMode.Metered); + + assertEq(sys.debts(address(reactive)), 0, "funded reactive pays its charge, no debt"); + assertEq(reactive.reactCount(), 1, "reactive still reacted"); + } + + /// @dev Full debt lifecycle (§10 CallbackAuth: "exercise debt/blacklist/coverDebt flows"): + /// Metered charge -> debt + BlacklistContract (subs deactivated) -> cover the debt -> + /// WhitelistContract (subs reactivated) -> delivery resumes. + function test_meteredDebtBlacklistThenCoverDebtWhitelistsAndResumes() public { + SystemContract sys = SystemContract(payable(OmniConstants.SYSTEM_ADDR)); + + // Round 1 (Metered, unfunded): reactive charged -> debt + blacklist -> subs deactivated. + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + _switchToChain(RC); + vm.txGasPrice(1 gwei); + _react(FeeMode.Metered); + uint256 debt = sys.debts(address(reactive)); + assertGt(debt, 0, "reactive in debt"); + _assertEmitted(OmniConstants.SYSTEM_ADDR, OmniConstants.TOPIC_BLACKLIST); + assertEq(reactive.reactCount(), 1); + + // Cover the debt directly -> clears it and emits WhitelistContract(reactive). + sys.depositTo{ value: debt }(address(reactive)); + assertEq(sys.debts(address(reactive)), 0, "debt covered"); + + // Round 2 (Subsidized): the WhitelistContract is processed first (reactivating the subs), + // then the origin event triggers the now-active reactive again. + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + _switchToChain(RC); + _react(FeeMode.Subsidized); + assertEq(reactive.reactCount(), 2, "whitelisted reactive triggers again after coverDebt"); + } + + // ---- _reactUntilQuiescent ---- + + function test_reactUntilQuiescentTerminates() public { + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + _switchToChain(RC); + + CallbackResult[] memory r = _reactUntilQuiescent(FeeMode.Subsidized, 5); + assertEq(r.length, 1, "single cross-chain callback, then quiescent"); + assertTrue(r[0].success); + } + + // ---- deferred subscription changes (§7.1) ---- + + function test_deferredSubscriptionIsNotRetroactive() public { + SampleOrigin origin2 = new SampleOrigin(); + ResubscribingReactive rc = + new ResubscribingReactive(SEPOLIA, address(origin), uint256(RECEIVED), address(origin2)); + _registerContract(RC, address(rc)); + _registerContract(SEPOLIA, address(origin2)); + + // Batch 1: origin fires (triggers rc -> rc subscribes to origin2, DEFERRED), then origin2 + // fires in the SAME batch. The deferred subscription must NOT retroactively match origin2. + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + origin2.emitReceived(0.002 ether); + _switchToChain(RC); + _react(FeeMode.Subsidized); + assertEq(rc.reactCount(), 1, "only origin triggered rc in batch 1 (origin2 sub was deferred)"); + + // Batch 2: origin2 fires again -> now the (previously deferred) subscription matches. + _switchToChain(SEPOLIA); + origin2.emitReceived(0.002 ether); + _switchToChain(RC); + _react(FeeMode.Subsidized); + assertEq(rc.reactCount(), 2, "origin2 triggers rc after the deferred sub is applied"); + } + + // ---- delivery failure & discard paths (§7.3) ---- + + function test_callbackFailureYieldsUnsuccessfulResult() public { + RevertingCallback bad = new RevertingCallback(); + SampleReactive rc2 = new SampleReactive(SEPOLIA, address(origin), uint256(RECEIVED), SEPOLIA, 0.001 ether); + rc2.setCallback(address(bad)); + _registerContract(RC, address(rc2)); + _registerContract(SEPOLIA, address(bad)); + + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + _switchToChain(RC); + CallbackResult[] memory r = _react(FeeMode.Subsidized); + + assertEq(r.length, 2, "both callbacks attempted"); + bool sawFailure; + for (uint256 i = 0; i < r.length; ++i) { + if (r[i].recipient == address(bad)) { + assertFalse(r[i].success, "reverting recipient -> success == false"); + sawFailure = true; + } else if (r[i].recipient == address(callbackContract)) { + assertTrue(r[i].success, "healthy recipient still succeeds"); + } + } + assertTrue(sawFailure, "failure result present"); + _assertEmitted(proxy, OmniConstants.TOPIC_CALLBACK_FAILURE); + } + + function test_callbackToUnregisteredChainDiscarded() public { + SampleReactive rcBad = new SampleReactive(SEPOLIA, address(origin), uint256(RECEIVED), 999, 0.001 ether); + rcBad.setCallback(address(callbackContract)); + _registerContract(RC, address(rcBad)); + + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + _switchToChain(RC); + CallbackResult[] memory r = _react(FeeMode.Subsidized); + + assertEq(r.length, 1, "callback to unregistered chain discarded"); + assertEq(r[0].recipient, address(callbackContract)); + } + + function test_callbackToRecipientOnWrongChainDiscarded() public { + address baseProxy = _registerChain(BASE); + SampleCallback wrongChainCb = new SampleCallback(baseProxy, address(0)); + _registerContract(BASE, address(wrongChainCb)); + + SampleReactive rcWrong = new SampleReactive(SEPOLIA, address(origin), uint256(RECEIVED), SEPOLIA, 0.001 ether); + rcWrong.setCallback(address(wrongChainCb)); + _registerContract(RC, address(rcWrong)); + + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + _switchToChain(RC); + CallbackResult[] memory r = _react(FeeMode.Subsidized); + + assertEq(r.length, 1, "recipient registered on the wrong chain is discarded"); + assertEq(r[0].recipient, address(callbackContract)); + } + + // ---- reverted react() handling (§7.3) ---- + + function test_reactRevertIsSwallowedAndCycleContinues() public { + RevertingReactive rcRevert = new RevertingReactive(SEPOLIA, address(origin), uint256(RECEIVED)); + _registerContract(RC, address(rcRevert)); + + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + _switchToChain(RC); + CallbackResult[] memory r = _react(FeeMode.Subsidized); + + assertEq(r.length, 1, "reverting reactive produced no callback; the healthy one still did"); + assertEq(r[0].recipient, address(callbackContract)); + _assertEmitted(OmniConstants.SYSTEM_ADDR, REACTIVE_REVERTED); + } + + /// @dev A reverted react() in Metered mode is STILL charged after the inner catch, and the real + /// (not rolled-back) `BlacklistContract` from that charge must be captured and applied — it + /// lives in the suffix after `ReactiveContractReverted`, so the prefix-discard keeps it. + function test_meteredRevertingReactStillChargesAndBlacklists() public { + SampleOrigin origin2 = new SampleOrigin(); + RevertingReactive rcRevert = new RevertingReactive(SEPOLIA, address(origin2), uint256(RECEIVED)); + _registerContract(RC, address(rcRevert)); + _registerContract(SEPOLIA, address(origin2)); + + _switchToChain(SEPOLIA); + origin2.emitReceived(0.002 ether); // only rcRevert listens to origin2 + _switchToChain(RC); + vm.txGasPrice(1 gwei); + _react(FeeMode.Metered); + + SystemContract sys = SystemContract(payable(OmniConstants.SYSTEM_ADDR)); + assertGt(sys.debts(address(rcRevert)), 0, "reverting react is still charged in Metered mode"); + + // The genuine post-catch BlacklistContract(rcRevert) must be captured (was dropped before). + (bool found, Vm.Log memory log) = _findEmitted(OmniConstants.SYSTEM_ADDR, OmniConstants.TOPIC_BLACKLIST); + assertTrue(found, "post-catch BlacklistContract captured"); + assertEq( + address(uint160(uint256(log.topics[1]))), address(rcRevert), "blacklist targets the reverting reactive" + ); + } + + /// @dev A `react()` that requests a callback then reverts must yield 0 callbacks (the + /// CallbackRequest was rolled back on-chain, even though Foundry still recorded it). + function test_callbackRequestedThenReactRevertsProducesNoCallback() public { + SampleOrigin origin2 = new SampleOrigin(); + CallbackThenRevertReactive rc = + new CallbackThenRevertReactive(SEPOLIA, address(origin2), uint256(RECEIVED), SEPOLIA); + rc.setCallback(address(callbackContract)); + _registerContract(RC, address(rc)); + _registerContract(SEPOLIA, address(origin2)); + + _switchToChain(SEPOLIA); + origin2.emitReceived(0.002 ether); + _switchToChain(RC); + CallbackResult[] memory r = _react(FeeMode.Subsidized); + + assertEq(r.length, 0, "reverted react must not deliver a phantom callback"); + assertEq(callbackContract.count(), 0, "callback contract never invoked"); + _assertEmitted(OmniConstants.SYSTEM_ADDR, REACTIVE_REVERTED); + // The rolled-back CallbackRequest must NOT surface in the captured logs. + assertEq(_countEmitted(OmniConstants.SYSTEM_ADDR, OmniConstants.TOPIC_CALLBACK_REQUEST), 0); + } + + /// @dev A callback that emits an event drives a second `_reactUntilQuiescent` round; that + /// emission must be captured exactly once (not once per extra round). + function test_reactUntilQuiescentDoesNotDoubleCaptureCallbackEmissions() public { + SampleOrigin origin2 = new SampleOrigin(); + SampleReactive rc = new SampleReactive(SEPOLIA, address(origin2), uint256(RECEIVED), SEPOLIA, 0.001 ether); + EmittingCallback ecb = new EmittingCallback(proxy, address(rc)); + rc.setCallback(address(ecb)); + _registerContract(RC, address(rc)); + _registerContract(SEPOLIA, address(origin2)); + _registerContract(SEPOLIA, address(ecb)); + + _switchToChain(SEPOLIA); + origin2.emitReceived(0.002 ether); + _switchToChain(RC); + CallbackResult[] memory r = _reactUntilQuiescent(FeeMode.Subsidized, 5); + + assertEq(r.length, 1, "one callback delivered"); + assertTrue(r[0].success); + assertEq(_countEmitted(address(ecb), EmittingCallback.Step2.selector), 1, "Step2 captured exactly once"); + } +} diff --git a/test/SelfCallback.t.sol b/test/SelfCallback.t.sol index 2bbbcae..3ef7cd8 100644 --- a/test/SelfCallback.t.sol +++ b/test/SelfCallback.t.sol @@ -1,77 +1,55 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; -import {ReactiveTest} from "../src/base/ReactiveTest.sol"; -import {CallbackResult} from "../src/interfaces/IReactiveInterfaces.sol"; -import {ReactiveConstants} from "../src/constants/ReactiveConstants.sol"; -import {SampleOrigin} from "./mocks/SampleOrigin.sol"; -import {SampleSelfCallbackContract} from "./mocks/SampleSelfCallbackContract.sol"; +pragma solidity ^0.8.29; -/// @notice Tests same-chain (self) callbacks where react() emits Callback targeting -/// the reactive contract itself on the reactive chain. These callbacks must be -/// delivered via SERVICE_ADDR, not the proxy. +import { ReactiveTest, FeeMode, CallbackResult } from "../src/ReactiveTest.sol"; +import { SampleOrigin } from "./mocks/SampleOrigin.sol"; +import { SampleReactive } from "./mocks/SampleReactive.sol"; + +/// @notice §10 SelfCallback (removed feature → negative test): reactive-chain callbacks are +/// unsupported in Omni (no RVMs, §6.5). A `CallbackRequest` whose destination is the reactive +/// chain must make `_react` revert, surfacing the misuse rather than silently discarding it. contract SelfCallbackTest is ReactiveTest { - SampleOrigin origin; - SampleSelfCallbackContract rc; + uint256 constant REACTIVE_CHAIN = 0x512512; + uint256 constant SEPOLIA = 11155111; + bytes32 constant RECEIVED = keccak256("Received(address,address,uint256)"); - uint256 constant ORIGIN_CHAIN = 11155111; // Sepolia + SampleOrigin origin; + SampleReactive reactive; - function setUp() public override { - super.setUp(); + function setUp() public { + _reactiveSetUp(REACTIVE_CHAIN); + _registerChain(SEPOLIA); origin = new SampleOrigin(); + // A reactive contract that (mistakenly) targets the reactive chain as its callback destination. + reactive = new SampleReactive(SEPOLIA, address(origin), uint256(RECEIVED), REACTIVE_CHAIN, 0.001 ether); + reactive.setCallback(address(0xDEAD)); - uint256 receivedTopic = uint256(keccak256("Received(address,address,uint256)")); - - rc = new SampleSelfCallbackContract( - address(sys), - reactiveChainId, // reactive chain ID for self-callbacks - ORIGIN_CHAIN, - address(origin), - receivedTopic - ); + _registerContract(REACTIVE_CHAIN, address(reactive)); + _registerContract(SEPOLIA, address(origin)); } - function testSelfCallbackDeliveredViaServiceAddr() public { - // Trigger an event that causes react() to emit a self-callback - CallbackResult[] memory results = triggerAndReactWithValue( - address(origin), - abi.encodeWithSignature("deposit()"), - 0.01 ether, - ORIGIN_CHAIN - ); - - // Callback should succeed (delivered via SERVICE_ADDR, not proxy) - assertCallbackCount(results, 1); - assertCallbackSuccess(results, 0); - - // Verify the self-callback was actually executed - assertEq(rc.deliveryCount(), 1); - assertEq(rc.deliveredAmount(), 0.01 ether); + /// @dev External wrapper so `vm.expectRevert` observes the internal `_react` revert. + function reactExt(FeeMode mode_) external returns (CallbackResult[] memory) { + return _react(mode_); } - function testSelfCallbackRvmIdInjected() public { - triggerAndReactWithValue( - address(origin), - abi.encodeWithSignature("deposit()"), - 0.01 ether, - ORIGIN_CHAIN - ); + function test_reactiveChainCallbackReverts() public { + _switchToChain(SEPOLIA); + origin.emitReceived(0.002 ether); + _switchToChain(REACTIVE_CHAIN); - // RVM ID should be injected even for self-callbacks - assertEq(rc.deliveredRvmId(), rvmId); + vm.expectRevert(bytes("ReactiveTest: reactive-chain callbacks unsupported")); + this.reactExt(FeeMode.Subsidized); } - function testSelfCallbackTargetsReactiveChain() public { - CallbackResult[] memory results = triggerAndReactWithValue( - address(origin), - abi.encodeWithSignature("deposit()"), - 0.01 ether, - ORIGIN_CHAIN - ); + function test_getCallbackProxyForReactiveChainReverts() public { + vm.expectRevert(bytes("ReactiveTest: chain not registered")); + this.getReactiveProxy(); + } - // Callback chain ID should be the reactive chain - assertEq(results[0].chainId, reactiveChainId); - assertEq(results[0].target, address(rc)); + function getReactiveProxy() external view returns (address) { + return _getCallbackProxy(REACTIVE_CHAIN); } } diff --git a/test/SubscriptionFiltering.t.sol b/test/SubscriptionFiltering.t.sol index d8da076..274800b 100644 --- a/test/SubscriptionFiltering.t.sol +++ b/test/SubscriptionFiltering.t.sol @@ -1,185 +1,143 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; -import {ReactiveTest} from "../src/base/ReactiveTest.sol"; -import {MockSystemContract} from "../src/mock/MockSystemContract.sol"; -import {ReactiveConstants} from "../src/constants/ReactiveConstants.sol"; +pragma solidity ^0.8.29; +import { ReactiveTest, FeeMode } from "../src/ReactiveTest.sol"; +import { OmniConstants } from "../src/OmniConstants.sol"; +import { SampleOrigin } from "./mocks/SampleOrigin.sol"; +import { CountingReactive } from "./mocks/CountingReactive.sol"; + +/// @notice §10 SubscriptionFiltering: wildcard (chain/contract/topic) matching, de-dup (one trigger +/// per event despite multiple matching subs), and unsubscribe stopping delivery — all driven +/// end-to-end through the engine, not just the index unit tests. contract SubscriptionFilteringTest is ReactiveTest { - uint256 constant RI = 0xa65f96fc951c35ead38878e0f0b7a3c744a6f5ccc1476b313353ce31712313ad; - uint256 constant CHAIN_A = 1; - uint256 constant CHAIN_B = 2; - - address contractA; - address contractB; - address subscriber1; - address subscriber2; - - function setUp() public override { - super.setUp(); - contractA = makeAddr("contractA"); - contractB = makeAddr("contractB"); - subscriber1 = makeAddr("subscriber1"); - subscriber2 = makeAddr("subscriber2"); + uint256 constant RC = 0x512512; + uint256 constant SEPOLIA = 11155111; + uint256 constant BASE = 8453; + uint256 constant IGNORE = OmniConstants.REACTIVE_IGNORE; + uint256 constant RECEIVED = uint256(keccak256("Received(address,address,uint256)")); + + SampleOrigin originA; // SEPOLIA + SampleOrigin originB; // SEPOLIA + SampleOrigin originC; // BASE + CountingReactive rc; + + function setUp() public { + _reactiveSetUp(RC); + _registerChain(SEPOLIA); + _registerChain(BASE); + + originA = new SampleOrigin(); + originB = new SampleOrigin(); + originC = new SampleOrigin(); + rc = new CountingReactive(); + + _registerContract(RC, address(rc)); + _registerContract(SEPOLIA, address(originA)); + _registerContract(SEPOLIA, address(originB)); + _registerContract(BASE, address(originC)); } - function testExactMatch() public { - uint256 topic = uint256(keccak256("Transfer(address,address,uint256)")); + function test_contractWildcardMatchesAnyEmitterOnChain() public { + // Any contract on SEPOLIA emitting Received. + rc.sub(SEPOLIA, address(0), RECEIVED, IGNORE, IGNORE, IGNORE); - vm.prank(subscriber1); - sys.subscribe(CHAIN_A, contractA, topic, RI, RI, RI); + _switchToChain(SEPOLIA); + originA.emitReceived(1); + originB.emitReceived(1); + _switchToChain(RC); + _react(FeeMode.Subsidized); - address[] memory matches = sys.getMatchingSubscribers( - CHAIN_A, contractA, topic, 0, 0, 0 - ); - assertEq(matches.length, 1); - assertEq(matches[0], subscriber1); + assertEq(rc.reactCount(), 2, "wildcard contract matched both SEPOLIA emitters"); } - function testWildcardChainId() public { - uint256 topic = uint256(keccak256("Transfer(address,address,uint256)")); - - // Subscribe with chainId=0 (wildcard) - vm.prank(subscriber1); - sys.subscribe(0, contractA, topic, RI, RI, RI); + function test_contractWildcardDoesNotCrossChain() public { + rc.sub(SEPOLIA, address(0), RECEIVED, IGNORE, IGNORE, IGNORE); - // Should match any chain - address[] memory matchesA = sys.getMatchingSubscribers( - CHAIN_A, contractA, topic, 0, 0, 0 - ); - assertEq(matchesA.length, 1); + _switchToChain(BASE); + originC.emitReceived(1); // BASE emitter, but the sub is pinned to SEPOLIA + _switchToChain(RC); + _react(FeeMode.Subsidized); - address[] memory matchesB = sys.getMatchingSubscribers( - CHAIN_B, contractA, topic, 0, 0, 0 - ); - assertEq(matchesB.length, 1); + assertEq(rc.reactCount(), 0, "SEPOLIA-scoped sub ignores a BASE event"); } - function testWildcardContract() public { - uint256 topic = uint256(keccak256("Transfer(address,address,uint256)")); + function test_chainWildcardMatchesAnyChain() public { + // chainId 0 + contract 0 = any chain, any contract, with this topic0. + rc.sub(0, address(0), RECEIVED, IGNORE, IGNORE, IGNORE); - // Subscribe with contract=address(0) (wildcard) - vm.prank(subscriber1); - sys.subscribe(CHAIN_A, address(0), topic, RI, RI, RI); + _switchToChain(SEPOLIA); + originA.emitReceived(1); + _switchToChain(BASE); + originC.emitReceived(1); + _switchToChain(RC); + _react(FeeMode.Subsidized); - // Should match any contract on chain A - address[] memory matchesA = sys.getMatchingSubscribers( - CHAIN_A, contractA, topic, 0, 0, 0 - ); - assertEq(matchesA.length, 1); - - address[] memory matchesB = sys.getMatchingSubscribers( - CHAIN_A, contractB, topic, 0, 0, 0 - ); - assertEq(matchesB.length, 1); + assertEq(rc.reactCount(), 2, "chain-wildcard matched SEPOLIA and BASE"); } - function testWildcardTopics() public { - // Subscribe with all topics = REACTIVE_IGNORE - vm.prank(subscriber1); - sys.subscribe(CHAIN_A, contractA, RI, RI, RI, RI); - - // Should match any event from contractA on chain A - uint256 topicTransfer = uint256(keccak256("Transfer(address,address,uint256)")); - uint256 topicApproval = uint256(keccak256("Approval(address,address,uint256)")); + function test_dedupOneTriggerPerEventDespiteMultipleMatchingSubs() public { + // Two overlapping subscriptions that both match originA.Received. + rc.sub(SEPOLIA, address(originA), RECEIVED, IGNORE, IGNORE, IGNORE); + rc.sub(SEPOLIA, address(0), RECEIVED, IGNORE, IGNORE, IGNORE); - address[] memory matches1 = sys.getMatchingSubscribers( - CHAIN_A, contractA, topicTransfer, 0, 0, 0 - ); - assertEq(matches1.length, 1); + _switchToChain(SEPOLIA); + originA.emitReceived(1); + _switchToChain(RC); + _react(FeeMode.Subsidized); - address[] memory matches2 = sys.getMatchingSubscribers( - CHAIN_A, contractA, topicApproval, 0, 0, 0 - ); - assertEq(matches2.length, 1); + assertEq(rc.reactCount(), 1, "subscriber triggered at most once per event"); } - function testNoMatchDifferentChain() public { - uint256 topic = uint256(keccak256("Transfer(address,address,uint256)")); - - vm.prank(subscriber1); - sys.subscribe(CHAIN_A, contractA, topic, RI, RI, RI); - - // Different chain should not match - address[] memory matches = sys.getMatchingSubscribers( - CHAIN_B, contractA, topic, 0, 0, 0 - ); - assertEq(matches.length, 0); - } + function test_unsubscribeStopsDelivery() public { + rc.sub(SEPOLIA, address(originA), RECEIVED, IGNORE, IGNORE, IGNORE); - function testNoMatchDifferentContract() public { - uint256 topic = uint256(keccak256("Transfer(address,address,uint256)")); + _switchToChain(SEPOLIA); + originA.emitReceived(1); + _switchToChain(RC); + _react(FeeMode.Subsidized); + assertEq(rc.reactCount(), 1, "delivered before unsubscribe"); - vm.prank(subscriber1); - sys.subscribe(CHAIN_A, contractA, topic, RI, RI, RI); + // Unsubscribe with the same criteria, then a later event must not trigger. + rc.unsub(SEPOLIA, address(originA), RECEIVED, IGNORE, IGNORE, IGNORE); - // Different contract should not match - address[] memory matches = sys.getMatchingSubscribers( - CHAIN_A, contractB, topic, 0, 0, 0 - ); - assertEq(matches.length, 0); + _switchToChain(SEPOLIA); + originA.emitReceived(1); + _switchToChain(RC); + _react(FeeMode.Subsidized); + assertEq(rc.reactCount(), 1, "no delivery after unsubscribe"); } - function testMultipleSubscribers() public { - uint256 topic = uint256(keccak256("Transfer(address,address,uint256)")); + function test_subscribeFromNonReactiveContractIsIgnored() public { + // A contract registered on a NON-reactive chain is not flagged reactive, so the engine + // ignores its `Subscribe` — it never enters the index and is never triggered, even though it + // issues the exact same subscription a reactive contract would. + CountingReactive notReactive = new CountingReactive(); + _registerContract(SEPOLIA, address(notReactive)); // non-reactive chain - vm.prank(subscriber1); - sys.subscribe(CHAIN_A, contractA, topic, RI, RI, RI); + notReactive.sub(SEPOLIA, address(originA), RECEIVED, IGNORE, IGNORE, IGNORE); - vm.prank(subscriber2); - sys.subscribe(CHAIN_A, contractA, topic, RI, RI, RI); + _switchToChain(SEPOLIA); + originA.emitReceived(1); + _switchToChain(RC); + _react(FeeMode.Subsidized); - address[] memory matches = sys.getMatchingSubscribers( - CHAIN_A, contractA, topic, 0, 0, 0 - ); - assertEq(matches.length, 2); + assertEq(notReactive.reactCount(), 0, "a non-reactive contract's Subscribe is ignored"); } - function testUnsubscribe() public { - uint256 topic = uint256(keccak256("Transfer(address,address,uint256)")); + function test_reactiveContractWithSameSubscribeIsHonored() public { + // Positive control for the gate above: the identical subscription from a REACTIVE-chain + // contract IS honored and triggers. + CountingReactive reactiveOne = new CountingReactive(); + _registerContract(RC, address(reactiveOne)); // reactive chain -> flagged reactive - vm.prank(subscriber1); - sys.subscribe(CHAIN_A, contractA, topic, RI, RI, RI); + reactiveOne.sub(SEPOLIA, address(originA), RECEIVED, IGNORE, IGNORE, IGNORE); - assertEq(sys.subscriptionCount(), 1); - - vm.prank(subscriber1); - sys.unsubscribe(CHAIN_A, contractA, topic, RI, RI, RI); - - assertEq(sys.subscriptionCount(), 0); - - address[] memory matches = sys.getMatchingSubscribers( - CHAIN_A, contractA, topic, 0, 0, 0 - ); - assertEq(matches.length, 0); - } - - function testUnsubscribeNonexistent() public { - uint256 topic = uint256(keccak256("Transfer(address,address,uint256)")); - - vm.expectRevert("MockSystemContract: subscription not found"); - vm.prank(subscriber1); - sys.unsubscribe(CHAIN_A, contractA, topic, RI, RI, RI); - } + _switchToChain(SEPOLIA); + originA.emitReceived(1); + _switchToChain(RC); + _react(FeeMode.Subsidized); - function testTopicSpecificMatch() public { - uint256 topic0 = uint256(keccak256("Transfer(address,address,uint256)")); - uint256 specificTopic1 = uint256(uint160(makeAddr("specificSender"))); - - // Subscribe to specific topic1 - vm.prank(subscriber1); - sys.subscribe(CHAIN_A, contractA, topic0, specificTopic1, RI, RI); - - // Should match when topic1 matches - address[] memory matches = sys.getMatchingSubscribers( - CHAIN_A, contractA, topic0, specificTopic1, 0, 0 - ); - assertEq(matches.length, 1); - - // Should NOT match different topic1 - matches = sys.getMatchingSubscribers( - CHAIN_A, contractA, topic0, 999, 0, 0 - ); - assertEq(matches.length, 0); + assertEq(reactiveOne.reactCount(), 1, "a reactive contract's Subscribe is honored"); } } diff --git a/test/SubscriptionIndex.t.sol b/test/SubscriptionIndex.t.sol new file mode 100644 index 0000000..3314d67 --- /dev/null +++ b/test/SubscriptionIndex.t.sol @@ -0,0 +1,346 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.29; + +import { Test } from "forge-std/Test.sol"; +import { SubscriptionIndex } from "../src/SubscriptionIndex.sol"; +import { OmniConstants } from "../src/OmniConstants.sol"; +import { SubscriptionStore } from "../src/ReactiveTypes.sol"; + +/// @notice Unit tests for the `SubscriptionIndex` port of `mem_filters.go` (§6.4): the wildcard-mask +/// bucket index — wildcard/topic/contract/chain matching, positional topic semantics, uid +/// grouping of subscribers that share criteria, per-event de-dup, exact unsubscribe, the +/// canonical `(uid, contract)` result ordering (insertion-order-independent), and +/// blacklist/whitelist active toggling. +contract SubscriptionIndexTest is Test { + using SubscriptionIndex for SubscriptionStore; + + SubscriptionStore internal store; + SubscriptionStore internal store2; // second, independent store for insertion-order comparisons + + uint256 constant IGNORE = OmniConstants.REACTIVE_IGNORE; + + uint256 constant SEPOLIA = 11155111; + uint256 constant BASE = 8453; + + address constant ORIGIN = address(0xAA01); + address constant OTHER = address(0xAA02); + address constant RC_A = address(0xBB01); + address constant RC_B = address(0xBB02); + address constant RC_C = address(0xBB03); + + uint256 constant T0 = uint256(keccak256("Received(address,address,uint256)")); + uint256 constant T1 = uint256(uint160(address(0xCAFE))); + uint256 constant TX = uint256(keccak256("Other(uint256)")); + + // ---- helpers ---- + + function _one(uint256 a) internal pure returns (uint256[4] memory t) { + t[0] = a; + } + + function _two(uint256 a, uint256 b) internal pure returns (uint256[4] memory t) { + t[0] = a; + t[1] = b; + } + + function _find(uint256 chainId, address emitter, uint256[4] memory topics, uint256 count) + internal + view + returns (address[] memory) + { + return store.findSubscribers(chainId, emitter, topics, count); + } + + // ---- exact / wildcard matching ---- + + function test_exactMatch() public { + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + address[] memory r = _find(SEPOLIA, ORIGIN, _one(T0), 1); + assertEq(r.length, 1); + assertEq(r[0], RC_A); + } + + function test_chainWildcardMatchesAnyChain() public { + store.add(0, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + assertEq(_find(SEPOLIA, ORIGIN, _one(T0), 1).length, 1); + assertEq(_find(BASE, ORIGIN, _one(T0), 1).length, 1); + } + + function test_chainMismatchNoMatch() public { + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + assertEq(_find(BASE, ORIGIN, _one(T0), 1).length, 0); + } + + function test_contractWildcardMatchesAnyEmitter() public { + store.add(SEPOLIA, address(0), T0, IGNORE, IGNORE, IGNORE, RC_A); + assertEq(_find(SEPOLIA, ORIGIN, _one(T0), 1).length, 1); + assertEq(_find(SEPOLIA, OTHER, _one(T0), 1).length, 1); + } + + function test_contractMismatchNoMatch() public { + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + assertEq(_find(SEPOLIA, OTHER, _one(T0), 1).length, 0); + } + + function test_topic0WildcardMatchesAnyTopic0() public { + store.add(SEPOLIA, ORIGIN, IGNORE, IGNORE, IGNORE, IGNORE, RC_A); + assertEq(_find(SEPOLIA, ORIGIN, _one(T0), 1).length, 1); + assertEq(_find(SEPOLIA, ORIGIN, _one(TX), 1).length, 1); + } + + function test_topic0MismatchNoMatch() public { + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + assertEq(_find(SEPOLIA, ORIGIN, _one(TX), 1).length, 0); + } + + // ---- positional topic semantics ---- + + function test_concreteTopic1RequiresMatch() public { + store.add(SEPOLIA, ORIGIN, T0, T1, IGNORE, IGNORE, RC_A); + // event carries both topics -> match + assertEq(_find(SEPOLIA, ORIGIN, _two(T0, T1), 2).length, 1); + // event carries a different topic1 -> no match + assertEq(_find(SEPOLIA, ORIGIN, _two(T0, TX), 2).length, 0); + } + + function test_concreteTopicSlotBeyondEventTopicCountNoMatch() public { + // subscription constrains topic1, but event only has topic0 (count = 1) + store.add(SEPOLIA, ORIGIN, T0, T1, IGNORE, IGNORE, RC_A); + assertEq(_find(SEPOLIA, ORIGIN, _one(T0), 1).length, 0); + } + + function test_ignoreTopicSlotBeyondEventTopicCountStillMatches() public { + // wildcard slots don't require the event to carry a topic there + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + assertEq(_find(SEPOLIA, ORIGIN, _one(T0), 1).length, 1); + } + + function test_fullyConcreteFourTopicFilterMatches() public { + // mask = 0 (nothing wildcard): exercises the all-concrete bucket end-to-end. + store.add(SEPOLIA, ORIGIN, T0, T1, TX, T0, RC_A); + uint256[4] memory ev = [T0, T1, TX, T0]; + assertEq(store.findSubscribers(SEPOLIA, ORIGIN, ev, 4).length, 1, "all-concrete filter matches its event"); + // any single field off -> miss + assertEq(store.findSubscribers(SEPOLIA, ORIGIN, [T0, T1, TX, T1], 4).length, 0, "topic3 differs -> miss"); + } + + // ---- de-dup ---- + + function test_dedupSameSubscriberMultipleMatchingSubs() public { + // two distinct-criteria filters for the same reactive contract both match the event + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + store.add(SEPOLIA, address(0), IGNORE, IGNORE, IGNORE, IGNORE, RC_A); + assertEq(store.filterCount(), 2, "distinct criteria -> two filters"); + address[] memory r = _find(SEPOLIA, ORIGIN, _one(T0), 1); + assertEq(r.length, 1, "subscriber triggered at most once per event"); + assertEq(r[0], RC_A); + } + + function test_multipleDistinctSubscribers() public { + // same criteria, two subscribers -> one filter, two configs + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_B); + assertEq(store.filterCount(), 1, "shared criteria -> single filter"); + address[] memory r = _find(SEPOLIA, ORIGIN, _one(T0), 1); + assertEq(r.length, 2); + // same uid -> ordered by contract ascending + assertEq(r[0], RC_A); + assertEq(r[1], RC_B); + } + + // ---- node uid parity ---- + + /// @notice Anchors byte-for-byte parity with the node's `computeFilterId`/`BuildFilterId` + /// (`mem_filters.go`): the reference values were produced by calling the node's + /// `BuildFilterId` directly. This is what guarantees our canonical trigger order matches + /// the order the real network would produce for the same filter set. If the node's uid + /// formula ever changes, this test must be regenerated against it. + function test_uidMatchesNodeComputeFilterId() public { + // chainId=1, contract=0x..00AA, topic0=0x..01, topics1-3 wildcard. + bytes16 expectedConcrete = hex"17d8f66d5ec40203cd50291269889621"; + store.add(1, address(0xAA), 1, IGNORE, IGNORE, IGNORE, RC_A); + assertEq(store.filters[0].uid, expectedConcrete, "concrete-fields uid parity"); + + // all-wildcard filter. + bytes16 expectedWildcard = hex"5f179612d7132c8ed24ba0e286d60d39"; + store2.add(0, address(0), IGNORE, IGNORE, IGNORE, IGNORE, RC_A); + assertEq(store2.filters[0].uid, expectedWildcard, "all-wildcard uid parity"); + } + + // ---- canonical (uid, contract) ordering ---- + + function test_orderingIsInsertionIndependent() public { + // Two *distinct-criteria* filters (different masks -> different uids) that both match one + // event. Under the node's canonical ordering the trigger order depends only on the uids, not + // on the order the subscriptions were created — so both insertion orders must yield the same + // result. (Under the old creation-order semantics these two would differ.) + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); // mask: topics1-3 wildcard + store.add(0, address(0), IGNORE, IGNORE, IGNORE, IGNORE, RC_B); // mask: all wildcard + + store2.add(0, address(0), IGNORE, IGNORE, IGNORE, IGNORE, RC_B); // reverse insertion order + store2.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + + address[] memory r1 = store.findSubscribers(SEPOLIA, ORIGIN, _one(T0), 1); + address[] memory r2 = store2.findSubscribers(SEPOLIA, ORIGIN, _one(T0), 1); + + assertEq(r1.length, 2, "both filters match"); + assertEq(r2.length, 2); + assertEq(r1[0], r2[0], "canonical order is independent of insertion order"); + assertEq(r1[1], r2[1]); + // and the set is exactly {RC_A, RC_B} + assertTrue((r1[0] == RC_A && r1[1] == RC_B) || (r1[0] == RC_B && r1[1] == RC_A), "result set is {RC_A, RC_B}"); + } + + function test_sameCriteriaOrderedByContractAscending() public { + // Same criteria (same uid) but subscribers added highest-address-first: canonical order must + // still be contract-ascending, not creation order. + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_C); + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_B); + address[] memory r = _find(SEPOLIA, ORIGIN, _one(T0), 1); + assertEq(r.length, 3); + assertEq(r[0], RC_A, "0xBB01 first"); + assertEq(r[1], RC_B, "0xBB02 second"); + assertEq(r[2], RC_C, "0xBB03 third"); + } + + // ---- unsubscribe ---- + + function test_unsubscribeStopsDelivery() public { + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + assertEq(_find(SEPOLIA, ORIGIN, _one(T0), 1).length, 1); + + bool removed = store.remove(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + assertTrue(removed); + assertEq(store.filterCount(), 0, "last config gone -> filter detached"); + assertEq(_find(SEPOLIA, ORIGIN, _one(T0), 1).length, 0); + } + + function test_unsubscribeRequiresExactMatch() public { + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + // different criteria -> different uid -> nothing removed + assertFalse(store.remove(BASE, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A)); + // same criteria, different subscriber -> filter found, no config -> nothing removed + assertFalse(store.remove(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_B)); + assertEq(store.filterCount(), 1); + assertEq(_find(SEPOLIA, ORIGIN, _one(T0), 1).length, 1); + } + + function test_unsubscribeRemovesOnlyThatSubscriber() public { + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_B); + // remove RC_A; the filter survives (RC_B still subscribed) and RC_B is still the sole match + assertTrue(store.remove(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A)); + assertEq(store.filterCount(), 1, "shared filter survives while any config remains"); + address[] memory r = _find(SEPOLIA, ORIGIN, _one(T0), 1); + assertEq(r.length, 1); + assertEq(r[0], RC_B); + } + + function test_detachedFilterSwapRemoveKeepsOthersIntact() public { + // Three distinct-criteria filters; remove the middle one (forces the swap-pop path) and + // confirm the survivors still match correctly and the count is right. + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + store.add(BASE, OTHER, TX, IGNORE, IGNORE, IGNORE, RC_B); + store.add(SEPOLIA, ORIGIN, T1, IGNORE, IGNORE, IGNORE, RC_C); + assertEq(store.filterCount(), 3); + + assertTrue(store.remove(BASE, OTHER, TX, IGNORE, IGNORE, IGNORE, RC_B), "middle filter removed"); + assertEq(store.filterCount(), 2); + + assertEq(_find(SEPOLIA, ORIGIN, _one(T0), 1)[0], RC_A, "A still matches"); + assertEq(_find(SEPOLIA, ORIGIN, _one(T1), 1)[0], RC_C, "C still matches"); + assertEq(_find(BASE, OTHER, _one(TX), 1).length, 0, "B is gone"); + } + + // ---- blacklist / whitelist ---- + + function test_blacklistDeactivatesThenWhitelistReactivates() public { + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + store.add(BASE, address(0), IGNORE, IGNORE, IGNORE, IGNORE, RC_A); + + store.setActive(RC_A, false); // BlacklistContract(RC_A) + assertEq(_find(SEPOLIA, ORIGIN, _one(T0), 1).length, 0, "blacklisted -> no delivery"); + assertEq(_find(BASE, OTHER, _one(TX), 1).length, 0, "all of RC_A subs deactivated"); + + store.setActive(RC_A, true); // WhitelistContract(RC_A) + assertEq(_find(SEPOLIA, ORIGIN, _one(T0), 1).length, 1, "whitelisted -> delivery resumes"); + } + + function test_blacklistOnlyAffectsTargetSubscriber() public { + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_B); + store.setActive(RC_A, false); + address[] memory r = _find(SEPOLIA, ORIGIN, _one(T0), 1); + assertEq(r.length, 1); + assertEq(r[0], RC_B, "only RC_B remains active"); + } + + // ---- edge cases ---- + + function test_emptyIndexNoMatch() public view { + assertEq(_find(SEPOLIA, ORIGIN, _one(T0), 1).length, 0); + } + + function test_zeroTopicEvent() public { + // A 0-topic event: a concrete-topic0 sub cannot match; an all-wildcard sub does. + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + uint256[4] memory none; + assertEq(_find(SEPOLIA, ORIGIN, none, 0).length, 0, "concrete topic0 needs a topic"); + + store.add(0, address(0), IGNORE, IGNORE, IGNORE, IGNORE, RC_B); + address[] memory r = _find(SEPOLIA, ORIGIN, none, 0); + assertEq(r.length, 1, "all-wildcard matches a 0-topic event"); + assertEq(r[0], RC_B); + } + + function test_allWildcardMatchesEverything() public { + store.add(0, address(0), IGNORE, IGNORE, IGNORE, IGNORE, RC_A); + assertEq(_find(SEPOLIA, ORIGIN, _one(T0), 1).length, 1); + assertEq(_find(BASE, OTHER, _two(TX, T1), 2).length, 1); + } + + function test_setActiveUnknownSubscriberIsNoop() public { + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + store.setActive(RC_C, false); // RC_C has no subscriptions + assertEq(_find(SEPOLIA, ORIGIN, _one(T0), 1).length, 1, "unrelated toggle is a no-op"); + } + + function test_removeOnEmptyReturnsFalse() public { + assertFalse(store.remove(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A)); + assertEq(store.filterCount(), 0); + } + + // ---- idempotent add (node Subscribe parity) ---- + + function test_idempotentAddThenSingleUnsubscribeFullyRemoves() public { + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); // identical -> no duplicate config + assertEq(store.filterCount(), 1, "duplicate subscribe does not add a filter"); + assertEq(store.activeConfigCount(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A), 1, "single active config"); + + assertTrue(store.remove(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A)); + assertEq(store.filterCount(), 0, "single unsubscribe fully removes"); + assertEq(_find(SEPOLIA, ORIGIN, _one(T0), 1).length, 0); + } + + function test_resubscribeReactivatesAfterBlacklist() public { + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); + store.setActive(RC_A, false); // BlacklistContract(RC_A) + assertEq(_find(SEPOLIA, ORIGIN, _one(T0), 1).length, 0, "blacklisted"); + + store.add(SEPOLIA, ORIGIN, T0, IGNORE, IGNORE, IGNORE, RC_A); // re-subscribe reactivates + assertEq(store.filterCount(), 1, "still one filter"); + assertEq(_find(SEPOLIA, ORIGIN, _one(T0), 1).length, 1, "re-subscribe reactivates"); + } + + function test_concreteZeroTopicMatchesPresentZeroNotAbsentSlot() public { + // Subscription constrains topic1 to the literal value 0 (a real, non-wildcard value). + store.add(SEPOLIA, ORIGIN, T0, 0, IGNORE, IGNORE, RC_A); + // Event carries topic1 present and equal to 0 -> match. + assertEq(_find(SEPOLIA, ORIGIN, _two(T0, 0), 2).length, 1, "present zero matches concrete 0"); + // Event has only topic0 (topic1 absent) -> no match, even though the zero-filled slot reads 0. + assertEq(_find(SEPOLIA, ORIGIN, _one(T0), 1).length, 0, "absent slot != concrete 0"); + } +} diff --git a/test/bench/FindSubscribersBench.t.sol b/test/bench/FindSubscribersBench.t.sol new file mode 100644 index 0000000..ff4ee18 --- /dev/null +++ b/test/bench/FindSubscribersBench.t.sol @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.29; + +import { Test } from "forge-std/Test.sol"; +import { LinearScanIndex, LinearSub } from "./LinearScanIndex.sol"; +import { SubscriptionIndex } from "../../src/SubscriptionIndex.sol"; +import { SubscriptionStore } from "../../src/ReactiveTypes.sol"; +import { OmniConstants } from "../../src/OmniConstants.sol"; + +/// @notice Holds both indexes and exposes populate/lookup as external functions. Populating happens +/// in its own call frame (populate's heavy in-frame memory growth is discarded on return), so +/// the driver test can measure a lookup from a clean frame without an O(N) memory-expansion +/// artifact polluting the number. +contract BenchTarget { + using LinearScanIndex for LinearSub[]; + using SubscriptionIndex for SubscriptionStore; + + uint256 constant IGNORE = OmniConstants.REACTIVE_IGNORE; + + LinearSub[] internal oldSubs; + SubscriptionStore internal newStore; + + /// @dev Filter i: chain=1, contract=address(i+1), topic0=i+1, indexed args wildcard, subscriber= + /// address(i+1). Distinct criteria per row; the probe event matches only row 0. All rows + /// share one wildcard mask, the realistic "pin chain+contract+event-selector" shape. + function populateOld(uint256 n) external { + for (uint256 i = 0; i < n; ++i) { + // forge-lint: disable-next-line(unsafe-typecast) -- i < 1e6, fits uint160. + address a = address(uint160(i + 1)); + oldSubs.push( + LinearSub({ + chainId: 1, + listenContract: a, + topic0: i + 1, + topic1: IGNORE, + topic2: IGNORE, + topic3: IGNORE, + subscriber: a, + active: true + }) + ); + } + } + + function populateNew(uint256 n) external { + for (uint256 i = 0; i < n; ++i) { + // forge-lint: disable-next-line(unsafe-typecast) -- i < 1e6, fits uint160. + address a = address(uint160(i + 1)); + newStore.add(1, a, i + 1, IGNORE, IGNORE, IGNORE, a); + } + } + + function findOld(uint256 chainId_, address emitter_, uint256[4] calldata topics_, uint256 count_) + external + view + returns (uint256) + { + return oldSubs.findSubscribers(chainId_, emitter_, topics_, count_).length; + } + + function findNew(uint256 chainId_, address emitter_, uint256[4] calldata topics_, uint256 count_) + external + view + returns (uint256) + { + return newStore.findSubscribers(chainId_, emitter_, topics_, count_).length; + } +} + +/// @notice Gas benchmark: retired O(N) linear scan vs. the 64-bucket wildcard-mask index, measuring a +/// single `findSubscribers` lookup against 100 / 10_000 / 1_000_000 active subscriptions — +/// the same scales as the node's `perf_findsubscribers_test.go`. +/// @dev Inert unless `RUN_BENCH=true` (populating 1M subscriptions in-EVM is far too heavy for CI). +/// Run e.g.: +/// RUN_BENCH=true FOUNDRY_PROFILE=bench forge test --match-path 'test/bench/*' -vv +/// Populate runs inside `BenchTarget` (separate frame); the measured lookup is a fresh external +/// call, so the number is the isolated lookup cost. The new index's cost is bounded at 64 probes +/// + matched count regardless of bucket distribution, so N-independence holds for any layout, not +/// just this one; the old scan touches every row. +contract FindSubscribersBench is Test { + function _shouldRun() internal view returns (bool) { + return vm.envOr("RUN_BENCH", false); + } + + function _probeEvent() internal pure returns (uint256[4] memory t) { + t[0] = 1; // matches row 0 only (contract=address(1), topic0=1) + } + + function _benchOld(uint256 n) internal { + if (!_shouldRun()) return; + BenchTarget t = new BenchTarget(); + t.populateOld(n); + uint256[4] memory topics = _probeEvent(); + + uint256 before = gasleft(); + uint256 matched = t.findOld(1, address(uint160(1)), topics, 1); + uint256 used = before - gasleft(); + + assertEq(matched, 1, "old: expected exactly one match"); + emit log_named_uint(string.concat("OLD linear-scan gas @ N=", vm.toString(n)), used); + } + + function _benchNew(uint256 n) internal { + if (!_shouldRun()) return; + BenchTarget t = new BenchTarget(); + t.populateNew(n); + uint256[4] memory topics = _probeEvent(); + + uint256 before = gasleft(); + uint256 matched = t.findNew(1, address(uint160(1)), topics, 1); + uint256 used = before - gasleft(); + + assertEq(matched, 1, "new: expected exactly one match"); + emit log_named_uint(string.concat("NEW bucket-index gas @ N=", vm.toString(n)), used); + } + + function test_old_100() public { + _benchOld(100); + } + + function test_old_10k() public { + _benchOld(10_000); + } + + function test_old_1m() public { + _benchOld(1_000_000); + } + + function test_new_100() public { + _benchNew(100); + } + + function test_new_10k() public { + _benchNew(10_000); + } + + function test_new_1m() public { + _benchNew(1_000_000); + } +} diff --git a/test/bench/LinearScanIndex.sol b/test/bench/LinearScanIndex.sol new file mode 100644 index 0000000..385b689 --- /dev/null +++ b/test/bench/LinearScanIndex.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.29; + +import { OmniConstants } from "../../src/OmniConstants.sol"; + +/// @notice A single subscription row for the pre-bucket linear-scan index (the old `Subscription` +/// struct, kept here only so the retired algorithm can be benchmarked side-by-side). +struct LinearSub { + uint256 chainId; + address listenContract; + uint256 topic0; + uint256 topic1; + uint256 topic2; + uint256 topic3; + address subscriber; + bool active; +} + +/// @title LinearScanIndex +/// @notice The retired O(N) linear-scan `findSubscribers` (verbatim from `SubscriptionIndex.sol` at +/// commit 36e0604), preserved under a bench-only name so the bucket index can be measured +/// against it. Matching / positional-topic logic is identical to the surviving code; only +/// the lookup strategy (scan vs 64-bucket probe) differs. +library LinearScanIndex { + function findSubscribers( + LinearSub[] storage subs, + uint256 chainId_, + address emitter_, + uint256[4] memory topics_, + uint256 topicCount_ + ) internal view returns (address[] memory result_) { + uint256 len = subs.length; + address[] memory tmp = new address[](len); + uint256 count; + + for (uint256 i = 0; i < len; ++i) { + LinearSub storage s = subs[i]; + if (!s.active) continue; + if (!_matches(s, chainId_, emitter_, topics_, topicCount_)) continue; + + bool seen; + for (uint256 j = 0; j < count; ++j) { + if (tmp[j] == s.subscriber) { + seen = true; + break; + } + } + if (!seen) { + tmp[count++] = s.subscriber; + } + } + + result_ = new address[](count); + for (uint256 k = 0; k < count; ++k) { + result_[k] = tmp[k]; + } + } + + function _matches( + LinearSub storage s, + uint256 chainId_, + address emitter_, + uint256[4] memory topics_, + uint256 topicCount_ + ) private view returns (bool) { + if (s.chainId != 0 && s.chainId != chainId_) return false; + if (s.listenContract != address(0) && s.listenContract != emitter_) return false; + if (!_topicMatches(s.topic0, topics_[0], 0, topicCount_)) return false; + if (!_topicMatches(s.topic1, topics_[1], 1, topicCount_)) return false; + if (!_topicMatches(s.topic2, topics_[2], 2, topicCount_)) return false; + if (!_topicMatches(s.topic3, topics_[3], 3, topicCount_)) return false; + return true; + } + + function _topicMatches(uint256 subTopic_, uint256 eventTopic_, uint256 slot_, uint256 topicCount_) + private + pure + returns (bool) + { + if (subTopic_ == OmniConstants.REACTIVE_IGNORE) return true; + if (slot_ >= topicCount_) return false; + return subTopic_ == eventTopic_; + } +} diff --git a/test/mocks/CountingReactive.sol b/test/mocks/CountingReactive.sol new file mode 100644 index 0000000..12c0f89 --- /dev/null +++ b/test/mocks/CountingReactive.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.29; + +import { AbstractReactive } from "@reactive/src/base/AbstractReactive.sol"; + +/// @notice A reactive contract that only counts how many times it is triggered, with test-driven +/// subscribe/unsubscribe so a single mock can exercise arbitrary filtering scenarios. +contract CountingReactive is AbstractReactive { + uint256 public reactCount; + + function sub(uint256 chainId_, address contract_, uint256 t0_, uint256 t1_, uint256 t2_, uint256 t3_) external { + SYSTEM.subscribe(chainId_, contract_, t0_, t1_, t2_, t3_); + } + + function unsub(uint256 chainId_, address contract_, uint256 t0_, uint256 t1_, uint256 t2_, uint256 t3_) external { + SYSTEM.unsubscribe(chainId_, contract_, t0_, t1_, t2_, t3_); + } + + function react(LogRecord calldata) external onlySystem { + ++reactCount; + } +} diff --git a/test/mocks/EmittingCallback.sol b/test/mocks/EmittingCallback.sol new file mode 100644 index 0000000..0d47518 --- /dev/null +++ b/test/mocks/EmittingCallback.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.29; + +import { AbstractCallback } from "@reactive/src/base/AbstractCallback.sol"; +import { IPayable } from "@reactive/src/interfaces/IPayable.sol"; + +/// @notice A callback recipient that emits `Step2` when invoked — used to drive a second cascade +/// round (a reactive contract subscribed to `Step2` reacts on the next `_react`). +contract EmittingCallback is AbstractCallback { + event Step2(uint256 value); + + uint256 public count; + + constructor(address callbackProxy_, address reactive_) + AbstractCallback(IPayable(payable(callbackProxy_)), reactive_) + { } + + function callback(address sender_) external onlyCallbackSender(sender_) { + ++count; + emit Step2(42); + } +} diff --git a/test/mocks/SampleCallback.sol b/test/mocks/SampleCallback.sol index 6535c60..50e5191 100644 --- a/test/mocks/SampleCallback.sol +++ b/test/mocks/SampleCallback.sol @@ -1,29 +1,22 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; -/// @notice Minimal callback contract for testing. -contract SampleCallback { - address public authorizedSender; - address public rvmId; +pragma solidity ^0.8.29; - uint256 public lastAmount; - address public lastRvmId; - uint256 public callbackCount; +import { AbstractCallback } from "@reactive/src/base/AbstractCallback.sol"; +import { IPayable } from "@reactive/src/interfaces/IPayable.sol"; - constructor(address _callbackSender) { - authorizedSender = _callbackSender; - rvmId = msg.sender; - } +/// @notice Reference callback contract: guards its entry point with `onlyCallbackSender`, which +/// only passes when the injected arg0 equals the authorized reactive contract (§10). +contract SampleCallback is AbstractCallback { + address public lastSender; + uint256 public count; - function onCallback(address _rvmId, uint256 amount) external { - lastRvmId = _rvmId; - lastAmount = amount; - callbackCount++; - } + constructor(address callbackProxy_, address reactive_) + AbstractCallback(IPayable(payable(callbackProxy_)), reactive_) + { } - function onCronCallback(address _rvmId, uint256 blockNumber) external { - lastRvmId = _rvmId; - lastAmount = blockNumber; - callbackCount++; + function callback(address sender_) external onlyCallbackSender(sender_) { + lastSender = sender_; + ++count; } } diff --git a/test/mocks/SampleCron.sol b/test/mocks/SampleCron.sol new file mode 100644 index 0000000..1f2e371 --- /dev/null +++ b/test/mocks/SampleCron.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.29; + +import { AbstractReactive } from "@reactive/src/base/AbstractReactive.sol"; + +/// @notice Reference cron reactive contract: subscribes to a single `CronN` topic on the legacy +/// proxy and records each tick's block number (§6.7). +contract SampleCron is AbstractReactive { + uint256 public immutable cronTopic; + uint256 public tickCount; + uint256 public lastBlock; + + constructor(uint256 reactiveChain_, address legacyProxy_, uint256 cronTopic_) { + cronTopic = cronTopic_; + SYSTEM.subscribe(reactiveChain_, legacyProxy_, cronTopic_, REACTIVE_IGNORE, REACTIVE_IGNORE, REACTIVE_IGNORE); + } + + function react(LogRecord calldata log) external onlySystem { + ++tickCount; + lastBlock = log.topic1; // CronN(uint256 indexed number) => block number in topic1 + } +} diff --git a/test/mocks/SampleCronContract.sol b/test/mocks/SampleCronContract.sol deleted file mode 100644 index a124e4f..0000000 --- a/test/mocks/SampleCronContract.sol +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; - -/// @notice Minimal cron-based reactive contract for testing. -contract SampleCronContract { - uint256 internal constant REACTIVE_IGNORE = 0xa65f96fc951c35ead38878e0f0b7a3c744a6f5ccc1476b313353ce31712313ad; - - struct LogRecord { - uint256 chain_id; - address _contract; - uint256 topic_0; - uint256 topic_1; - uint256 topic_2; - uint256 topic_3; - bytes data; - uint256 block_number; - uint256 op_code; - uint256 block_hash; - uint256 tx_hash; - uint256 log_index; - } - - event Callback( - uint256 indexed chain_id, - address indexed _contract, - uint64 indexed gas_limit, - bytes payload - ); - - address public callbackTarget; - uint256 public destChainId; - uint256 public lastCronBlock; - bool public paused; - - constructor( - address _systemContract, - uint256 _cronTopic, - uint256 _destChainId, - address _callbackTarget - ) { - callbackTarget = _callbackTarget; - destChainId = _destChainId; - - // Subscribe to cron events on the reactive chain - (bool ok,) = _systemContract.call( - abi.encodeWithSignature( - "subscribe(uint256,address,uint256,uint256,uint256,uint256)", - uint256(0x512512), // REACTIVE_CHAIN_ID - address(0), // wildcard contract - _cronTopic, - REACTIVE_IGNORE, - REACTIVE_IGNORE, - REACTIVE_IGNORE - ) - ); - require(ok, "subscribe failed"); - } - - function react(LogRecord calldata log) external { - if (paused) return; - - lastCronBlock = log.block_number; - - bytes memory payload = abi.encodeWithSignature( - "onCronCallback(address,uint256)", - address(0), // RVM ID placeholder - log.block_number - ); - emit Callback(destChainId, callbackTarget, 100000, payload); - } - - function pause() external { - paused = true; - } - - function resume() external { - paused = false; - } -} diff --git a/test/mocks/SampleMultiStepContracts.sol b/test/mocks/SampleMultiStepContracts.sol deleted file mode 100644 index 29ec16c..0000000 --- a/test/mocks/SampleMultiStepContracts.sol +++ /dev/null @@ -1,166 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; - -/// @notice Simulates a simplified bridge protocol with multiple reactive hops: -/// 1. User calls MiniOrigin.deposit() → emits Deposit event -/// 2. MiniReactive.react() matches Deposit → emits Callback to MiniBridge.receiveDeposit() -/// 3. MiniBridge.receiveDeposit() → emits Confirmation event -/// 4. MiniReactive.react() matches Confirmation → emits self-Callback to MiniReactive.deliver() -/// 5. MiniReactive.deliver() stores result - -// ---- Origin (L1 deposit contract) ---- - -contract MiniOrigin { - event Deposit(address indexed sender, uint256 indexed amount); - - function deposit() external payable { - emit Deposit(msg.sender, msg.value); - } -} - -// ---- Bridge (L1 bridge contract that confirms deposits) ---- - -contract MiniBridge { - event Confirmation( - uint256 indexed depositId, - address indexed sender, - uint256 indexed amount - ); - - address public authorizedSender; - - uint256 public lastDepositId; - uint256 public confirmationCount; - - constructor(address _callbackSender) { - authorizedSender = _callbackSender; - } - - function receiveDeposit(address /* rvmId */, uint256 depositId, address sender, uint256 amount) external { - // In a real scenario, would check authorizedSender - lastDepositId = depositId; - confirmationCount++; - emit Confirmation(depositId, sender, amount); - } -} - -// ---- Reactive Contract (subscribes to both chains, performs multi-step protocol) ---- - -contract MiniReactive { - uint256 internal constant REACTIVE_IGNORE = 0xa65f96fc951c35ead38878e0f0b7a3c744a6f5ccc1476b313353ce31712313ad; - - struct LogRecord { - uint256 chain_id; - address _contract; - uint256 topic_0; - uint256 topic_1; - uint256 topic_2; - uint256 topic_3; - bytes data; - uint256 block_number; - uint256 op_code; - uint256 block_hash; - uint256 tx_hash; - uint256 log_index; - } - - event Callback( - uint256 indexed chain_id, - address indexed _contract, - uint64 indexed gas_limit, - bytes payload - ); - - uint256 public originChainId; - uint256 public destChainId; - uint256 public rnChainId; - address public bridgeAddr; - - // Delivery tracking - uint256 public deliveredAmount; - address public deliveredSender; - uint256 public deliveryCount; - - // Auth - mapping(address => bool) public authorizedSenders; - - uint256 private constant DEPOSIT_TOPIC = 0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c; - uint256 private constant CONFIRMATION_TOPIC = 0xa37a53be3e077363939b60e1f4e37f25f2367f12dc6e7d4f6ad47a66c5948983; - - constructor( - address _systemContract, - uint256 _originChainId, - uint256 _destChainId, - uint256 _rnChainId, - address _originContract, - address _bridgeAddr - ) { - originChainId = _originChainId; - destChainId = _destChainId; - rnChainId = _rnChainId; - bridgeAddr = _bridgeAddr; - authorizedSenders[_systemContract] = true; - - // Subscribe to Deposit events from origin - uint256 depositTopic = uint256(keccak256("Deposit(address,uint256)")); - (bool ok,) = _systemContract.call( - abi.encodeWithSignature( - "subscribe(uint256,address,uint256,uint256,uint256,uint256)", - _originChainId, _originContract, depositTopic, REACTIVE_IGNORE, REACTIVE_IGNORE, REACTIVE_IGNORE - ) - ); - require(ok, "subscribe deposit failed"); - - // Subscribe to Confirmation events from bridge - uint256 confirmTopic = uint256(keccak256("Confirmation(uint256,address,uint256)")); - (ok,) = _systemContract.call( - abi.encodeWithSignature( - "subscribe(uint256,address,uint256,uint256,uint256,uint256)", - _destChainId, _bridgeAddr, confirmTopic, REACTIVE_IGNORE, REACTIVE_IGNORE, REACTIVE_IGNORE - ) - ); - require(ok, "subscribe confirmation failed"); - } - - function react(LogRecord calldata log) external { - uint256 depositTopic = uint256(keccak256("Deposit(address,uint256)")); - uint256 confirmTopic = uint256(keccak256("Confirmation(uint256,address,uint256)")); - - if (log.topic_0 == depositTopic) { - // Step 1: Deposit detected → send callback to bridge - address sender = address(uint160(log.topic_1)); - uint256 amount = log.topic_2; - uint256 depositId = uint256(keccak256(abi.encode(sender, amount, block.number))); - - bytes memory payload = abi.encodeWithSignature( - "receiveDeposit(address,uint256,address,uint256)", - address(0), // RVM ID placeholder - depositId, - sender, - amount - ); - emit Callback(destChainId, bridgeAddr, 500000, payload); - - } else if (log.topic_0 == confirmTopic) { - // Step 2: Confirmation received → self-callback to deliver - uint256 amount = log.topic_3; - address sender = address(uint160(log.topic_2)); - - bytes memory payload = abi.encodeWithSignature( - "deliver(address,address,uint256)", - address(0), // RVM ID placeholder - sender, - amount - ); - emit Callback(rnChainId, address(this), 500000, payload); - } - } - - /// @notice Self-callback: final delivery step. - function deliver(address /* rvmId */, address sender, uint256 amount) external { - require(authorizedSenders[msg.sender], "Authorized sender only"); - deliveredSender = sender; - deliveredAmount = amount; - deliveryCount++; - } -} diff --git a/test/mocks/SampleOrigin.sol b/test/mocks/SampleOrigin.sol index 43ea3df..98218c9 100644 --- a/test/mocks/SampleOrigin.sol +++ b/test/mocks/SampleOrigin.sol @@ -1,14 +1,19 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; -/// @notice A simple origin contract that emits events when receiving ETH. +pragma solidity ^0.8.29; + +/// @notice A simple origin-chain contract emitting an event the reactive contract listens to. +/// @dev `amount` is indexed so it lands in `topic3`, matching the reference reactive contract's +/// `log.topic3 >= threshold` check. contract SampleOrigin { - event Received(address indexed sender, address indexed recipient, uint256 amount); + event Received(address indexed sender, address indexed recipient, uint256 indexed amount); - receive() external payable { - emit Received(msg.sender, address(this), msg.value); + /// @notice Emit `Received` with an explicit amount (no value transfer needed). + function emitReceived(uint256 amount) external { + emit Received(msg.sender, address(this), amount); } + /// @notice Emit `Received` carrying the ETH sent. function deposit() external payable { emit Received(msg.sender, address(this), msg.value); } diff --git a/test/mocks/SampleReactive.sol b/test/mocks/SampleReactive.sol new file mode 100644 index 0000000..7339a8a --- /dev/null +++ b/test/mocks/SampleReactive.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.29; + +import { AbstractReactive } from "@reactive/src/base/AbstractReactive.sol"; +import { ISystemContract } from "@reactive/src/interfaces/ISystemContract.sol"; + +/// @notice Reference reactive contract: subscribes to an origin event in its constructor and, when +/// the event's `topic3` clears a threshold, requests a cross-chain callback (§10). +contract SampleReactive is AbstractReactive { + uint256 public immutable originChain; + address public immutable origin; + uint256 public immutable topic0; + uint256 public immutable destChain; + uint256 public immutable threshold; + + address public callbackContract; + uint256 public reactCount; + + constructor(uint256 originChain_, address origin_, uint256 topic0_, uint256 destChain_, uint256 threshold_) { + originChain = originChain_; + origin = origin_; + topic0 = topic0_; + destChain = destChain_; + threshold = threshold_; + SYSTEM.subscribe(originChain_, origin_, topic0_, REACTIVE_IGNORE, REACTIVE_IGNORE, REACTIVE_IGNORE); + } + + /// @notice Set the destination callback contract (breaks the reactive<->callback deploy cycle). + function setCallback(address callbackContract_) external { + callbackContract = callbackContract_; + } + + function react(LogRecord calldata log) external onlySystem { + ++reactCount; + if (log.topic3 >= threshold) { + bytes memory payload = abi.encodeWithSignature("callback(address)", address(0)); + SYSTEM.requestCallbackV_1_0( + ISystemContract.CallbackConfiguration_V_1_0(destChain, callbackContract, 1e6, payload) + ); + } + } +} diff --git a/test/mocks/SampleReactiveContract.sol b/test/mocks/SampleReactiveContract.sol deleted file mode 100644 index 8db6d6c..0000000 --- a/test/mocks/SampleReactiveContract.sol +++ /dev/null @@ -1,78 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; - -/// @notice Minimal reactive contract for testing — does NOT inherit from AbstractReactive -/// to avoid circular dependencies. Mimics the same interface. -contract SampleReactiveContract { - uint256 internal constant REACTIVE_IGNORE = 0xa65f96fc951c35ead38878e0f0b7a3c744a6f5ccc1476b313353ce31712313ad; - - struct LogRecord { - uint256 chain_id; - address _contract; - uint256 topic_0; - uint256 topic_1; - uint256 topic_2; - uint256 topic_3; - bytes data; - uint256 block_number; - uint256 op_code; - uint256 block_hash; - uint256 tx_hash; - uint256 log_index; - } - - event Callback( - uint256 indexed chain_id, - address indexed _contract, - uint64 indexed gas_limit, - bytes payload - ); - - address public callbackTarget; - uint256 public destChainId; - uint256 public threshold; - uint256 public reactCallCount; - - constructor( - address _systemContract, - uint256 _originChainId, - uint256 _destChainId, - address _originContract, - uint256 _topic0, - address _callbackTarget - ) { - callbackTarget = _callbackTarget; - destChainId = _destChainId; - - // Subscribe via the system contract - (bool ok,) = _systemContract.call( - abi.encodeWithSignature( - "subscribe(uint256,address,uint256,uint256,uint256,uint256)", - _originChainId, - _originContract, - _topic0, - REACTIVE_IGNORE, - REACTIVE_IGNORE, - REACTIVE_IGNORE - ) - ); - require(ok, "subscribe failed"); - } - - function react(LogRecord calldata log) external { - reactCallCount++; - - // Decode the amount from the event data - uint256 amount = abi.decode(log.data, (uint256)); - - // Only fire callback if amount > 0.001 ether - if (amount > 0.001 ether) { - bytes memory payload = abi.encodeWithSignature( - "onCallback(address,uint256)", - address(0), // placeholder — RVM ID will be injected here - amount - ); - emit Callback(destChainId, callbackTarget, 100000, payload); - } - } -} diff --git a/test/mocks/SampleSelfCallbackContract.sol b/test/mocks/SampleSelfCallbackContract.sol deleted file mode 100644 index d46fc72..0000000 --- a/test/mocks/SampleSelfCallbackContract.sol +++ /dev/null @@ -1,86 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.20; - -/// @notice Reactive contract that emits a self-callback (targets itself on the reactive chain). -/// Mimics the ReactiveBridge pattern where react() emits Callback(reactive_chain_id, address(this), ...). -contract SampleSelfCallbackContract { - uint256 internal constant REACTIVE_IGNORE = 0xa65f96fc951c35ead38878e0f0b7a3c744a6f5ccc1476b313353ce31712313ad; - - struct LogRecord { - uint256 chain_id; - address _contract; - uint256 topic_0; - uint256 topic_1; - uint256 topic_2; - uint256 topic_3; - bytes data; - uint256 block_number; - uint256 op_code; - uint256 block_hash; - uint256 tx_hash; - uint256 log_index; - } - - event Callback( - uint256 indexed chain_id, - address indexed _contract, - uint64 indexed gas_limit, - bytes payload - ); - - uint256 public reactiveChainId; - uint256 public deliveredAmount; - address public deliveredRvmId; - uint256 public deliveryCount; - - /// @notice Authorized sender (SERVICE_ADDR for same-chain callbacks). - mapping(address => bool) public authorizedSenders; - - constructor( - address _systemContract, - uint256 _reactiveChainId, - uint256 _originChainId, - address _originContract, - uint256 _topic0 - ) { - reactiveChainId = _reactiveChainId; - - // Authorize SERVICE_ADDR as callback sender (same as ReactiveBridge does) - authorizedSenders[_systemContract] = true; - - // Subscribe to origin events - (bool ok,) = _systemContract.call( - abi.encodeWithSignature( - "subscribe(uint256,address,uint256,uint256,uint256,uint256)", - _originChainId, - _originContract, - _topic0, - REACTIVE_IGNORE, - REACTIVE_IGNORE, - REACTIVE_IGNORE - ) - ); - require(ok, "subscribe failed"); - } - - /// @notice react() emits a self-callback targeting this contract on the reactive chain. - function react(LogRecord calldata log) external { - uint256 amount = abi.decode(log.data, (uint256)); - - // Self-callback: target is address(this), chain is reactiveChainId - bytes memory payload = abi.encodeWithSignature( - "deliver(address,uint256)", - address(0), // RVM ID placeholder - amount - ); - emit Callback(reactiveChainId, address(this), 500000, payload); - } - - /// @notice Callback entry point — called via SERVICE_ADDR for same-chain delivery. - function deliver(address _rvmId, uint256 amount) external { - require(authorizedSenders[msg.sender], "Authorized sender only"); - deliveredRvmId = _rvmId; - deliveredAmount = amount; - deliveryCount++; - } -}