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 +3 -0
- ownerwatch/__main__.py +3 -0
- ownerwatch/alerts.py +120 -0
- ownerwatch/cli.py +173 -0
- ownerwatch/config.py +141 -0
- ownerwatch/decode.py +160 -0
- ownerwatch/events.py +340 -0
- ownerwatch/hosted.py +157 -0
- ownerwatch/probe.py +468 -0
- ownerwatch/safe.py +148 -0
- ownerwatch/watcher.py +301 -0
- ownerwatch-0.7.2.dist-info/METADATA +136 -0
- ownerwatch-0.7.2.dist-info/RECORD +17 -0
- ownerwatch-0.7.2.dist-info/WHEEL +5 -0
- ownerwatch-0.7.2.dist-info/entry_points.txt +2 -0
- ownerwatch-0.7.2.dist-info/licenses/LICENSE +21 -0
- ownerwatch-0.7.2.dist-info/top_level.txt +1 -0
ownerwatch/__init__.py
ADDED
ownerwatch/__main__.py
ADDED
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
|