ownerwatch 0.7.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ownerwatch/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ownerwatch: alert when the admin surface of an EVM contract changes."""
2
+
3
+ __version__ = "0.7.2"
ownerwatch/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
ownerwatch/alerts.py ADDED
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import sys
6
+ from dataclasses import asdict, dataclass
7
+
8
+ import requests
9
+
10
+ from .config import Alerts
11
+ from .events import severity_rank
12
+
13
+ log = logging.getLogger("ownerwatch")
14
+
15
+
16
+ @dataclass
17
+ class Alert:
18
+ severity: str
19
+ chain: str
20
+ contract: str
21
+ label: str
22
+ event: str
23
+ source: str
24
+ message: str
25
+ block: int
26
+ tx_hash: str
27
+
28
+ def text(self) -> str:
29
+ return (f"[{self.severity.upper()}] {self.label} ({self.contract}) on {self.chain}\n"
30
+ f"{self.source}.{self.event}: {self.message}\n"
31
+ f"block {self.block} tx {self.tx_hash}")
32
+
33
+
34
+ class Dispatcher:
35
+ """Fan an Alert out to every configured sink. Never raises: a dead
36
+ webhook must not stop the poller."""
37
+
38
+ def __init__(self, cfg: Alerts, sinks: list | None = None):
39
+ self.cfg = cfg
40
+ self.min_rank = severity_rank(cfg.min_severity)
41
+ self.extra_sinks = sinks or [] # callables, used by tests
42
+ self.counts: dict[str, int] = {} # severity -> alerts sent today
43
+ self.digest_day: str | None = None
44
+
45
+ def send(self, alert: Alert) -> None:
46
+ print(alert.text(), file=sys.stdout, flush=True)
47
+ self.counts[alert.severity] = self.counts.get(alert.severity, 0) + 1
48
+ if severity_rank(alert.severity) < self.min_rank:
49
+ return
50
+ for fn in self.extra_sinks:
51
+ fn(alert)
52
+ text = alert.text()
53
+ for name, fn in (("discord", self._discord), ("slack", self._slack), ("telegram", self._telegram), ("webhook", self._webhook)):
54
+ try:
55
+ fn(alert, text)
56
+ except Exception as e: # noqa: BLE001
57
+ log.warning("%s delivery failed: %s", name, e)
58
+
59
+ def maybe_digest(self, chain: str, head: int, today: str | None = None) -> bool:
60
+ """At the first poll of each UTC day, post yesterday's summary. Returns True when sent."""
61
+ import datetime as _dt
62
+ today = today or _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%d")
63
+ if not self.cfg.daily_digest:
64
+ return False
65
+ if self.digest_day is None:
66
+ self.digest_day = today
67
+ return False
68
+ if today == self.digest_day:
69
+ return False
70
+ summary = ", ".join(f"{k}: {self.counts.get(k, 0)}" for k in ("critical", "high", "medium", "info"))
71
+ self.send(Alert("info", chain, "-", "ownerwatch", "DailyDigest", "ownerwatch",
72
+ f"digest for {self.digest_day}: {summary}; watcher alive, head block {head}", head, "-"))
73
+ self.counts = {}
74
+ self.digest_day = today
75
+ return True
76
+
77
+ def heartbeat(self) -> None:
78
+ """Ping the dead-man's-switch URL. Silence there means the watcher is down."""
79
+ if not self.cfg.heartbeat_url:
80
+ return
81
+ try:
82
+ requests.get(self.cfg.heartbeat_url, timeout=10)
83
+ except Exception as e: # noqa: BLE001
84
+ log.warning("heartbeat failed: %s", e)
85
+
86
+ @staticmethod
87
+ def _clip(text: str, limit: int) -> str:
88
+ return text if len(text) <= limit else text[: limit - 1] + "…"
89
+
90
+ COLORS = {"info": 0x95A5A6, "medium": 0xF1C40F, "high": 0xE67E22, "critical": 0xE74C3C}
91
+
92
+ def _discord(self, alert: Alert, text: str) -> None:
93
+ if self.cfg.discord_webhook:
94
+ embed = {
95
+ "title": self._clip(f"[{alert.severity.upper()}] {alert.label}: {alert.source}.{alert.event}", 256),
96
+ "description": self._clip(alert.message, 4096),
97
+ "color": self.COLORS.get(alert.severity, 0x95A5A6),
98
+ "fields": [
99
+ {"name": "Contract", "value": f"`{alert.contract}`", "inline": True},
100
+ {"name": "Chain", "value": alert.chain, "inline": True},
101
+ {"name": "Block", "value": str(alert.block), "inline": True},
102
+ {"name": "Tx", "value": self._clip(alert.tx_hash, 1024), "inline": False},
103
+ ],
104
+ }
105
+ requests.post(self.cfg.discord_webhook, json={"embeds": [embed]}, timeout=10).raise_for_status()
106
+
107
+ def _slack(self, alert: Alert, text: str) -> None:
108
+ if self.cfg.slack_webhook:
109
+ requests.post(self.cfg.slack_webhook, json={"text": self._clip(text, 3000)}, timeout=10).raise_for_status()
110
+
111
+ def _telegram(self, alert: Alert, text: str) -> None:
112
+ if self.cfg.telegram_bot_token and self.cfg.telegram_chat_id:
113
+ requests.post(f"https://api.telegram.org/bot{self.cfg.telegram_bot_token}/sendMessage",
114
+ json={"chat_id": self.cfg.telegram_chat_id, "text": self._clip(text, 4096),
115
+ "disable_web_page_preview": True}, timeout=10).raise_for_status()
116
+
117
+ def _webhook(self, alert: Alert, text: str) -> None:
118
+ if self.cfg.webhook_url:
119
+ requests.post(self.cfg.webhook_url, data=json.dumps(asdict(alert)),
120
+ headers={"content-type": "application/json"}, timeout=10).raise_for_status()
ownerwatch/cli.py ADDED
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import sys
6
+
7
+ from . import __version__
8
+ from .alerts import Dispatcher
9
+ from .config import load_all
10
+ from .events import CATALOG
11
+ from .watcher import Watcher
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> int:
15
+ p = argparse.ArgumentParser(prog="ownerwatch", description="Alert when an EVM contract's admin surface changes.")
16
+ p.add_argument("--version", action="version", version=__version__)
17
+ sub = p.add_subparsers(dest="cmd", required=True)
18
+ r = sub.add_parser("run", help="poll forever and alert")
19
+ r.add_argument("--config", default="config.yaml")
20
+ r.add_argument("--once", action="store_true", help="scan once and exit (for cron)")
21
+ b = sub.add_parser("backfill", help="scan a historical block range and print alerts")
22
+ b.add_argument("--config", default="config.yaml")
23
+ b.add_argument("--from-block", type=int, required=True)
24
+ b.add_argument("--to-block", type=int, required=True)
25
+ b.add_argument("--chain", default=None, help="chain_name to backfill when the config has several chains")
26
+ sub.add_parser("events", help="list the events in the catalog")
27
+ va = sub.add_parser("validate", help="check a config file and print what it would watch")
28
+ va.add_argument("--config", default="config.yaml")
29
+ ta = sub.add_parser("test-alert", help="send a test alert through every configured sink")
30
+ ta.add_argument("--config", default="config.yaml")
31
+ h = sub.add_parser("hosted", help="run watchers for every address with an active on-chain subscription")
32
+ h.add_argument("--rpc", required=True, help="RPC of the chain the Subscriptions contract lives on")
33
+ h.add_argument("--subscriptions", required=True, help="Subscriptions contract address")
34
+ h.add_argument("--configs", default="configs", help="directory of <customer-address>.yaml files")
35
+ h.add_argument("--check-interval", type=int, default=60)
36
+ pr = sub.add_parser("probe", help="inspect the admin surface of addresses and suggest a config")
37
+ pr.add_argument("addresses", nargs="*")
38
+ pr.add_argument("--rpc", default=None)
39
+ pr.add_argument("--config", default=None, help="probe every contract and Safe listed in this config (per chain)")
40
+ pr.add_argument("--chain-name", default="ethereum")
41
+ pr.add_argument("--safe-chain", default=None, help="Safe Tx Service key (eth, base, arb1, oeth, ...) to add safes: entries")
42
+ pr.add_argument("--label", default="Protocol")
43
+ pr.add_argument("--yaml", action="store_true", help="print only the suggested config")
44
+ pr.add_argument("--markdown", action="store_true", help="print a short admin-surface report in markdown")
45
+ pr.add_argument("--json", action="store_true", help="print the probed nodes as JSON")
46
+ pr.add_argument("--roles-from-logs", action="store_true",
47
+ help="replay RoleGranted/RoleRevoked logs to list every current role holder (needs an RPC with log history)")
48
+ pr.add_argument("--from-block", type=int, default=None,
49
+ help="with --roles-from-logs: first block to scan (default: creation block from Sourcify or a code search)")
50
+ pr.add_argument("--logs-rpc", default=None,
51
+ help="with --roles-from-logs: RPC used only for eth_getLogs, e.g. https://rpc.flashbots.net (100k blocks per call)")
52
+ a = p.parse_args(argv)
53
+
54
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", stream=sys.stderr)
55
+
56
+ if a.cmd == "events":
57
+ for s in CATALOG:
58
+ print(f"{s.severity:8} {s.source:18} {s.signature}")
59
+ return 0
60
+
61
+ if a.cmd == "hosted":
62
+ from .hosted import HostedRunner
63
+ HostedRunner(a.rpc, a.subscriptions, a.configs, a.check_interval).run_forever()
64
+ return 0
65
+
66
+ if a.cmd == "probe":
67
+ from web3 import Web3
68
+ from .probe import Prober, describe, markdown_report, suggest_config
69
+ if a.config:
70
+ # one report per chain, addresses taken from the config
71
+ for cfg in load_all(a.config):
72
+ prober = Prober(Web3(Web3.HTTPProvider(cfg.rpc_url, request_kwargs={"timeout": 30})))
73
+ addrs = [c.address for c in cfg.contracts] + [s_.address for s_ in cfg.safes]
74
+ roots = [prober.probe(x) for x in dict.fromkeys(addrs)]
75
+ prober.check_roles(list(prober.seen.keys()))
76
+ if a.markdown:
77
+ print(markdown_report(prober, a.label, cfg.chain_name, roots))
78
+ elif a.json:
79
+ import dataclasses, json as _json
80
+ print(_json.dumps({"chain": cfg.chain_name, "nodes": [dataclasses.asdict(n) for n in prober.seen.values()]}, indent=2))
81
+ else:
82
+ print(f"# {cfg.chain_name}")
83
+ for n in prober.seen.values():
84
+ print(describe(n))
85
+ return 0
86
+ if not a.rpc or not a.addresses:
87
+ p.error("probe needs --rpc and at least one address, or --config")
88
+ prober = Prober(Web3(Web3.HTTPProvider(a.rpc, request_kwargs={"timeout": 30})))
89
+ roots = [prober.probe(x) for x in a.addresses]
90
+ prober.check_roles(list(prober.seen.keys()))
91
+ if a.roles_from_logs:
92
+ logs_w3 = Web3(Web3.HTTPProvider(a.logs_rpc, request_kwargs={"timeout": 60})) if a.logs_rpc else None
93
+ for x in a.addresses:
94
+ try:
95
+ prober.role_history(x, a.from_block, logs_w3, log=lambda m: print(m, file=sys.stderr))
96
+ except Exception as e: # noqa: BLE001
97
+ print(f"# role history for {x} failed: {e}", file=sys.stderr)
98
+ if a.markdown:
99
+ print(markdown_report(prober, a.label, a.chain_name, roots))
100
+ return 0
101
+ if a.json:
102
+ import dataclasses, json as _json
103
+ print(_json.dumps({"chain": a.chain_name, "nodes": [dataclasses.asdict(n) for n in prober.seen.values()]}, indent=2))
104
+ return 0
105
+ if not a.yaml:
106
+ for n in prober.seen.values():
107
+ print(describe(n))
108
+ print("\n# suggested config\n")
109
+ print(suggest_config(prober, a.rpc, a.chain_name, a.safe_chain, a.label))
110
+ return 0
111
+
112
+ try:
113
+ cfgs = load_all(a.config)
114
+ except Exception as e: # noqa: BLE001
115
+ print(f"config error: {e}", file=sys.stderr)
116
+ return 2
117
+ if a.cmd == "validate":
118
+ from web3 import Web3
119
+ for cfg in cfgs:
120
+ sinks = [k for k in ("discord_webhook", "slack_webhook", "telegram_bot_token", "webhook_url") if getattr(cfg.alerts, k)]
121
+ print(f"{cfg.chain_name}: {len(cfg.contracts)} contracts, {len(cfg.treasuries)} treasuries, {len(cfg.safes)} safes, sinks: {', '.join(sinks) or 'stdout only'}")
122
+ try:
123
+ w3 = Web3(Web3.HTTPProvider(cfg.rpc_url, request_kwargs={"timeout": 15}))
124
+ print(f" rpc ok, chain id {w3.eth.chain_id}, head {w3.eth.block_number}")
125
+ for c in cfg.contracts:
126
+ if len(w3.eth.get_code(c.address)) == 0:
127
+ print(f" WARNING {c.label} {c.address} has no code on this chain")
128
+ except Exception as e: # noqa: BLE001
129
+ print(f" rpc FAILED: {e}")
130
+ return 0
131
+ if a.cmd == "test-alert":
132
+ from .alerts import Alert
133
+ for cfg in cfgs:
134
+ Dispatcher(cfg.alerts).send(Alert("info", cfg.chain_name, "0x" + "00" * 20, "ownerwatch", "TestAlert", "ownerwatch",
135
+ "test alert: your sinks are wired up", 0, "-"))
136
+ return 0
137
+ if a.cmd == "backfill":
138
+ if len(cfgs) > 1 and not a.chain:
139
+ p.error("--chain is required for a multi-chain config")
140
+ if a.chain:
141
+ matches = [c for c in cfgs if c.chain_name == a.chain]
142
+ if not matches:
143
+ p.error(f"no chain named {a.chain!r} in config (have: {', '.join(c.chain_name for c in cfgs)})")
144
+ cfg = matches[0]
145
+ else:
146
+ cfg = cfgs[0]
147
+ w = Watcher(cfg, Dispatcher(cfg.alerts))
148
+ n, start = 0, a.from_block
149
+ while start <= a.to_block:
150
+ end = min(start + cfg.max_block_range - 1, a.to_block)
151
+ n += w._scan(start, end)
152
+ start = end + 1
153
+ print(f"{n} alerts in blocks {a.from_block}-{a.to_block}", file=sys.stderr)
154
+ return 0
155
+ watchers = [Watcher(c, Dispatcher(c.alerts)) for c in cfgs]
156
+ if a.once:
157
+ for w in watchers:
158
+ w.poll_once()
159
+ return 0
160
+ if len(watchers) == 1:
161
+ watchers[0].run_forever()
162
+ return 0
163
+ import threading
164
+ threads = [threading.Thread(target=w.run_forever, name=w.cfg.chain_name, daemon=True) for w in watchers]
165
+ for t in threads:
166
+ t.start()
167
+ for t in threads:
168
+ t.join()
169
+ return 0
170
+
171
+
172
+ if __name__ == "__main__":
173
+ sys.exit(main())
ownerwatch/config.py ADDED
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+ from web3 import Web3
8
+
9
+
10
+ @dataclass
11
+ class Token:
12
+ address: str
13
+ symbol: str
14
+ decimals: int
15
+ min_amount: float
16
+
17
+
18
+ @dataclass
19
+ class Collection:
20
+ address: str
21
+ label: str
22
+
23
+
24
+ @dataclass
25
+ class Treasury:
26
+ address: str
27
+ label: str
28
+ tokens: list[Token]
29
+ nfts: list[Collection] = field(default_factory=list)
30
+ min_native_drop: float | None = None # alert if native balance falls by >= this many ETH between polls
31
+
32
+
33
+ @dataclass
34
+ class Contract:
35
+ address: str
36
+ label: str
37
+
38
+
39
+ @dataclass
40
+ class Safe:
41
+ address: str
42
+ label: str
43
+ chain: str = "eth"
44
+
45
+
46
+ @dataclass
47
+ class Alerts:
48
+ discord_webhook: str = ""
49
+ slack_webhook: str = ""
50
+ telegram_bot_token: str = ""
51
+ telegram_chat_id: str = ""
52
+ webhook_url: str = ""
53
+ heartbeat_url: str = "" # GET after every successful poll (healthchecks.io, Better Uptime, cron monitors)
54
+ daily_digest: bool = False # once a day (UTC) post a one-line summary: alerts by severity, head block, uptime
55
+ min_severity: str = "info"
56
+
57
+
58
+ @dataclass
59
+ class Config:
60
+ rpc_url: str
61
+ chain_name: str = "ethereum"
62
+ explorer_url: str = "" # e.g. https://etherscan.io; alerts then carry a clickable tx link
63
+ poll_interval_seconds: int = 15
64
+ confirmations: int = 2
65
+ max_block_range: int = 2000
66
+ start_block: int | str = "latest"
67
+ state_file: str = "state/cursor.json"
68
+ contracts: list[Contract] = field(default_factory=list)
69
+ treasuries: list[Treasury] = field(default_factory=list)
70
+ safes: list[Safe] = field(default_factory=list)
71
+ safe_api_key: str = ""
72
+ alerts: Alerts = field(default_factory=Alerts)
73
+
74
+ def labels(self) -> dict[str, str]:
75
+ out = {c.address: c.label for c in self.contracts}
76
+ out.update({t.address: t.label for t in self.treasuries})
77
+ out.update({s.address: s.label for s in self.safes})
78
+ return out
79
+
80
+
81
+ def _addr(a: str) -> str:
82
+ return Web3.to_checksum_address(a)
83
+
84
+
85
+ SHARED_KEYS = ("explorer_url", "poll_interval_seconds", "confirmations", "max_block_range", "start_block", "alerts", "safe_api_key")
86
+
87
+
88
+ def load_all(path: str | Path) -> list[Config]:
89
+ """One Config per chain. A file with a top-level `chains:` list yields one
90
+ Config per entry; keys in SHARED_KEYS are inherited from the top level."""
91
+ raw = yaml.safe_load(Path(path).read_text()) or {}
92
+ if "chains" not in raw:
93
+ return [_build(raw)]
94
+ out = []
95
+ for entry in raw["chains"] or []:
96
+ merged = {k: raw[k] for k in SHARED_KEYS if k in raw}
97
+ merged.update(entry)
98
+ merged.setdefault("state_file", f"state/{merged.get('chain_name', 'chain')}.json")
99
+ out.append(_build(merged))
100
+ if not out:
101
+ raise ValueError("chains: is empty")
102
+ return out
103
+
104
+
105
+ def load(path: str | Path) -> Config:
106
+ cfgs = load_all(path)
107
+ if len(cfgs) > 1:
108
+ raise ValueError("multi-chain config: use load_all()")
109
+ return cfgs[0]
110
+
111
+
112
+ def _build(raw: dict) -> Config:
113
+ contracts = [Contract(_addr(c["address"]), c.get("label", c["address"])) for c in raw.get("contracts", []) or []]
114
+ treasuries = []
115
+ for t in raw.get("treasuries", []) or []:
116
+ tokens = [Token(_addr(k["address"]), k.get("symbol", "?"), int(k.get("decimals", 18)), float(k.get("min_amount", 0)))
117
+ for k in t.get("tokens", []) or []]
118
+ nfts = [Collection(_addr(n["address"]), n.get("label", n["address"])) for n in t.get("nfts", []) or []]
119
+ drop = t.get("min_native_drop")
120
+ treasuries.append(Treasury(_addr(t["address"]), t.get("label", t["address"]), tokens, nfts,
121
+ float(drop) if drop is not None else None))
122
+ safes = [Safe(_addr(x["address"]), x.get("label", x["address"]), x.get("chain", "eth")) for x in raw.get("safes", []) or []]
123
+ alerts = Alerts(**{k: v for k, v in (raw.get("alerts") or {}).items() if k in Alerts.__dataclass_fields__})
124
+ cfg = Config(
125
+ rpc_url=raw["rpc_url"],
126
+ chain_name=raw.get("chain_name", "ethereum"),
127
+ explorer_url=str(raw.get("explorer_url", "") or ""),
128
+ poll_interval_seconds=int(raw.get("poll_interval_seconds", 15)),
129
+ confirmations=int(raw.get("confirmations", 2)),
130
+ max_block_range=int(raw.get("max_block_range", 2000)),
131
+ start_block=raw.get("start_block", "latest"),
132
+ state_file=raw.get("state_file", "state/cursor.json"),
133
+ contracts=contracts,
134
+ treasuries=treasuries,
135
+ safes=safes,
136
+ safe_api_key=str(raw.get("safe_api_key", "") or ""),
137
+ alerts=alerts,
138
+ )
139
+ if not cfg.contracts and not cfg.treasuries and not cfg.safes:
140
+ raise ValueError("config has nothing to watch: add contracts or treasuries")
141
+ return cfg
ownerwatch/decode.py ADDED
@@ -0,0 +1,160 @@
1
+ """Best-effort decoding of calldata in timelock and Safe alerts.
2
+
3
+ Order of attempts:
4
+ 1. Sourcify (verified ABI for the target on this chain): full function + args.
5
+ 2. openchain.xyz, then 4byte.directory: function signature only.
6
+ 3. Raw selector.
7
+ Every lookup is cached in memory and fully optional; offline you still get
8
+ the selector.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+
14
+ import requests
15
+
16
+ log = logging.getLogger("ownerwatch")
17
+ _sel_cache: dict[str, str | None] = {}
18
+ _abi_cache: dict[tuple[int, str], list | None] = {}
19
+
20
+
21
+ def _raw(data: bytes | str) -> bytes:
22
+ return bytes.fromhex(data[2:]) if isinstance(data, str) else bytes(data)
23
+
24
+
25
+ def selector_name(data: bytes | str) -> str | None:
26
+ raw = _raw(data)
27
+ if len(raw) < 4:
28
+ return None
29
+ sel = "0x" + raw[:4].hex()
30
+ if sel in _sel_cache:
31
+ return _sel_cache[sel]
32
+ name = None
33
+ try:
34
+ r = requests.get("https://api.openchain.xyz/signature-database/v1/lookup",
35
+ params={"function": sel, "filter": "true"}, timeout=5)
36
+ hits = (r.json().get("result", {}).get("function", {}) or {}).get(sel) or []
37
+ if hits:
38
+ name = hits[0]["name"]
39
+ except Exception as e: # noqa: BLE001
40
+ log.debug("openchain lookup failed: %s", e)
41
+ if name is None:
42
+ try:
43
+ r = requests.get("https://www.4byte.directory/api/v1/signatures/",
44
+ params={"hex_signature": sel, "ordering": "created_at"}, timeout=5)
45
+ res = r.json().get("results") or []
46
+ if res:
47
+ name = res[0]["text_signature"]
48
+ except Exception as e: # noqa: BLE001
49
+ log.debug("4byte lookup failed: %s", e)
50
+ _sel_cache[sel] = name
51
+ return name
52
+
53
+
54
+ def verified_abi(chain_id: int, address: str) -> list | None:
55
+ key = (chain_id, address.lower())
56
+ if key in _abi_cache:
57
+ return _abi_cache[key]
58
+ abi = None
59
+ try:
60
+ r = requests.get(f"https://sourcify.dev/server/v2/contract/{chain_id}/{address}", params={"fields": "abi"}, timeout=8)
61
+ if r.status_code == 200:
62
+ abi = r.json().get("abi") or None
63
+ except Exception as e: # noqa: BLE001
64
+ log.debug("sourcify lookup failed: %s", e)
65
+ _abi_cache[key] = abi
66
+ return abi
67
+
68
+
69
+ MAX_ARG = 80 # characters per argument before clipping
70
+ MAX_LIST = 4 # list items shown before "... n more"
71
+
72
+
73
+ def _fmt(v) -> str:
74
+ if isinstance(v, (bytes, bytearray)):
75
+ return _clip("0x" + bytes(v).hex())
76
+ if isinstance(v, dict): # decoded struct
77
+ return "{" + ", ".join(f"{k}={_fmt(x)}" for k, x in v.items()) + "}"
78
+ if isinstance(v, (list, tuple)):
79
+ items = [_fmt(x) for x in v[:MAX_LIST]]
80
+ if len(v) > MAX_LIST:
81
+ items.append(f"... {len(v) - MAX_LIST} more")
82
+ return "[" + ", ".join(items) + "]"
83
+ return _clip(str(v))
84
+
85
+
86
+ def _clip(s: str) -> str:
87
+ return s if len(s) <= MAX_ARG else s[:MAX_ARG - 1] + "…"
88
+
89
+
90
+ IMPL_SLOT = int("0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc", 16)
91
+
92
+
93
+ def implementation_of(w3, address: str) -> str | None:
94
+ """ERC-1967 implementation, else slot 0 if it holds an address (Safe proxies keep the singleton there)."""
95
+ try:
96
+ for slot in (IMPL_SLOT, 0):
97
+ v = bytes(w3.eth.get_storage_at(address, slot))
98
+ if int.from_bytes(v[:12], "big") == 0 and int.from_bytes(v[12:], "big") != 0:
99
+ from web3 import Web3
100
+ cand = Web3.to_checksum_address("0x" + v[12:].hex())
101
+ if len(w3.eth.get_code(cand)) > 0:
102
+ return cand
103
+ except Exception as e: # noqa: BLE001
104
+ log.debug("implementation lookup failed for %s: %s", address, e)
105
+ return None
106
+
107
+
108
+ def _try_abi_decode(abi: list, raw: bytes, sel: str, note: str) -> str | None:
109
+ try:
110
+ from web3 import Web3
111
+ fn, args = Web3().eth.contract(abi=abi).decode_function_input(raw)
112
+ inner = ", ".join(f"{k}={_fmt(v)}" for k, v in args.items())
113
+ return f"{fn.fn_name}({inner}) [{sel}, {note}]"
114
+ except Exception as e: # noqa: BLE001
115
+ log.debug("abi decode failed: %s", e)
116
+ return None
117
+
118
+
119
+ def describe_call(data: bytes | str, chain_id: int | None = None, target: str | None = None, w3=None) -> str:
120
+ raw = _raw(data)
121
+ if not raw:
122
+ return "empty calldata"
123
+ sel = "0x" + raw[:4].hex()
124
+ if chain_id is not None and target:
125
+ abi = verified_abi(chain_id, target)
126
+ if abi:
127
+ out = _try_abi_decode(abi, raw, sel, "verified")
128
+ if out:
129
+ return out
130
+ if w3 is not None:
131
+ impl = implementation_of(w3, target)
132
+ if impl:
133
+ abi = verified_abi(chain_id, impl)
134
+ if abi:
135
+ out = _try_abi_decode(abi, raw, sel, "verified via implementation")
136
+ if out:
137
+ return out
138
+ name = selector_name(raw)
139
+ return f"{name} [{sel}]" if name else f"selector {sel}"
140
+
141
+
142
+ def describe_signature_call(signature: str, data: bytes | str) -> str:
143
+ """GovernorBravo/legacy Timelock style: explicit 'fn(type,type)' plus args-only calldata."""
144
+ raw = _raw(data)
145
+ try:
146
+ from eth_abi import decode as abi_decode
147
+ types_str = signature[signature.index("(") + 1: signature.rindex(")")]
148
+ types = [t.strip() for t in types_str.split(",") if t.strip()]
149
+ if not types:
150
+ return signature
151
+ try:
152
+ values = abi_decode(types, raw)
153
+ except Exception: # noqa: BLE001
154
+ if len(raw) % 32 != 4:
155
+ raise
156
+ values = abi_decode(types, raw[4:]) # proposer included the selector as well as the signature
157
+ return f"{signature[:signature.index('(')]}({', '.join(_fmt(v) for v in values)})"
158
+ except Exception as e: # noqa: BLE001
159
+ log.debug("signature decode failed for %s: %s", signature, e)
160
+ return signature