Skip to content

feat(scanner): Chainlink PriceCache with staleness check#35

Merged
obchain merged 3 commits into
mainfrom
feat/10-chainlink-pricecache
Apr 24, 2026
Merged

feat(scanner): Chainlink PriceCache with staleness check#35
obchain merged 3 commits into
mainfrom
feat/10-chainlink-pricecache

Conversation

@obchain
Copy link
Copy Markdown
Owner

@obchain obchain commented Apr 21, 2026

Closes #11

Per-chain, per-asset Chainlink feed reader that backs the scanner and (soon) the profit calculator with USD prices that are either fresh or explicitly rejected — no silent reliance on stale rounds.

  • IAggregatorV3 sol! binding covering decimals() and latestRoundData()
  • CachedPrice struct (price, decimals, updatedAt, fetchedAt)
  • PriceCache — provider + (symbol → feed address) + DashMap cache; refresh / refresh_all / get / is_fresh
  • 10-minute default max_age; negative answer rejected as degraded
  • New [chainlink.<chain>] config section with five BSC feeds (BNB, BTCB, ETH, USDT, USDC)
  • CLI listen performs one startup refresh and logs each feed's price + decimals
  • Unit tests on freshness predicate; live integration tests against BSC mainnet (happy-path + forced-stale rejection)

Depends on #10 (feat/09-health-scanner).

Per-chain, per-asset Chainlink feed reader that backs the scanner and
(soon) the profit calculator with USD prices that are either fresh or
explicitly rejected — no silent reliance on stale rounds.

- `IAggregatorV3` sol! binding covering `decimals()` and
  `latestRoundData()`
- `CachedPrice` struct (price, decimals, updatedAt, fetchedAt)
- `PriceCache` — provider + `(symbol → feed address)` + DashMap cache,
  `refresh / refresh_all / get / is_fresh` surface
- 10-minute default `max_age`; negative `answer` rejected as degraded
- New `[chainlink.<chain>]` config section with the five BSC feeds
  used by Venus (BNB, BTCB, ETH, USDT, USDC)
- CLI `listen` performs one startup refresh and logs each feed's
  price + decimals; cache is ready for downstream consumers
- Unit tests on freshness predicate + feed iterator
- Live integration tests (`tests/chainlink_refresh.rs`) hit BSC
  mainnet: one happy-path refresh, one forced-stale rejection
… fail-loud clock, BTCB fix

- Reject answeredInRound < roundId in refresh(): a partially-aggregated
  round carries the previous answer behind a fresh updatedAt, silently
  serving stale prices to the liquidation path.
- Reject updatedAt == 0 explicitly — distinguishes an uninitialized
  aggregator from a merely-stale one in the log message.
- unix_now() returns Result; refresh() and is_fresh() propagate the clock
  error instead of coercing to 0. Previously a clock skew made every
  cached entry look fresh-forever ('some_updated_at + max_age >= 0' is
  always true), bypassing the staleness gate entirely.
- with_per_symbol_max_age constructor accepts a HashMap<String, Duration>
  so stablecoin feeds with day-long heartbeats and sub-second deviation
  feeds can coexist without needing a single global compromise.
- CachedPrice::scaled_to(target_decimals) normalizes the raw feed answer
  into the consumer's decimal space using integer arithmetic (no f64).
  Venus oracle (36 - underlying) and Aave 1e18 consumers now get a
  correctly-sized USD value instead of being off by 10^10.
- Fix BTCB/USD feed address in config/default.toml:
  0x264990fbd0A4796A3E3d8E37C4d5F87a3aCa5Ebf (malformed, 41 hex chars) ->
  0x264990fbd0A4796A3E3d8E37022BdAf1A5a4C1f0 (canonical docs.chain.link).
  Tag each feed with the human-readable pair so future audits are one
  grep away.

Closes #108 #109 #110 #111 #112
@obchain obchain changed the base branch from feat/09-health-scanner to main April 24, 2026 10:29
…cecache

# Conflicts:
#	config/default.toml
#	crates/charon-cli/src/main.rs
#	crates/charon-scanner/Cargo.toml
#	crates/charon-scanner/src/lib.rs
@obchain obchain merged commit 4e57efc into main Apr 24, 2026
obchain added a commit that referenced this pull request Apr 26, 2026
…loses #109) (#337)

The address shipped in #109's "fix" — 0x264990fbd0…1f0 — has no
bytecode on BSC mainnet. `cast code` returns 0x against both dRPC
and bsc-dataseed.binance.org, so every `decimals()` / `latestRoundData()`
call against it abi-decodes garbage and BTCB-collateral positions
have been silently unpriceable since #35 landed.

Discovered the live address by walking the on-chain Venus oracle path:

  ResilientOracle (0x6592b5DE…)
    → ChainlinkOracle facet (0x1B210344…)
      → tokenConfigs(BTCB) → 0x8ECF7dE377F788A813F5215668E282556b35f300

Verified live against bsc.drpc.org:

  cast code        → 19,145 bytes of bytecode
  decimals()       → 18  (Venus uses 18-decimal Chainlink wrappers,
                          not the standard 8-decimal feeds)
  description()    → "BTC / USD"
  latestRoundData  → answer ≈ 78,090e18 (= $78,090), fresh round

Confirmed Chainlink-ops-owned: feed `owner()` is 0xcb6754D5…, the
same multisig that owns the BNB/USD canonical feed.

PriceCache (`crates/charon-scanner/src/oracle.rs:33,59`) reads
`decimals()` per feed at startup and `CachedPrice::scaled_to`
rescales to a target — the 8→18 decimal difference is invisible to
consumers, no Rust changes required.

Same swap applied to `config/fork.toml` since the fork profile pins
mainnet block forks and would have hit the same garbage decode.

Comment block above [chainlink.bnb] in default.toml records the
Venus-oracle discovery path so future reviewers don't have to walk
it again.

Adjacent finding (separate issue recommended): the other four
[chainlink.bnb] entries (BNB, ETH, USDT, USDC) were sourced the same
way from docs.chain.link. Given one of five was bytecodeless, the
remaining four warrant the same cast-code/decimals/description sanity
check.
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] Chainlink PriceCache: latestRoundData ABI + staleness check + per-asset config

1 participant