// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /// @title ObseroWatchBoard /// @notice Public board of monitoring presets. A preset says "watch this address with these rules": the publisher /// names a target address, a short label and the rule set (a small JSON document). The rule bytes travel in /// the PresetPublished event and only their keccak-256 hash is stored, so anyone can fetch the rules from /// the event log, hash them, compare with `rulesHash` and import the preset into their own monitor. /// A preset is a public statement by its publisher. The contract does not check that the rules are useful, /// that the target is safe, or that the label is true. Publishing a preset gives nobody any permission. /// @dev No owner, no admin, no fee, no pause, no upgrade path, no constructor arguments. The contract never holds /// funds: no function is payable and there is no receive or fallback, so ETH transfers revert. /// A publisher can retire their own preset (it stays readable, flagged as retired); nobody else can. /// On Arbitrum-style chains `block.number` reports the parent chain's block, so the chain's own block is read /// from the ArbSys precompile when it answers, and from `block.number` everywhere else (local tests). contract ObseroWatchBoard { struct Preset { address publisher; uint64 publishedBlock; uint32 rulesLength; bool retired; address target; uint64 publishedAt; bytes32 rulesHash; string label; } /// @notice Longest label in bytes. uint256 public constant MAX_LABEL = 48; /// @notice Longest rule document in bytes. uint256 public constant MAX_RULES = 2048; /// @notice Most ids returned by one paged read. uint256 public constant MAX_PAGE = 100; /// @dev ArbSys precompile. `arbBlockNumber()` returns the chain's own block number. address private constant ARB_SYS = address(0x64); bytes4 private constant ARB_BLOCK_NUMBER = 0xa3b1b31d; /// @dev Gas forwarded to ArbSys. The real precompile needs about a thousand; the cap keeps a chain (or a local /// fork) where 0x64 holds failing code from burning the whole transaction's gas. uint256 private constant ARB_SYS_GAS = 30_000; /// @notice Number of presets ever published. Preset ids start at 1. uint256 public presetCount; mapping(uint256 id => Preset) private _presets; /// @dev One preset per (publisher, target, rules hash). mapping(bytes32 key => uint256) private _byPublisher; /// @dev Preset ids per target address, in publishing order. mapping(address target => uint256[]) private _targetPresets; /// @dev Preset ids per publisher, in publishing order. mapping(address publisher => uint256[]) private _publisherPresets; 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); error ZeroTarget(); error BadLabel(uint256 length); error BadRules(uint256 length); error AlreadyPublished(uint256 id); error UnknownPreset(uint256 id); error NotPublisher(uint256 id); error AlreadyRetired(uint256 id); /// @notice Publishes a monitoring preset. /// @param target Address the preset watches (a contract or a wallet). Must not be the zero address. /// @param label Short name, 1 to MAX_LABEL bytes. /// @param rules Rule document, 1 to MAX_RULES bytes. Only its hash is stored; the bytes are in the event. /// @return id The new preset id. function publish(address target, string calldata label, bytes calldata rules) external returns (uint256 id) { if (target == address(0)) revert ZeroTarget(); uint256 labelLength = bytes(label).length; if (labelLength == 0 || labelLength > MAX_LABEL) revert BadLabel(labelLength); if (rules.length == 0 || rules.length > MAX_RULES) revert BadRules(rules.length); bytes32 rulesHash = keccak256(rules); bytes32 key = keccak256(abi.encode(msg.sender, target, rulesHash)); uint256 prior = _byPublisher[key]; if (prior != 0) revert AlreadyPublished(prior); uint64 nowBlock = chainBlock(); id = ++presetCount; _byPublisher[key] = id; _targetPresets[target].push(id); _publisherPresets[msg.sender].push(id); _presets[id] = Preset({ publisher: msg.sender, publishedBlock: nowBlock, rulesLength: uint32(rules.length), retired: false, target: target, publishedAt: uint64(block.timestamp), rulesHash: rulesHash, label: label }); emit PresetPublished(id, msg.sender, target, rulesHash, nowBlock, label, rules); } /// @notice Flags one of your own presets as retired. It stays readable; the site shows it as retired. function retire(uint256 id) external { if (id == 0 || id > presetCount) revert UnknownPreset(id); Preset storage p = _presets[id]; if (p.publisher != msg.sender) revert NotPublisher(id); if (p.retired) revert AlreadyRetired(id); p.retired = true; emit PresetRetired(id, msg.sender, chainBlock()); } /// @notice The preset with id `id`. Reverts for an id that does not exist. function preset(uint256 id) external view returns (Preset memory) { if (id == 0 || id > presetCount) revert UnknownPreset(id); return _presets[id]; } /// @notice The preset `publisher` published for this target and rule hash, or 0 when there is none. function presetOf(address publisher, address target, bytes32 rulesHash) external view returns (uint256) { return _byPublisher[keccak256(abi.encode(publisher, target, rulesHash))]; } /// @notice How many presets exist for `target`. function targetPresetCount(address target) external view returns (uint256) { return _targetPresets[target].length; } /// @notice Up to `limit` (max MAX_PAGE) preset ids for `target`, newest first, skipping the `offset` newest. function targetPresets(address target, uint256 offset, uint256 limit) external view returns (uint256[] memory) { return _page(_targetPresets[target], offset, limit); } /// @notice How many presets `publisher` has published. function publisherPresetCount(address publisher) external view returns (uint256) { return _publisherPresets[publisher].length; } /// @notice Up to `limit` (max MAX_PAGE) preset ids by `publisher`, newest first, skipping the `offset` newest. function publisherPresets(address publisher, uint256 offset, uint256 limit) external view returns (uint256[] memory) { return _page(_publisherPresets[publisher], offset, limit); } /// @notice The chain's current block: ArbSys `arbBlockNumber()` when the precompile answers, else `block.number`. function chainBlock() public view returns (uint64) { (bool ok, bytes memory ret) = ARB_SYS.staticcall{gas: ARB_SYS_GAS}(abi.encodeWithSelector(ARB_BLOCK_NUMBER)); if (ok && ret.length == 32) { uint256 n = abi.decode(ret, (uint256)); if (n != 0 && n <= type(uint64).max) return uint64(n); } return uint64(block.number); } function _page(uint256[] storage all, uint256 offset, uint256 limit) private view returns (uint256[] memory ids) { uint256 n = all.length; if (limit > MAX_PAGE) limit = MAX_PAGE; if (offset >= n || limit == 0) return new uint256[](0); uint256 count = n - offset; if (count > limit) count = limit; ids = new uint256[](count); for (uint256 i = 0; i < count; i++) { ids[i] = all[n - 1 - offset - i]; } } }