ownerwatch 0.7.2__tar.gz
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-0.7.2/LICENSE +21 -0
- ownerwatch-0.7.2/PKG-INFO +136 -0
- ownerwatch-0.7.2/README.md +123 -0
- ownerwatch-0.7.2/ownerwatch/__init__.py +3 -0
- ownerwatch-0.7.2/ownerwatch/__main__.py +3 -0
- ownerwatch-0.7.2/ownerwatch/alerts.py +120 -0
- ownerwatch-0.7.2/ownerwatch/cli.py +173 -0
- ownerwatch-0.7.2/ownerwatch/config.py +141 -0
- ownerwatch-0.7.2/ownerwatch/decode.py +160 -0
- ownerwatch-0.7.2/ownerwatch/events.py +340 -0
- ownerwatch-0.7.2/ownerwatch/hosted.py +157 -0
- ownerwatch-0.7.2/ownerwatch/probe.py +468 -0
- ownerwatch-0.7.2/ownerwatch/safe.py +148 -0
- ownerwatch-0.7.2/ownerwatch/watcher.py +301 -0
- ownerwatch-0.7.2/ownerwatch.egg-info/PKG-INFO +136 -0
- ownerwatch-0.7.2/ownerwatch.egg-info/SOURCES.txt +26 -0
- ownerwatch-0.7.2/ownerwatch.egg-info/dependency_links.txt +1 -0
- ownerwatch-0.7.2/ownerwatch.egg-info/entry_points.txt +2 -0
- ownerwatch-0.7.2/ownerwatch.egg-info/requires.txt +3 -0
- ownerwatch-0.7.2/ownerwatch.egg-info/top_level.txt +1 -0
- ownerwatch-0.7.2/pyproject.toml +21 -0
- ownerwatch-0.7.2/setup.cfg +4 -0
- ownerwatch-0.7.2/tests/test_alerts.py +73 -0
- ownerwatch-0.7.2/tests/test_decode.py +42 -0
- ownerwatch-0.7.2/tests/test_hosted.py +54 -0
- ownerwatch-0.7.2/tests/test_probe.py +144 -0
- ownerwatch-0.7.2/tests/test_safe.py +125 -0
- ownerwatch-0.7.2/tests/test_watcher.py +222 -0
ownerwatch-0.7.2/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 imal1ice
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ownerwatch
|
|
3
|
+
Version: 0.7.2
|
|
4
|
+
Summary: Self-hosted monitor that alerts when admin keys, roles, proxies, pausers or treasuries of EVM contracts change.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: web3>=7
|
|
10
|
+
Requires-Dist: pyyaml>=6
|
|
11
|
+
Requires-Dist: requests>=2.31
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# ownerwatch
|
|
15
|
+
|
|
16
|
+
[](https://github.com/imal1ice/ownerwatch/actions/workflows/ci.yml) [](https://github.com/imal1ice/ownerwatch/releases) 
|
|
17
|
+
|
|
18
|
+
Self-hosted monitor that alerts you the moment the **admin surface** of your EVM contracts changes.
|
|
19
|
+
|
|
20
|
+
Most protocol losses in 2025-2026 started with a key, a role or a proxy changing hands, not with a clever exploit. ownerwatch watches the events that mean "someone just gained control" and pushes them to Discord, Slack, Telegram or any webhook within seconds. No account, no SaaS, no third party holding your contract list. One YAML file and a Docker container.
|
|
21
|
+
|
|
22
|
+
## What it watches
|
|
23
|
+
|
|
24
|
+
Every address in your config gets the full catalog:
|
|
25
|
+
|
|
26
|
+
| Standard | Events | Severity |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| Ownable / Ownable2Step | OwnershipTransferred, OwnershipTransferStarted | critical / high |
|
|
29
|
+
| AccessControl | RoleGranted, RoleRevoked, RoleAdminChanged | high / critical |
|
|
30
|
+
| ERC-1967 proxies | Upgraded, AdminChanged, BeaconUpgraded | critical |
|
|
31
|
+
| Pausable | Paused, Unpaused | medium |
|
|
32
|
+
| TimelockController | CallScheduled, CallExecuted, Cancelled, MinDelayChange | high / critical |
|
|
33
|
+
| Legacy Timelock (Compound, Uniswap and forks) | NewAdmin, NewPendingAdmin, NewDelay, QueueTransaction, ExecuteTransaction, CancelTransaction | medium to critical |
|
|
34
|
+
| Governor (OpenZeppelin and GovernorBravo) | ProposalCreated, ProposalQueued, ProposalExecuted, ProposalCanceled, VotingDelaySet, VotingPeriodSet, ProposalThresholdSet, QuorumNumeratorUpdated, TimelockChange, NewImplementation | medium to critical |
|
|
35
|
+
| Aragon OS (Lido and others) | SetApp, SetPermission, ChangePermissionManager, StartVote, ExecuteVote | medium to critical |
|
|
36
|
+
| Safe | AddedOwner, RemovedOwner, ChangedThreshold, EnabledModule, DisabledModule, ChangedGuard, ChangedFallbackHandler, ExecutionFromModuleSuccess, ExecutionSuccess, ExecutionFailure | info to critical |
|
|
37
|
+
| ERC-20 treasuries | Transfer out above a threshold you set | high |
|
|
38
|
+
| NFT treasuries | Any ERC-721 or ERC-1155 transfer out | high |
|
|
39
|
+
| Native balance | ETH balance of a treasury falls by more than a threshold between polls | high |
|
|
40
|
+
|
|
41
|
+
**Safe queues.** Add a Safe under `safes:` and ownerwatch polls the Safe Transaction Service: you get an alert when a transaction is proposed, and a critical one when it has collected enough signatures to execute. That is the moment to act; the on-chain events above are the record of what already happened.
|
|
42
|
+
|
|
43
|
+
Timelock and Safe-queue alerts show the call being made: full function and arguments when the target is verified on Sourcify, otherwise the signature from the openchain and 4byte databases, otherwise the raw selector. Set `explorer_url` and every alert carries a clickable transaction link.
|
|
44
|
+
|
|
45
|
+
Run `ownerwatch events` to print the catalog.
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install git+https://github.com/imal1ice/ownerwatch
|
|
51
|
+
cp config.example.yaml config.yaml # add your contracts and a Discord/Telegram target
|
|
52
|
+
ownerwatch run --config config.yaml
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Or with Docker:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
docker compose up -d # uses ./config.yaml, keeps state in a named volume
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
A hardened systemd unit is in `deploy/ownerwatch.service`.
|
|
62
|
+
|
|
63
|
+
Several chains in one process: put shared settings at the top level and a `chains:` list underneath, see `examples/multichain.yaml`.
|
|
64
|
+
|
|
65
|
+
**Don't know what to watch?** Point `probe` at your main contract and it walks the owner/admin/proxy chain, tells you who can upgrade or pause it, flags EOAs and 1-of-N Safes, and prints a config:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
ownerwatch probe --rpc https://eth.drpc.org --safe-chain eth --label Acme 0xYourProxy 0xYourSafe
|
|
69
|
+
ownerwatch probe --rpc https://eth.drpc.org --roles-from-logs 0xAccessControlContract # every current role holder, replayed from logs since deployment
|
|
70
|
+
ownerwatch probe --config config.yaml --markdown --label Acme > admin-surface.md # monthly report of everything you watch
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`--roles-from-logs` finds the creation block on Sourcify and reads the history from a free public log gateway on Ethereum, Optimism, Arbitrum, Gnosis and Polygon; on Base (and for any chain without one) pass `--logs-rpc` with an RPC that serves wide `eth_getLogs` ranges, or `--from-block` to bound the scan.
|
|
74
|
+
|
|
75
|
+
Want that check in CI? `examples/github-action-drift.yml` re-probes weekly and fails when a signer, delay or proxy admin changes from the committed report.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Other commands:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
ownerwatch validate # check the config, the RPC and that every address has code
|
|
84
|
+
ownerwatch test-alert # push a test message through your sinks
|
|
85
|
+
ownerwatch run --once # single scan, for cron
|
|
86
|
+
ownerwatch backfill --from-block N --to-block M # replay history, prints what you would have seen
|
|
87
|
+
ownerwatch events # list the catalog
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
See [docs/what-to-watch.md](docs/what-to-watch.md) for the control-graph model behind the catalog, and [docs/sample-alerts.md](docs/sample-alerts.md) for real alerts replayed from mainnet history.
|
|
91
|
+
|
|
92
|
+
## Examples
|
|
93
|
+
|
|
94
|
+
`examples/` has verified configs for Uniswap, Compound, Arbitrum, Aave and the Optimism Foundation Safe on mainnet. Backfill one to see what alerts look like on real history:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
ownerwatch backfill --config examples/aave-mainnet.yaml --from-block 22000000 --to-block 22050000
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## How it works
|
|
101
|
+
|
|
102
|
+
- Polls `eth_getLogs` for the catalog's topic hashes on your addresses, in chunks that fit public RPCs.
|
|
103
|
+
- Waits `confirmations` blocks behind the head so small reorgs don't produce ghost alerts.
|
|
104
|
+
- Persists a block cursor to `state/`, so a restart resumes where it left off with no duplicates.
|
|
105
|
+
- Alert delivery never stops the poller: a dead webhook is logged and skipped.
|
|
106
|
+
- Set `heartbeat_url` to a dead-man's-switch (healthchecks.io or similar) and you get paged when ownerwatch itself stops polling. Quiet and dead must not look the same.
|
|
107
|
+
- Works on any EVM chain, one process can watch several.
|
|
108
|
+
|
|
109
|
+
## Hosted tier
|
|
110
|
+
|
|
111
|
+
Don't want to run it? I run it for you, per chain, paid in USDC on-chain through `contracts/src/Subscriptions.sol`: approve, call `subscribe(periods, chains, maxCost, configRef)`, send me the config. No invoices, no accounts, no KYC either way. Details in [docs/hosted.md](docs/hosted.md). Deployment address will be listed here once live; until then open an issue. The operator side is `ownerwatch hosted --rpc ... --subscriptions 0x... --configs configs/`: it starts a watcher for every address with an active subscription and stops it when the subscription lapses.
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
cd contracts && forge install foundry-rs/forge-std --no-git && forge test
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Roadmap
|
|
118
|
+
|
|
119
|
+
- Native balance and NFT alerts for Safe queues (currently on-chain only)
|
|
120
|
+
|
|
121
|
+
## Support the project
|
|
122
|
+
|
|
123
|
+
ownerwatch is free and MIT licensed. If it saves you a 3am incident, fund the work:
|
|
124
|
+
|
|
125
|
+
- Drips: https://www.drips.network/app/projects/github/imal1ice/ownerwatch
|
|
126
|
+
- Giveth: https://giveth.io/project/ownerwatch
|
|
127
|
+
- Direct: `0x874D018623EeBb398B898AC7AB0c97506244141B` (Ethereum and all major L2s)
|
|
128
|
+
|
|
129
|
+
Need it deployed, extended or integrated with your alerting stack? Open an issue or reach out on GitHub.
|
|
130
|
+
|
|
131
|
+
## Development
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
python3 -m venv .venv && .venv/bin/pip install -e . pytest
|
|
135
|
+
.venv/bin/pytest # end-to-end test against a local anvil node; needs foundry
|
|
136
|
+
```
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# ownerwatch
|
|
2
|
+
|
|
3
|
+
[](https://github.com/imal1ice/ownerwatch/actions/workflows/ci.yml) [](https://github.com/imal1ice/ownerwatch/releases) 
|
|
4
|
+
|
|
5
|
+
Self-hosted monitor that alerts you the moment the **admin surface** of your EVM contracts changes.
|
|
6
|
+
|
|
7
|
+
Most protocol losses in 2025-2026 started with a key, a role or a proxy changing hands, not with a clever exploit. ownerwatch watches the events that mean "someone just gained control" and pushes them to Discord, Slack, Telegram or any webhook within seconds. No account, no SaaS, no third party holding your contract list. One YAML file and a Docker container.
|
|
8
|
+
|
|
9
|
+
## What it watches
|
|
10
|
+
|
|
11
|
+
Every address in your config gets the full catalog:
|
|
12
|
+
|
|
13
|
+
| Standard | Events | Severity |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| Ownable / Ownable2Step | OwnershipTransferred, OwnershipTransferStarted | critical / high |
|
|
16
|
+
| AccessControl | RoleGranted, RoleRevoked, RoleAdminChanged | high / critical |
|
|
17
|
+
| ERC-1967 proxies | Upgraded, AdminChanged, BeaconUpgraded | critical |
|
|
18
|
+
| Pausable | Paused, Unpaused | medium |
|
|
19
|
+
| TimelockController | CallScheduled, CallExecuted, Cancelled, MinDelayChange | high / critical |
|
|
20
|
+
| Legacy Timelock (Compound, Uniswap and forks) | NewAdmin, NewPendingAdmin, NewDelay, QueueTransaction, ExecuteTransaction, CancelTransaction | medium to critical |
|
|
21
|
+
| Governor (OpenZeppelin and GovernorBravo) | ProposalCreated, ProposalQueued, ProposalExecuted, ProposalCanceled, VotingDelaySet, VotingPeriodSet, ProposalThresholdSet, QuorumNumeratorUpdated, TimelockChange, NewImplementation | medium to critical |
|
|
22
|
+
| Aragon OS (Lido and others) | SetApp, SetPermission, ChangePermissionManager, StartVote, ExecuteVote | medium to critical |
|
|
23
|
+
| Safe | AddedOwner, RemovedOwner, ChangedThreshold, EnabledModule, DisabledModule, ChangedGuard, ChangedFallbackHandler, ExecutionFromModuleSuccess, ExecutionSuccess, ExecutionFailure | info to critical |
|
|
24
|
+
| ERC-20 treasuries | Transfer out above a threshold you set | high |
|
|
25
|
+
| NFT treasuries | Any ERC-721 or ERC-1155 transfer out | high |
|
|
26
|
+
| Native balance | ETH balance of a treasury falls by more than a threshold between polls | high |
|
|
27
|
+
|
|
28
|
+
**Safe queues.** Add a Safe under `safes:` and ownerwatch polls the Safe Transaction Service: you get an alert when a transaction is proposed, and a critical one when it has collected enough signatures to execute. That is the moment to act; the on-chain events above are the record of what already happened.
|
|
29
|
+
|
|
30
|
+
Timelock and Safe-queue alerts show the call being made: full function and arguments when the target is verified on Sourcify, otherwise the signature from the openchain and 4byte databases, otherwise the raw selector. Set `explorer_url` and every alert carries a clickable transaction link.
|
|
31
|
+
|
|
32
|
+
Run `ownerwatch events` to print the catalog.
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install git+https://github.com/imal1ice/ownerwatch
|
|
38
|
+
cp config.example.yaml config.yaml # add your contracts and a Discord/Telegram target
|
|
39
|
+
ownerwatch run --config config.yaml
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or with Docker:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
docker compose up -d # uses ./config.yaml, keeps state in a named volume
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
A hardened systemd unit is in `deploy/ownerwatch.service`.
|
|
49
|
+
|
|
50
|
+
Several chains in one process: put shared settings at the top level and a `chains:` list underneath, see `examples/multichain.yaml`.
|
|
51
|
+
|
|
52
|
+
**Don't know what to watch?** Point `probe` at your main contract and it walks the owner/admin/proxy chain, tells you who can upgrade or pause it, flags EOAs and 1-of-N Safes, and prints a config:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
ownerwatch probe --rpc https://eth.drpc.org --safe-chain eth --label Acme 0xYourProxy 0xYourSafe
|
|
56
|
+
ownerwatch probe --rpc https://eth.drpc.org --roles-from-logs 0xAccessControlContract # every current role holder, replayed from logs since deployment
|
|
57
|
+
ownerwatch probe --config config.yaml --markdown --label Acme > admin-surface.md # monthly report of everything you watch
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`--roles-from-logs` finds the creation block on Sourcify and reads the history from a free public log gateway on Ethereum, Optimism, Arbitrum, Gnosis and Polygon; on Base (and for any chain without one) pass `--logs-rpc` with an RPC that serves wide `eth_getLogs` ranges, or `--from-block` to bound the scan.
|
|
61
|
+
|
|
62
|
+
Want that check in CI? `examples/github-action-drift.yml` re-probes weekly and fails when a signer, delay or proxy admin changes from the committed report.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Other commands:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
ownerwatch validate # check the config, the RPC and that every address has code
|
|
71
|
+
ownerwatch test-alert # push a test message through your sinks
|
|
72
|
+
ownerwatch run --once # single scan, for cron
|
|
73
|
+
ownerwatch backfill --from-block N --to-block M # replay history, prints what you would have seen
|
|
74
|
+
ownerwatch events # list the catalog
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
See [docs/what-to-watch.md](docs/what-to-watch.md) for the control-graph model behind the catalog, and [docs/sample-alerts.md](docs/sample-alerts.md) for real alerts replayed from mainnet history.
|
|
78
|
+
|
|
79
|
+
## Examples
|
|
80
|
+
|
|
81
|
+
`examples/` has verified configs for Uniswap, Compound, Arbitrum, Aave and the Optimism Foundation Safe on mainnet. Backfill one to see what alerts look like on real history:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
ownerwatch backfill --config examples/aave-mainnet.yaml --from-block 22000000 --to-block 22050000
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## How it works
|
|
88
|
+
|
|
89
|
+
- Polls `eth_getLogs` for the catalog's topic hashes on your addresses, in chunks that fit public RPCs.
|
|
90
|
+
- Waits `confirmations` blocks behind the head so small reorgs don't produce ghost alerts.
|
|
91
|
+
- Persists a block cursor to `state/`, so a restart resumes where it left off with no duplicates.
|
|
92
|
+
- Alert delivery never stops the poller: a dead webhook is logged and skipped.
|
|
93
|
+
- Set `heartbeat_url` to a dead-man's-switch (healthchecks.io or similar) and you get paged when ownerwatch itself stops polling. Quiet and dead must not look the same.
|
|
94
|
+
- Works on any EVM chain, one process can watch several.
|
|
95
|
+
|
|
96
|
+
## Hosted tier
|
|
97
|
+
|
|
98
|
+
Don't want to run it? I run it for you, per chain, paid in USDC on-chain through `contracts/src/Subscriptions.sol`: approve, call `subscribe(periods, chains, maxCost, configRef)`, send me the config. No invoices, no accounts, no KYC either way. Details in [docs/hosted.md](docs/hosted.md). Deployment address will be listed here once live; until then open an issue. The operator side is `ownerwatch hosted --rpc ... --subscriptions 0x... --configs configs/`: it starts a watcher for every address with an active subscription and stops it when the subscription lapses.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
cd contracts && forge install foundry-rs/forge-std --no-git && forge test
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Roadmap
|
|
105
|
+
|
|
106
|
+
- Native balance and NFT alerts for Safe queues (currently on-chain only)
|
|
107
|
+
|
|
108
|
+
## Support the project
|
|
109
|
+
|
|
110
|
+
ownerwatch is free and MIT licensed. If it saves you a 3am incident, fund the work:
|
|
111
|
+
|
|
112
|
+
- Drips: https://www.drips.network/app/projects/github/imal1ice/ownerwatch
|
|
113
|
+
- Giveth: https://giveth.io/project/ownerwatch
|
|
114
|
+
- Direct: `0x874D018623EeBb398B898AC7AB0c97506244141B` (Ethereum and all major L2s)
|
|
115
|
+
|
|
116
|
+
Need it deployed, extended or integrated with your alerting stack? Open an issue or reach out on GitHub.
|
|
117
|
+
|
|
118
|
+
## Development
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
python3 -m venv .venv && .venv/bin/pip install -e . pytest
|
|
122
|
+
.venv/bin/pytest # end-to-end test against a local anvil node; needs foundry
|
|
123
|
+
```
|
|
@@ -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()
|
|
@@ -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())
|