Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion maple/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
## Governance Monitoring

- **DAO Multisig** ([`0xd6d4Bcde6c816F17889f1Dd3000aF0261B03a196`](https://etherscan.io/address/0xd6d4Bcde6c816F17889f1Dd3000aF0261B03a196)): Added to [safe monitoring](../safe/README.md). Alerts on queued multisig transactions.
- **Governor Timelock** ([`0x2eFFf88747EB5a3FF00d4d8d0f0800E306C0426b`](https://etherscan.io/address/0x2eFFf88747EB5a3FF00d4d8d0f0800E306C0426b)): Should be added to [cross-protocol timelock monitoring](../timelock/README.md).
- **Governor Timelock** ([`0x2eFFf88747EB5a3FF00d4d8d0f0800E306C0426b`](https://etherscan.io/address/0x2eFFf88747EB5a3FF00d4d8d0f0800E306C0426b)): Added to [cross-protocol timelock monitoring](../timelock/README.md). Alerts on `ProposalScheduled` events.

## Running

Expand Down
38 changes: 24 additions & 14 deletions timelock/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Timelock Monitoring

Monitors all timelock contract types (TimelockController, Aave, Compound, Puffer, Lido) and sends Telegram alerts to protocol-specific channels.
Monitors all timelock contract types (TimelockController, Aave, Compound, Puffer, Lido, Maple) and sends Telegram alerts to protocol-specific channels.

## How It Works

Expand All @@ -13,7 +13,7 @@ The script runs [hourly via GitHub Actions](../.github/workflows/hourly.yml).

## GraphQL Schema

The script queries the unified `TimelockEvent` type from the Envio indexer. The query fetches all timelock types (TimelockController, Aave, Compound, Puffer, Lido) for monitored addresses.
The script queries the unified `TimelockEvent` type from the Envio indexer. The query fetches all timelock types (TimelockController, Aave, Compound, Puffer, Lido, Maple) for monitored addresses.

### Query Structure

Expand Down Expand Up @@ -58,7 +58,7 @@ The `TimelockEvent` type includes fields that vary by timelock type:
**Common fields (all types):**
- **`id`** - Unique identifier: `${chainId}_${blockNumber}_${logIndex}`
- **`timelockAddress`** - Address of the timelock contract
- **`timelockType`** - Type discriminator: `"TimelockController"`, `"Aave"`, `"Compound"`, `"Puffer"`, or `"Lido"`
- **`timelockType`** - Type discriminator: `"TimelockController"`, `"Aave"`, `"Compound"`, `"Puffer"`, `"Lido"`, or `"Maple"`
- **`eventName`** - Original event name (e.g., `"CallScheduled"`, `"ProposalQueued"`, `"QueueTransaction"`, etc.)
- **`chainId`** - Chain ID (1 for Mainnet, 8453 for Base, etc.)
- **`blockNumber`** - Block number where the event was emitted
Expand All @@ -72,21 +72,30 @@ The `TimelockEvent` type includes fields that vary by timelock type:
- **Compound**: `target`, `value`, `data`, `delay` (absolute timestamp/eta), `signature`, `operationId` (txHash)
- **Puffer**: `target`, `data`, `delay` (absolute timestamp/lockedUntil), `operationId` (txHash)
- **Lido**: `creator`, `metadata`, `operationId` (voteId)
- **Maple**: `delay` (absolute timestamp/delayedUntil), `operationId` (proposalId)

For complete field mapping details, see [`detils.md`](./detils.md).

## Monitored Timelocks

| Address | Chain | Protocol | Label |
|---------|-------|----------|-------|
| [0xd823...9ab](https://etherscan.io/address/0xd8236031d8279d82e615af2bfab5fc0127a329ab) | Mainnet | CAP | CAP TimelockController |
| [0x5d8a...556](https://etherscan.io/address/0x5d8a7dc9405f08f14541ba918c1bf7eb2dace556) | Mainnet | RTOKEN | ETH+ Timelock |
| [0x055e...e59](https://etherscan.io/address/0x055e84e7fe8955e2781010b866f10ef6e1e77e59) | Mainnet | LRT | Lombard TimeLock |
| [0xe1f0...d22](https://etherscan.io/address/0xe1f03b7b0ebf84e9b9f62a1db40f1efb8faa7d22) | Mainnet | SILO | Silo TimelockController |
| [0x81f6...cc7](https://etherscan.io/address/0x81f6e9914136da1a1d3b1efd14f7e0761c3d4cc7) | Mainnet | LRT | Renzo(ezETH) TimelockController |
| [0x9f26...761](https://etherscan.io/address/0x9f26d4c958fd811a1f59b01b86be7dffc9d20761) | Mainnet | LRT | EtherFi Timelock |
| [0x49bd...3b1](https://etherscan.io/address/0x49bd9989e31ad35b0a62c20be86335196a3135b1) | Mainnet | LRT | KelpDAO(rsETH) Timelock |
| [0xf817...4f](https://basescan.org/address/0xf817cb3092179083c48c014688d98b72fb61464f) | Base | LRT | superOETH Timelock |
| [0xd8236031d8279d82e615af2bfab5fc0127a329ab](https://etherscan.io/address/0xd8236031d8279d82e615af2bfab5fc0127a329ab) | Mainnet | CAP | CAP TimelockController |
| [0x5d8a7dc9405f08f14541ba918c1bf7eb2dace556](https://etherscan.io/address/0x5d8a7dc9405f08f14541ba918c1bf7eb2dace556) | Mainnet | RTOKEN | ETH+ Timelock |
| [0x055e84e7fe8955e2781010b866f10ef6e1e77e59](https://etherscan.io/address/0x055e84e7fe8955e2781010b866f10ef6e1e77e59) | Mainnet | LRT | Lombard TimeLock |
| [0xe1f03b7b0ebf84e9b9f62a1db40f1efb8faa7d22](https://etherscan.io/address/0xe1f03b7b0ebf84e9b9f62a1db40f1efb8faa7d22) | Mainnet | SILO | Silo TimelockController |
| [0x81f6e9914136da1a1d3b1efd14f7e0761c3d4cc7](https://etherscan.io/address/0x81f6e9914136da1a1d3b1efd14f7e0761c3d4cc7) | Mainnet | LRT | Renzo(ezETH) TimelockController |
| [0x9f26d4c958fd811a1f59b01b86be7dffc9d20761](https://etherscan.io/address/0x9f26d4c958fd811a1f59b01b86be7dffc9d20761) | Mainnet | LRT | EtherFi Timelock |
| [0x49bd9989e31ad35b0a62c20be86335196a3135b1](https://etherscan.io/address/0x49bd9989e31ad35b0a62c20be86335196a3135b1) | Mainnet | LRT | KelpDAO(rsETH) Timelock |
| [0x3d18480cc32b6ab3b833dcabd80e76cfd41c48a9](https://etherscan.io/address/0x3d18480cc32b6ab3b833dcabd80e76cfd41c48a9) | Mainnet | INFINIFI | Infinifi Longtimelock |
| [0x4b174afbed7b98ba01f50e36109eee5e6d327c32](https://etherscan.io/address/0x4b174afbed7b98ba01f50e36109eee5e6d327c32) | Mainnet | INFINIFI | Infinifi Shorttimelock |
| [0x9aee0b04504cef83a65ac3f0e838d0593bcb2bc7](https://etherscan.io/address/0x9aee0b04504cef83a65ac3f0e838d0593bcb2bc7) | Mainnet | AAVE | Aave Governance V3 |
| [0x6d903f6003cca6255d85cca4d3b5e5146dc33925](https://etherscan.io/address/0x6d903f6003cca6255d85cca4d3b5e5146dc33925) | Mainnet | COMP | Compound Timelock |
| [0x2386dc45added673317ef068992f19421b481f4c](https://etherscan.io/address/0x2386dc45added673317ef068992f19421b481f4c) | Mainnet | FLUID | Fluid Timelock |
| [0x3c28b7c7ba1a1f55c9ce66b263b33b204f2126ea](https://etherscan.io/address/0x3c28b7c7ba1a1f55c9ce66b263b33b204f2126ea) | Mainnet | LRT | Puffer Timelock |
| [0x2e59a20f205bb85a89c53f1936454680651e618e](https://etherscan.io/address/0x2e59a20f205bb85a89c53f1936454680651e618e) | Mainnet | LIDO | Lido Timelock |
| [0x2efff88747eb5a3ff00d4d8d0f0800e306c0426b](https://etherscan.io/address/0x2efff88747eb5a3ff00d4d8d0f0800e306c0426b) | Mainnet | MAPLE | Maple GovernorTimelock |
| [0xf817cb3092179083c48c014688d98b72fb61464f](https://basescan.org/address/0xf817cb3092179083c48c014688d98b72fb61464f) | Base | LRT | superOETH Timelock |

## How to Add a New Timelock

Expand Down Expand Up @@ -164,14 +173,15 @@ uv run timelock/timelock_alerts.py
Optional flags:

- `--limit` — max events to fetch per run (default: `100`)
- `--since-seconds` — fallback lookback window when no cache exists (default: `7200` / 2h)
- `--since-seconds` — fallback lookback window when no cache exists (default: `43200` / 12h)
- `--no-cache` — disable caching, always use `--since-seconds` lookback
- `--protocol` — filter to a specific protocol, case-insensitive (e.g. `--protocol MAPLE`)
- `--log-level` — set log verbosity: `DEBUG`, `INFO`, `WARNING`, `ERROR` (default: `WARNING`)

## Caching

The script stores the latest processed `blockTimestamp` in `cache-id.txt` under key `TIMELOCK_LAST_TS`. This value is universal across chains (unlike block numbers) so a single cache entry covers all monitored timelocks. On the first run (or with `--no-cache`), it falls back to querying events from the last 2 hours.
The script stores the latest processed `blockTimestamp` in `cache-id.txt` under key `TIMELOCK_LAST_TS`. This value is universal across chains (unlike block numbers) so a single cache entry covers all monitored timelocks. On the first run (or with `--no-cache`), it falls back to querying events from the last 12 hours.

## Schema Details

For comprehensive information about the unified `TimelockEvent` schema, including field mappings for all supported timelock types (TimelockController, Aave, Compound, Puffer, Lido), see [`detils.md`](./detils.md).
For comprehensive information about the unified `TimelockEvent` schema, including field mappings for all supported timelock types (TimelockController, Aave, Compound, Puffer, Lido, Maple), see [`detils.md`](./detils.md).
29 changes: 25 additions & 4 deletions timelock/timelock_alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class TimelockConfig:
TimelockConfig("0x2386dc45added673317ef068992f19421b481f4c", 1, "FLUID", "Fluid Timelock"),
TimelockConfig("0x3c28b7c7ba1a1f55c9ce66b263b33b204f2126ea", 1, "LRT", "Puffer Timelock"),
TimelockConfig("0x2e59a20f205bb85a89c53f1936454680651e618e", 1, "LIDO", "Lido Timelock"),
TimelockConfig("0x2efff88747eb5a3ff00d4d8d0f0800e306c0426b", 1, "MAPLE", "Maple GovernorTimelock"),
# Chain 8453 - Base
TimelockConfig("0xf817cb3092179083c48c014688d98b72fb61464f", 8453, "LRT", "superOETH Timelock"),
]
Expand Down Expand Up @@ -107,9 +108,10 @@ def format_delay(seconds: int) -> str:
return " ".join(parts)


def load_events(limit: int, since_ts: int) -> dict:
def load_events(limit: int, since_ts: int, timelocks: list[TimelockConfig] | None = None) -> dict:
"""Fetch TimelockEvent events from the Envio GraphQL API."""
addresses = [t.address for t in TIMELOCK_LIST]
source = timelocks if timelocks is not None else TIMELOCK_LIST
addresses = [t.address for t in source]
_logger.info("load_events limit=%s since_ts=%s addresses=%s", limit, since_ts, len(addresses))
query = """
query GetTimelockEvents($limit: Int!, $sinceTs: Int!, $addresses: [String!]!) {
Expand Down Expand Up @@ -161,7 +163,7 @@ def _format_delay_info(delay: int | None, timelock_type: str) -> str | None:
return None

delay_val = int(delay)
if timelock_type in ("Compound", "Puffer"):
if timelock_type in ("Compound", "Puffer", "Maple"):
# Absolute timestamp
relative = delay_val - int(time.time())
if relative > 0:
Expand Down Expand Up @@ -244,6 +246,9 @@ def build_alert_message(events: list[dict], timelock_info: TimelockConfig) -> st
lines.append(f"📄 Metadata: {metadata}")
lines.append(f"🆔 Vote: {first.get('operationId') or ''}")

elif timelock_type == "Maple":
lines.append(f"🆔 Proposal: {first.get('operationId') or ''}")

elif timelock_type in ("TimelockController", "Compound", "Puffer"):
for event in events:
lines.extend(_build_call_info(event, explorer, len(events) > 1))
Expand Down Expand Up @@ -347,6 +352,12 @@ def main() -> None:
help="Fallback lookback window in seconds when no cache exists (default: 12h)",
)
parser.add_argument("--no-cache", action="store_true", help="Disable caching of last processed timestamp")
parser.add_argument(
"--protocol",
type=str,
default="",
help="Filter to a specific protocol (e.g. MAPLE, AAVE). Case-insensitive.",
)
parser.add_argument(
"--log-level",
type=str,
Expand All @@ -356,6 +367,16 @@ def main() -> None:
args = parser.parse_args()
_logger.setLevel(args.log_level.upper())

# Filter timelocks by protocol if specified
filtered_timelocks: list[TimelockConfig] | None = None
if args.protocol:
protocol_filter = args.protocol.upper()
filtered_timelocks = [t for t in TIMELOCK_LIST if t.protocol.upper() == protocol_filter]
if not filtered_timelocks:
_logger.error("No timelocks found for protocol: %s", args.protocol)
sys.exit(1)
_logger.info("Filtering to protocol %s: %s timelocks", protocol_filter, len(filtered_timelocks))

use_cache = not args.no_cache

# Determine the starting timestamp
Expand All @@ -373,7 +394,7 @@ def main() -> None:

_logger.info("Fetching TimelockEvent events since timestamp %s", since_ts)

response = load_events(args.limit, since_ts)
response = load_events(args.limit, since_ts, filtered_timelocks)
if "errors" in response:
_logger.error("GraphQL errors: %s", response["errors"])
sys.exit(1)
Expand Down