From 8478ded5b4050e75377c8f3de5bc39397169fef5 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Wed, 27 May 2026 18:09:44 +0200 Subject: [PATCH] feat(risk): expand risk anchors + add known-address registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - risk_anchors: add Safe self-administration selectors (addOwner, removeOwner, swapOwner, changeThreshold, enableModule=CRITICAL, disableModule, setGuard, setFallbackHandler) plus acceptOwnership() and mint(). Directly relevant to the Safe monitor — these change who/what can move multisig funds. - known_addresses: curated address→label registry wired as the highest-priority resolver backend, ahead of Etherscan/swiss-knife. Seeded with canonical burn/null addresses; _BY_CHAIN is left for per-deployment curation (verified multisigs/EOAs only — no fabricated entries). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_known_addresses.py | 46 +++++++++++++++++++++++++++++++++++ tests/test_risk_anchors.py | 44 ++++++++++++++++++++++++++++++++- utils/address_resolver.py | 8 ++++++ utils/known_addresses.py | 38 +++++++++++++++++++++++++++++ utils/risk_anchors.py | 12 +++++++++ 5 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 tests/test_known_addresses.py create mode 100644 utils/known_addresses.py diff --git a/tests/test_known_addresses.py b/tests/test_known_addresses.py new file mode 100644 index 00000000..c14ccf99 --- /dev/null +++ b/tests/test_known_addresses.py @@ -0,0 +1,46 @@ +"""Tests for utils/known_addresses and its address-resolver integration.""" + +import unittest +from unittest.mock import patch + +from utils import known_addresses +from utils.address_resolver import resolve_address_label + + +class TestKnownAddressesLookup(unittest.TestCase): + def test_chain_agnostic_burn_address(self) -> None: + label = known_addresses.lookup(1, "0x000000000000000000000000000000000000dEaD") + self.assertIn("Burn", label) + + def test_case_insensitive(self) -> None: + self.assertEqual( + known_addresses.lookup(1, "0x000000000000000000000000000000000000DEAD"), + known_addresses.lookup(8453, "0x000000000000000000000000000000000000dead"), + ) + + def test_unknown_returns_empty(self) -> None: + self.assertEqual(known_addresses.lookup(1, "0x" + "ab" * 20), "") + + def test_empty_address(self) -> None: + self.assertEqual(known_addresses.lookup(1, ""), "") + + def test_chain_specific_takes_precedence(self) -> None: + addr = "0x" + "11" * 20 + with patch.dict(known_addresses._BY_CHAIN, {(1, addr): "Yearn yChad"}, clear=False): + self.assertEqual(known_addresses.lookup(1, addr), "Yearn yChad") + # Different chain → no chain-specific entry, falls through to "". + self.assertEqual(known_addresses.lookup(8453, addr), "") + + +class TestResolverIntegration(unittest.TestCase): + def test_known_address_backend_wins(self) -> None: + addr = "0x" + "22" * 20 + # Registry hit should short-circuit before any network backend runs. + with patch.dict(known_addresses._BY_CHAIN, {(1, addr): "Yearn dev multisig"}, clear=False): + with patch("utils.address_resolver._etherscan_backend", return_value="ShouldNotWin") as etherscan: + self.assertEqual(resolve_address_label(1, addr), "Yearn dev multisig") + etherscan.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_risk_anchors.py b/tests/test_risk_anchors.py index da814339..298e0bb1 100644 --- a/tests/test_risk_anchors.py +++ b/tests/test_risk_anchors.py @@ -2,7 +2,9 @@ import unittest -from utils.risk_anchors import RiskAnchor, format_anchors_block, lookup +from eth_utils import function_signature_to_4byte_selector + +from utils.risk_anchors import _ANCHORS, RiskAnchor, format_anchors_block, lookup class TestLookup(unittest.TestCase): @@ -49,5 +51,45 @@ def test_renders_multiple_anchors(self) -> None: self.assertIn("transferOwnership(address)", block) +class TestAnchorRegistryIntegrity(unittest.TestCase): + """Guards over the whole _ANCHORS table.""" + + _VALID_LEVELS = {"LOW", "MEDIUM", "HIGH", "CRITICAL"} + + def test_all_keys_wellformed(self) -> None: + for selector in _ANCHORS: + self.assertTrue(selector.startswith("0x") and len(selector) == 10, f"bad selector {selector}") + self.assertEqual(selector, selector.lower(), f"selector not lowercase: {selector}") + + def test_all_levels_valid(self) -> None: + for selector, anchor in _ANCHORS.items(): + self.assertIn(anchor.level, self._VALID_LEVELS, f"{selector} has invalid level {anchor.level}") + self.assertTrue(anchor.rationale, f"{selector} missing rationale") + + def test_selectors_match_signatures(self) -> None: + # Recompute the selector for each anchored signature to catch typos. + expected = { + "acceptOwnership()": "0x79ba5097", + "mint(address,uint256)": "0x40c10f19", + "addOwnerWithThreshold(address,uint256)": "0x0d582f13", + "removeOwner(address,address,uint256)": "0xf8dc5dd9", + "swapOwner(address,address,address)": "0xe318b52b", + "changeThreshold(uint256)": "0x694e80c3", + "enableModule(address)": "0x610b5925", + "disableModule(address,address)": "0xe009cfde", + "setGuard(address)": "0xe19a9dd9", + "setFallbackHandler(address)": "0xf08a0323", + } + for sig, selector in expected.items(): + self.assertEqual("0x" + function_signature_to_4byte_selector(sig).hex(), selector, sig) + self.assertIn(selector, _ANCHORS, f"{sig} ({selector}) not registered") + + def test_safe_module_is_critical(self) -> None: + # enableModule can move funds with no owner signatures — highest band. + anchor = lookup("0x610b5925") + assert anchor is not None + self.assertEqual(anchor.level, "CRITICAL") + + if __name__ == "__main__": unittest.main() diff --git a/utils/address_resolver.py b/utils/address_resolver.py index 935e9d65..1746306c 100644 --- a/utils/address_resolver.py +++ b/utils/address_resolver.py @@ -19,6 +19,13 @@ Backend = Callable[[int, str], str] +def _known_address_backend(chain_id: int, address: str) -> str: + """Curated registry of multisigs / EOAs / burn addresses. No IO.""" + from utils.known_addresses import lookup + + return lookup(chain_id, address) + + def _safe_utility_backend(chain_id: int, address: str) -> str: """Canonical Safe utilities (MultiSendCallOnly, SignMessageLib, …). No IO.""" from safe.multisend import safe_utility_label @@ -66,6 +73,7 @@ def _etherscan_backend(chain_id: int, address: str) -> str: # actually swaps what the chain calls. Also lets callers `register_backend` a # new function attached to this module and have it resolve correctly. _BACKEND_NAMES: list[str] = [ + "_known_address_backend", "_safe_utility_backend", "_swiss_knife_backend", "_etherscan_backend", diff --git a/utils/known_addresses.py b/utils/known_addresses.py new file mode 100644 index 00000000..da1aeda1 --- /dev/null +++ b/utils/known_addresses.py @@ -0,0 +1,38 @@ +"""Curated address → label registry. + +The highest-priority label source, ahead of Etherscan / swiss-knife, for +addresses those backends don't name usefully — governance multisigs, known +EOAs, and canonical burn addresses. A correct label here lets the LLM reason +about *who* an address is (e.g. ``grantRole`` to a known multisig reads very +differently from ``grantRole`` to an unknown EOA). + +Keys are lowercase hex. Labels are either chain-agnostic (``_CHAIN_AGNOSTIC``, +e.g. burn addresses that mean the same everywhere) or chain-specific +(``_BY_CHAIN``, keyed by ``(chain_id, address)``). + +Populate ``_BY_CHAIN`` per deployment with the multisigs/EOAs you care about — +only add an address you have independently verified, since a wrong label is +worse than none. +""" + +# Same meaning on every chain. +_CHAIN_AGNOSTIC: dict[str, str] = { + "0x0000000000000000000000000000000000000000": "Null address (0x0)", + "0x000000000000000000000000000000000000dead": "Burn address (0x…dEaD)", +} + +# (chain_id, lowercase address) → label. Curate per deployment, e.g.: +# (1, "0x....."): "Yearn yChad (main multisig)", +# (1, "0x....."): "Yearn dev multisig (ySafe)", +_BY_CHAIN: dict[tuple[int, str], str] = {} + + +def lookup(chain_id: int, address: str) -> str: + """Return a curated label for ``address``, or "" if none is registered. + + Chain-specific entries take precedence over chain-agnostic ones. + """ + if not address: + return "" + addr = address.lower() + return _BY_CHAIN.get((chain_id, addr)) or _CHAIN_AGNOSTIC.get(addr, "") diff --git a/utils/risk_anchors.py b/utils/risk_anchors.py index 093095a3..28e0f337 100644 --- a/utils/risk_anchors.py +++ b/utils/risk_anchors.py @@ -40,6 +40,9 @@ class RiskAnchor: # Ownership — irreversible authority change "0xf2fde38b": RiskAnchor("HIGH", "transferOwnership(): hands over full admin control"), "0x715018a6": RiskAnchor("HIGH", "renounceOwnership(): irrevocably abandons admin"), + "0x79ba5097": RiskAnchor("HIGH", "acceptOwnership(): completes an Ownable2Step handover"), + # Token supply + "0x40c10f19": RiskAnchor("MEDIUM", "mint(address,uint256): new supply — elevate to HIGH if large or unbacked"), # Proxy upgrades — replaces all code; impl-diff section should drive the verdict "0x3659cfe6": RiskAnchor("HIGH", "upgradeTo(): replaces all implementation code"), "0x4f1ef286": RiskAnchor("HIGH", "upgradeToAndCall(): replaces code AND runs initializer"), @@ -50,6 +53,15 @@ class RiskAnchor: "0x0e18b681": RiskAnchor("HIGH", "acceptAdmin(): completes an admin handover"), # Diamond / facet operations "0x1f931c1c": RiskAnchor("HIGH", "diamondCut(): replaces/adds/removes selectors — bytecode-level change"), + # Gnosis Safe self-administration — changes who/what can move the multisig's funds + "0x0d582f13": RiskAnchor("HIGH", "addOwnerWithThreshold(): adds a Safe signer"), + "0xf8dc5dd9": RiskAnchor("HIGH", "removeOwner(): removes a Safe signer"), + "0xe318b52b": RiskAnchor("HIGH", "swapOwner(): replaces a Safe signer"), + "0x694e80c3": RiskAnchor("HIGH", "changeThreshold(): changes signatures required to execute"), + "0x610b5925": RiskAnchor("CRITICAL", "enableModule(): a module can move funds with NO owner signatures"), + "0xe009cfde": RiskAnchor("MEDIUM", "disableModule(): removes a module — usually defensive"), + "0xe19a9dd9": RiskAnchor("HIGH", "setGuard(): a guard can permit or block every Safe transaction"), + "0xf08a0323": RiskAnchor("MEDIUM", "setFallbackHandler(): changes the Safe's fallback behavior"), }