// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {Test, Vm} from "forge-std/Test.sol"; import {ObseroWatchBoard} from "../src/ObseroWatchBoard.sol"; /// Stand-in for the ArbSys precompile so the chain-block path can be exercised locally. contract FakeArbSys { uint256 public n; function set(uint256 v) external { n = v; } function arbBlockNumber() external view returns (uint256) { return n; } } /// ArbSys stand-in that burns all gas it gets (a broken precompile must not take the transaction with it). contract GreedyArbSys { fallback() external { while (true) {} } } contract ObseroWatchBoardTest is Test { ObseroWatchBoard board; address constant ALICE = address(0xA11CE); address constant BOB = address(0xB0B); address constant TARGET = address(0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168); address constant TARGET2 = address(0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73); bytes constant RULES = bytes('{"v":1,"events":["Transfer"],"alerts":[{"k":"value_over","n":"1000"}]}'); bytes constant RULES2 = bytes('{"v":1,"events":["Approval"],"alerts":[]}'); event PresetPublished(uint256 indexed id, address indexed publisher, address indexed target, bytes32 rulesHash, uint64 publishedBlock, string label, bytes rules); event PresetRetired(uint256 indexed id, address indexed publisher, uint64 retiredBlock); function setUp() public { vm.roll(1000); vm.warp(1_700_000_000); board = new ObseroWatchBoard(); } function _publish(address who, address target, string memory label, bytes memory rules) internal returns (uint256) { vm.prank(who); return board.publish(target, label, rules); } function test_publishStoresPresetAndEmits() public { vm.expectEmit(true, true, true, true); emit PresetPublished(1, ALICE, TARGET, keccak256(RULES), 1000, "USDG large transfers", RULES); uint256 id = _publish(ALICE, TARGET, "USDG large transfers", RULES); assertEq(id, 1); assertEq(board.presetCount(), 1); ObseroWatchBoard.Preset memory p = board.preset(1); assertEq(p.publisher, ALICE); assertEq(p.target, TARGET); assertEq(p.publishedBlock, 1000); assertEq(p.publishedAt, 1_700_000_000); assertEq(p.rulesHash, keccak256(RULES)); assertEq(p.rulesLength, RULES.length); assertEq(p.label, "USDG large transfers"); assertFalse(p.retired); assertEq(board.presetOf(ALICE, TARGET, keccak256(RULES)), 1); assertEq(board.presetOf(BOB, TARGET, keccak256(RULES)), 0); assertEq(board.targetPresetCount(TARGET), 1); assertEq(board.publisherPresetCount(ALICE), 1); } function test_rulesInEventHashToStoredHash() public { vm.recordLogs(); _publish(ALICE, TARGET, "x", RULES); Vm.Log[] memory logs = vm.getRecordedLogs(); assertEq(logs.length, 1); (bytes32 h,, string memory label, bytes memory rules) = abi.decode(logs[0].data, (bytes32, uint64, string, bytes)); assertEq(keccak256(rules), h); assertEq(h, board.preset(1).rulesHash); assertEq(label, "x"); } function test_revertsOnZeroTarget() public { vm.expectRevert(ObseroWatchBoard.ZeroTarget.selector); board.publish(address(0), "x", RULES); } function test_revertsOnEmptyLabel() public { vm.expectRevert(abi.encodeWithSelector(ObseroWatchBoard.BadLabel.selector, 0)); board.publish(TARGET, "", RULES); } function test_revertsOnLongLabel() public { string memory long = string(new bytes(49)); vm.expectRevert(abi.encodeWithSelector(ObseroWatchBoard.BadLabel.selector, 49)); board.publish(TARGET, long, RULES); } function test_maxLabelAccepted() public { string memory label = string(new bytes(48)); assertEq(board.publish(TARGET, label, RULES), 1); } function test_revertsOnEmptyRules() public { vm.expectRevert(abi.encodeWithSelector(ObseroWatchBoard.BadRules.selector, 0)); board.publish(TARGET, "x", ""); } function test_revertsOnLongRules() public { vm.expectRevert(abi.encodeWithSelector(ObseroWatchBoard.BadRules.selector, 2049)); board.publish(TARGET, "x", new bytes(2049)); } function test_maxRulesAccepted() public { assertEq(board.publish(TARGET, "x", new bytes(2048)), 1); assertEq(board.preset(1).rulesLength, 2048); } function test_samePublisherTargetRulesRefused() public { _publish(ALICE, TARGET, "a", RULES); vm.expectRevert(abi.encodeWithSelector(ObseroWatchBoard.AlreadyPublished.selector, 1)); _publish(ALICE, TARGET, "another label", RULES); } function test_otherPublisherOrTargetOrRulesAllowed() public { _publish(ALICE, TARGET, "a", RULES); assertEq(_publish(BOB, TARGET, "a", RULES), 2); assertEq(_publish(ALICE, TARGET2, "a", RULES), 3); assertEq(_publish(ALICE, TARGET, "a", RULES2), 4); assertEq(board.targetPresetCount(TARGET), 3); assertEq(board.publisherPresetCount(ALICE), 3); } function test_retireByPublisher() public { _publish(ALICE, TARGET, "a", RULES); vm.roll(1005); vm.expectEmit(true, true, false, true); emit PresetRetired(1, ALICE, 1005); vm.prank(ALICE); board.retire(1); assertTrue(board.preset(1).retired); // still blocks a republish of the same rules by the same publisher vm.expectRevert(abi.encodeWithSelector(ObseroWatchBoard.AlreadyPublished.selector, 1)); _publish(ALICE, TARGET, "a", RULES); } function test_retireByOtherRefused() public { _publish(ALICE, TARGET, "a", RULES); vm.prank(BOB); vm.expectRevert(abi.encodeWithSelector(ObseroWatchBoard.NotPublisher.selector, 1)); board.retire(1); } function test_retireTwiceRefused() public { _publish(ALICE, TARGET, "a", RULES); vm.startPrank(ALICE); board.retire(1); vm.expectRevert(abi.encodeWithSelector(ObseroWatchBoard.AlreadyRetired.selector, 1)); board.retire(1); vm.stopPrank(); } function test_retireUnknownRefused() public { vm.expectRevert(abi.encodeWithSelector(ObseroWatchBoard.UnknownPreset.selector, 0)); board.retire(0); vm.expectRevert(abi.encodeWithSelector(ObseroWatchBoard.UnknownPreset.selector, 1)); board.retire(1); } function test_presetUnknownReverts() public { vm.expectRevert(abi.encodeWithSelector(ObseroWatchBoard.UnknownPreset.selector, 0)); board.preset(0); vm.expectRevert(abi.encodeWithSelector(ObseroWatchBoard.UnknownPreset.selector, 1)); board.preset(1); } function test_pagingNewestFirst() public { for (uint256 i = 0; i < 5; i++) _publish(ALICE, TARGET, "p", abi.encodePacked(RULES, i)); uint256[] memory ids = board.targetPresets(TARGET, 0, 3); assertEq(ids.length, 3); assertEq(ids[0], 5); assertEq(ids[2], 3); ids = board.targetPresets(TARGET, 3, 10); assertEq(ids.length, 2); assertEq(ids[0], 2); assertEq(ids[1], 1); assertEq(board.targetPresets(TARGET, 5, 10).length, 0); assertEq(board.targetPresets(TARGET, 0, 0).length, 0); ids = board.publisherPresets(ALICE, 1, 1); assertEq(ids.length, 1); assertEq(ids[0], 4); } function test_pageCappedAtMax() public { for (uint256 i = 0; i < 105; i++) _publish(ALICE, TARGET, "p", abi.encodePacked(i)); assertEq(board.targetPresets(TARGET, 0, 1000).length, 100); assertEq(board.publisherPresets(ALICE, 0, type(uint256).max).length, 100); } function test_rejectsEth() public { vm.deal(ALICE, 1 ether); vm.prank(ALICE); (bool ok,) = address(board).call{value: 1}(""); assertFalse(ok); assertEq(address(board).balance, 0); } function test_chainBlockUsesArbSysWhenPresent() public { FakeArbSys fake = new FakeArbSys(); vm.etch(address(0x64), address(fake).code); FakeArbSys(address(0x64)).set(95_000_123); assertEq(board.chainBlock(), 95_000_123); _publish(ALICE, TARGET, "a", RULES); assertEq(board.preset(1).publishedBlock, 95_000_123); } function test_chainBlockFallsBackWhenArbSysAnswersZero() public { FakeArbSys fake = new FakeArbSys(); vm.etch(address(0x64), address(fake).code); assertEq(board.chainBlock(), 1000); } function test_chainBlockFallsBackWhenArbSysTooLarge() public { FakeArbSys fake = new FakeArbSys(); vm.etch(address(0x64), address(fake).code); FakeArbSys(address(0x64)).set(uint256(type(uint64).max) + 1); assertEq(board.chainBlock(), 1000); } function test_greedyArbSysDoesNotEatTheTransaction() public { GreedyArbSys greedy = new GreedyArbSys(); vm.etch(address(0x64), address(greedy).code); uint256 g = gasleft(); _publish(ALICE, TARGET, "a", RULES); assertLt(g - gasleft(), 1_000_000); assertEq(board.preset(1).publishedBlock, 1000); } function testFuzz_publishRoundTrip(address who, address target, bytes calldata rules, uint8 labelLen) public { vm.assume(target != address(0)); vm.assume(rules.length > 0 && rules.length <= 2048); uint256 n = bound(labelLen, 1, 48); string memory label = string(new bytes(n)); vm.prank(who); uint256 id = board.publish(target, label, rules); ObseroWatchBoard.Preset memory p = board.preset(id); assertEq(p.publisher, who); assertEq(p.target, target); assertEq(p.rulesHash, keccak256(rules)); assertEq(p.rulesLength, rules.length); assertEq(board.presetOf(who, target, keccak256(rules)), id); } function testFuzz_onlyPublisherRetires(address who, address other) public { vm.assume(who != other); vm.prank(who); uint256 id = board.publish(TARGET, "a", RULES); vm.prank(other); vm.expectRevert(abi.encodeWithSelector(ObseroWatchBoard.NotPublisher.selector, id)); board.retire(id); vm.prank(who); board.retire(id); assertTrue(board.preset(id).retired); } }