feat(scanner): mempool monitor for Venus oracle updates#46
Merged
Conversation
`MempoolMonitor` subscribes to the chain's pending-tx stream, filters for calls targeting the Venus price oracle, and decodes the pending update before it confirms. Gives the bot a ~3 s (one-block) head start on BSC: a downstream handler can simulate the incoming price against current positions, pre-sign liquidations for any borrower that crosses the health threshold, and fire them on the next block. Pure decode + pre-sign storage sits on `PendingCache` so the selector and TTL logic is test-friendly without an RPC provider. The RPC-bound subscription lives on `MempoolMonitor` proper, which reconnects with the same 1 s → 30 s exponential backoff as the block listener. - Recognises four oracle-write selectors out of the box (`updatePrice`, `updateAssetPrice`, `setDirectPrice`, `setUnderlyingPrice`) — selector set is constructor-configurable for deployments behind a custom proxy. - `OracleUpdate` carries the tx hash, matched selector, asset/vToken, and new price (`None` for single-arg refresh calls). - Pre-signed liquidations are keyed by borrower; drain-on-block clears the map and drops entries older than 30 s so stale pre-signs don't broadcast against a subsequent block's state. - 15 unit tests cover selector matching, ABI decoding of each call shape, recipient/selector/length rejection paths, drain semantics, TTL expiry, overwrite-on-repeat-insert, and default-selector membership. Library-only in this PR. Wiring into the CLI listen loop (plus the handler that consumes `OracleUpdate`, pre-signs via `TxBuilder`, and broadcasts drained txs via `Submitter`) lands alongside the broadcast integration work once a real signer and deployed `CharonLiquidator` are available. Closes #17
This was referenced Apr 22, 2026
Closed
Closed
Closed
subscribe_pending_transactions succeeds against public BSC
endpoints even when the subscription is disabled or scoped to the
local pool. The original code logged "subscription established"
and the stream silently yielded nothing. Add a FIRST_TX_WATCHDOG
(30s) that fires once on the select! loop if no hash arrives, with
a warn! naming the likely cause (public RPC) and the two working
endpoint classes (paid MEV stream, self-hosted geth with exposed
txpool). Module rustdoc gains a dedicated "RPC endpoint
requirements" section so operators see the constraint before they
deploy.
Pre-signed liquidations bypass the eth_call gate that charon-
executor enforces for non-pre-signed flows. The oracle tx that
motivated the pre-sign may never confirm (revert, replacement,
not-included), so a drain-and-broadcast path would violate the
CLAUDE.md hard invariant that every liquidation tx passes a
simulation gate. Move enforcement into the type system:
- SimulationVerdict { Ok, Revert, Error }, #[must_use]
- UnverifiedPreSigned newtype wraps PreSignedLiquidation; only
peek accessors (borrower, trigger_tx, opportunity) are public
- verify(self, SimulationVerdict) consumes the wrapper; Ok
returns the inner struct (raw_tx reachable), Revert/Error
returns Err((self, verdict)) so the caller keeps the wrapper
for logging and cannot accidentally broadcast
- PendingCache::drain and MempoolMonitor::drain now return
Vec<UnverifiedPreSigned>, both #[must_use]
A broadcaster written against this API cannot reach raw_tx without
a passing verdict — the gate is enforced by the type checker, not
by a comment. Five new unit tests cover the Ok/Revert/Error
branches plus the retry-after-failed-sim and peek-after-reject
paths. 20 mempool tests pass; clippy clean.
Closes #225
Closes #226
Ten fixes against MempoolMonitor and PendingCache, all surfaced by the #21 review round. PendingCache::drain_for_block(block_hash, confirmed_tx_hashes) is the new primary drain. Entries whose trigger oracle tx is NOT in the new block's confirmed-tx set are re-queued if still within TTL so a pre-sign whose trigger slips to the next block is not silently broadcast against state that never materialised. Legacy drain() stays as #[deprecated] for backward compatibility but returns the same Vec<UnverifiedPreSigned> wrapper (#227, preserves the #226 sim-gate guard). unix_now() now returns Result<u64, SystemTimeError>. Both drain paths warn + clear the cache on clock failure instead of falling back to inserted_at = 0 and silently broadcasting stale pre-signs against an unknown-age state (#228). MempoolMonitor::run returns Result<(), MempoolError> — callers get a typed enum (SubscriptionFailed wrapping alloy TransportError, ChannelClosed) rather than anyhow. Internal run_once keeps anyhow for ergonomic ? propagation across the subscription path (#229). default_selectors() drops setDirectPrice / setUnderlyingPrice — neither is installed on the live BSC ResilientOracle at 0x6592b5DE802159F3E74B2486b091D11a8256ab8A, so tracking them produced false positives. Both selectors move to legacy_selectors() behind ILegacyVenusOracleWrite for operators running against a fork or an older deployment (#230). OracleUpdate is now an enum: Refresh { tx_hash, selector, asset } or DirectUpdate { .., price }. Pre-sign builders can no longer accept a Refresh and fill in price = 0 — the type system demands a DirectUpdate or an explicit Refresh branch. Accessors (tx_hash(), selector(), asset(), kind()) preserve field-style ergonomics for logging call sites (#231). #[non_exhaustive] on OracleUpdate, PreSignedLiquidation, SimulationVerdict, UnverifiedPreSigned, MempoolError. Adding a variant or peek accessor later stops being a breaking change for downstream callers (#232). run_once info! on every matched oracle update drops to debug! — info is reserved for lifecycle. TODO(charon-metrics) marker left for the Prometheus counter wire-up once charon-metrics merges in rebase (#233). New tracking issue #299 opened for the deferred CLI wiring; the module-level doc comment now references it instead of the vague "separate PR" hand-wave (#234). Test constant HASH was already a valid 64-char b256! literal, so no change was needed for that specific code path. Verified via a test-suite build; leaving the existing literal in place (#235). Reconnect backoff gains 0-25% random jitter after the doubling step via backoff_with_jitter(current, max). Prevents thundering-herd reconnects when many monitors share an upstream. Helper is private + unit-tested; cap still 30 s to mirror BlockListener (#236). Workspace gains thiserror + rand as shared deps. 36 mempool tests (20 original + 16 new) pass; clippy -D warnings clean across the workspace; fmt check clean. Closes #227 Closes #228 Closes #229 Closes #230 Closes #231 Closes #232 Closes #233 Closes #234 Closes #235 Closes #236
Spawns MempoolMonitor alongside BlockListener on the shared provider when the operator sets CHARON_VENUS_ORACLE to the live Venus oracle address. BlockListener now surfaces the block hash on every NewBlock; the listen loop pulls the confirmed tx-hash set via eth_getBlockByHash (hashes-only) and calls PendingCache::drain_for_block with the real hash set — acceptance requires this, not an empty placeholder. For each drained UnverifiedPreSigned the loop rebuilds the liquidator calldata via the Venus adapter + TxBuilder, runs it through Simulator::simulate, and only hands the pre-sign a SimulationVerdict::Ok proof token when simulation succeeds. The resulting PreSignedLiquidation is logged as "ready for broadcast"; eth_sendRawTransaction remains an explicit non-goal per the issue body (signer + liquidator-contract bridge tracked separately). The type-level guard (UnverifiedPreSigned → PreSignedLiquidation only via verify(SimulationVerdict::Ok)) is preserved end-to-end, so the eventual broadcast commit composes naturally without bypassing the CLAUDE.md eth_call gate. Oracle-update channel is logged at debug (pre-sign builder is a non-goal for #299) so operators can confirm the monitor is actually decoding writes on their upstream. Closes #299
# Conflicts: # Cargo.lock # Cargo.toml # crates/charon-cli/src/main.rs # crates/charon-scanner/Cargo.toml # crates/charon-scanner/src/listener.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #17
Adds a mempool monitor that subscribes to the chain's pending-tx stream, filters for calls targeting the Venus price oracle (address discovered at connect time by
VenusAdapter), and decodes the pending update before it confirms. Gives the bot a ~3 s (one-block) head start on BSC: a downstream handler can simulate the incoming price against current positions, pre-sign liquidations for any borrower that crosses the health threshold, and fire them on the next block event.Pure decode + pre-sign storage live on
PendingCacheso the selector and TTL logic is test-friendly without an RPC provider. The RPC-bound subscription lives onMempoolMonitorproper, which reconnects with the same 1 s → 30 s exponential backoff asBlockListener.Key changes:
crates/charon-scanner/src/mempool.rsmodule withMempoolMonitor,PendingCache,OracleUpdate,PreSignedLiquidation, anddefault_selectors().updatePrice(address),updateAssetPrice(address),setDirectPrice(address,uint256),setUnderlyingPrice(address,uint256). Selector set is constructor-configurable for deployments behind custom proxies.OracleUpdatecarries the tx hash, matched selector, asset/vToken, and new price (Nonefor single-arg refresh calls).drain()is called on eachChainEvent::NewBlock, clearing the map and dropping entries older than 30 s so stale pre-signs don't broadcast against a later block's state. Re-insert for the same borrower overwrites — the latest observed update wins.Library-only in this PR. Wiring into the CLI listen loop (plus the handler that consumes
OracleUpdate, pre-signs viaTxBuilder, and broadcasts drained txs viaSubmitter) lands alongside the broadcast integration work once a real signer and deployedCharonLiquidatorare available.Gates:
Depends on `feat/20-multi-liq-batcher`.