Skip to content

feat(scanner): mempool monitor for Venus oracle updates#46

Merged
obchain merged 5 commits intomainfrom
feat/21-mempool-monitor
Apr 24, 2026
Merged

feat(scanner): mempool monitor for Venus oracle updates#46
obchain merged 5 commits intomainfrom
feat/21-mempool-monitor

Conversation

@obchain
Copy link
Copy Markdown
Owner

@obchain obchain commented Apr 21, 2026

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 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 BlockListener.

Key changes:

  • New crates/charon-scanner/src/mempool.rs module with MempoolMonitor, PendingCache, OracleUpdate, PreSignedLiquidation, and default_selectors().
  • Recognises four Venus oracle-write selectors out of the box: updatePrice(address), updateAssetPrice(address), setDirectPrice(address,uint256), setUnderlyingPrice(address,uint256). Selector set is constructor-configurable for deployments behind custom proxies.
  • 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() is called on each ChainEvent::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.
  • 15 unit tests: selector membership, ABI decode of each call shape, recipient / selector / length rejection paths, drain-and-clear, TTL expiry, overwrite-on-repeat-insert, default-selector sanity, lowercase hex rendering of selectors.

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.

Gates:

  • `cargo fmt --all --check`: clean
  • `cargo clippy --workspace --all-targets --all-features -- -D warnings`: clean
  • `cargo test --workspace --all-targets --locked`: 22 scanner tests (15 new), workspace green
  • `cargo test --workspace --doc --locked`: green
  • `forge build` (contracts untouched): green

Depends on `feat/20-multi-liq-batcher`.

`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
obchain added 3 commits April 23, 2026 17:25
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
@obchain obchain changed the base branch from feat/20-multi-liq-batcher to main April 24, 2026 12:57
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	crates/charon-cli/src/main.rs
#	crates/charon-scanner/Cargo.toml
#	crates/charon-scanner/src/listener.rs
@obchain obchain merged commit 9308b05 into main Apr 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[scanner] Mempool monitor: pending Venus oracle-update txs + pre-computed impacted positions

1 participant