feat(unibtc): add Bedrock uniBTC Safe monitor and hourly state polling - #362
Conversation
Cover the single-EOA mint path, reserve gate, PoR, supply feeder, redemptions, and peg without event subscriptions. Co-authored-by: Cursor <cursoragent@cursor.com>
Seven correctness fixes from the review of #362: - Dedupe the mint alerts. A single mint stays inside the 24h lookback for ~20 hourly runs and re-alerted on every one. The supply level that last alerted is now cached; the alert repeats only once supply grows by another full threshold, and falling back under the threshold re-arms. - Key the reserve-gate and pause latches on *which* fields changed rather than on bool(diffs). A second tampered gate field (supply-feeder hijack after an adequacyRatio change) or a newly paused component was previously swallowed by the first alert's latch. - Replace the absolute feeder-vs-API gap check. The feeder and the Bedrock dashboard cover different chain sets, so the steady-state gap is ~15% -- well over the 2% threshold -- meaning the HIGH fired on the first run and latched forever. The PR's own test asserted this. Now tracks the ratio between the two against a learned baseline and alerts on a >5% move; the baseline is relearned only while inside the band, so an anomalous reading cannot become the new normal. - Reject non-positive uniBTC price quotes. A zero quote fell through the btc_usd guard and yielded a 0 ratio, i.e. a false CRITICAL depeg alert. - Keep future-dated supply snapshots. block_timestamp moves backwards when the RPC pool rotates to a lagging provider, which discarded the newest snapshot -- the only baseline the next run had. Also warn when a mint window has no usable baseline instead of going silently blind. - Report the real baseline age in mint alerts; the "1h" CRITICAL can cover up to 3h. - Make check_por_stale track the live Vault feederHeartbeat as its docstring claims, capped at POR_STALE_SECONDS so a heartbeat widened by a compromised MANAGER_ROLE cannot also blind the check. Docs in monitoring.yaml and the protocol README updated to match. The ten new tests were verified to fail against the pre-fix code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gaps Both defects were introduced by the previous commit's dedupe work and were reproduced against the real code before fixing. Feeder ratio could drift unbounded. Re-anchoring on every in-band reading let each sub-threshold step move the reference: four -3% steps accumulated 11.47% total drift with zero alerts. The anchor is now re-taken at most once per FEEDER_ANCHOR_REFRESH_SECONDS (7 days), and still only from a reading inside the band, so cumulative drift trips the alert instead of being absorbed. The same four steps now alert on the second one. A stale mint marker could suppress a real mint. mint_alert_due was only called when a window delta existed, so a polling gap left the previous alert's supply level cached. Reproduced: an 11 BTC mint alerted and pinned the marker at 31100000000, a gap plus redemptions dropped supply back, and a genuine fresh 10 BTC mint at 31000000000 never cleared the last + threshold bar and alerted nothing. The helper now takes the delta as Optional and treats a missing baseline as a re-arm, and is called unconditionally so broken continuity always clears the marker. Docs updated to match. The new tests were verified to fail against the previous commit, except the mint_alert_due unit test, which passes there by coincidence of the old bool signature -- the defect was at the call site, which the integration-level test covers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects found auditing the rest of the module. A feeder pinned at 0 was never reported. check_supply_feeder used previous_value == 0 as its "never seen" sentinel, but 0 is also what the cache returns for an unset key, so a feeder genuinely reporting 0 looked like a fresh observation on every run and reset its own staleness clock. Reproduced: 10 days of a zero feeder with the Bedrock API down produced no alert at all, while the same loop with a normal value alerts as expected. This is the reading that least deserves to be dropped -- a zero supply satisfies the Vault mint gate outright, which is the unbacked-mint path this monitor exists to catch. The staleness clock is now keyed on the changed-at timestamp, which is unambiguous. Note the ratio check already covers this within one run whenever the API is up; the blind spot was the API-down fallback. A truncated RPC batch surfaced as a bare IndexError naming no field, unlike every other decode path here. load_state now checks the response count first and raises with the shortfall. BATCH_CALL_COUNT is asserted against the real batch in a test so it cannot drift from the call list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A zero totalTokenSupply() satisfies the Vault reserve check for any mint amount. Previously this surfaced only via the ratio check (needs the Bedrock API) or, with the API down, the 48h staleness path. The new check reads the on-chain value directly, fires on the first run, alerts once while the condition holds, and re-arms on recovery. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… gap The learned-anchor feeder check was built on a false premise. The ~15% feeder/API gap it treated as steady state was a fault: on-chain history shows the feeder tracked the API (ratio ~1.00) through 2026-09-12, and since 09-13 the updater 0x2C62803181243Fa99C659DE0d2A0530879a79911 writes a value ~701 uniBTC low on alternating days -- exactly BOB-uniBTC's supply. The anchor only alerted on 09-16 because it happened to be seeded two minutes before a bad write; seeded on a bad day it would have gone quiet on the fault and alerted on recovery. Feeder check: back to the absolute 2% gap specified in #361. The alert now names the chain whose supply matches the gap ("likely omits BOB-uniBTC"). The reserve API is Bedrock's own undocumented dashboard backend (loaded by app.bedrock.technology from their Vercel team), so it is kept, but it was observed dropping BOB from `supplies` on 2026-09-16 09:25Z, reporting total supply 3,845.33 instead of 4,546.70. Trusting that is worse than skipping: PoR coverage reads 120.7% instead of 102%, and a feeder omitting the same chain looks correct. Responses are now rejected -- skipping PoR coverage and the feeder gap for that run, with an error message -- unless: - `time` is at most 1h older than the pinned block; - Ethereum, BSC, Base, BOB and Berachain (99.4% of supply) are all present with positive supply; - the Ethereum entry matches block-pinned on-chain totalSupply within 1% (verified live: they agree to 1.8e-7). The mainnet cross-check alone would have passed the BOB-less response, hence the required-chain list. Dry-run against live data names BOB as the omitted chain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The peg had a single HIGH level at 0.98. Levels are set from a year of DeFiLlama uniBTC/WBTC prices (1,993 4h samples, 2025-09-14 to 2026-09-16): median 0.9945; below 0.99 9% of the time (~130 dips/yr, routine); below 0.985 ~36 dips/yr; below 0.97 only four episodes (2025-12-16, 2026-04-26, 2026-05-09/10), low 0.9616. A 0.99 trigger was considered and rejected as noise: its 10th percentile sits at 0.9903. Alerts fire on entering a worse band, like PoR coverage: HIGH -> CRITICAL re-alerts, CRITICAL -> HIGH is silent, full recovery re-arms. The band logic PoR coverage had inline is extracted into severity_band and _alert_on_band_escalation, shared by both checks. The peg state moves to a new UNIBTC_PEG_BAND cache key since the stored value changes meaning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
…ain sum Two findings from the PR #362 review, both reproduced before fixing. Mint alerts were marked sent before delivery. mint_alert_due wrote the alerted supply, and both window markers were written before either send. send_telegram_message re-raises TelegramError on failure, so a failed delivery aborted the run with both markers already set. Reproduced through the real send_alert -> telegram path: a +10 uniBTC mint whose first send failed was never delivered on the next, healthy run -- and the 24h alert was never even attempted. mint_alert_due now only decides (and still clears on re-arm); each marker is written right after its own send_alert returns, so a failure retries next run, and a delivered 1h alert is not repeated when only the 24h send failed. The module's other latches already sent first. API total_supply was not checked against its per-chain list. The required- chain and Ethereum safeguards inspect `supplies`, but PoR coverage and the feeder gap consume `total_supply`, so they only protected the total when the two agreed. A response listing every chain but whose total excluded BOB passed validation and yielded PoR coverage 120.69% (true 102.06%) and a feeder gap of 0.005% (true 15.43%). Responses are now rejected when the total differs from the sum by more than 0.01 uniBTC; live responses differed by 4.2e-7. Not yet observed live -- the observed failure dropped BOB from both -- but the safeguards were logically incomplete without it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
lgtm |
Summary
Closes #361
Test plan
uv run pytest tests/test_unibtc.py tests/test_monitoring_config.py tests/test_alert_protocol_keys.py tests/test_safe_main.pyprotocols/safe/addresses.pyand thatTELEGRAM_TOPIC_ID_UNIBTCis set on the VPS before mergeMade with Cursor