Skip to content

feat(oracle): add multi-source price aggregator for high-value transfers - #898

Merged
mijinummi merged 1 commit into
MDTechLabs:mainfrom
Cybermaxi7:feat/multi-source-oracle-aggregator-846
Aug 20, 2026
Merged

feat(oracle): add multi-source price aggregator for high-value transfers#898
mijinummi merged 1 commit into
MDTechLabs:mainfrom
Cybermaxi7:feat/multi-source-oracle-aggregator-846

Conversation

@Cybermaxi7

Copy link
Copy Markdown
Contributor

Closes #846

Summary

Adds contracts/oracle/BridgeMultiOracle.sol, which reads two independent price sources and refuses to value a transfer when they disagree by more than a configured margin — plus adapters for Chainlink and a Uniswap V3 TWAP so the two feeds are genuinely independent.

A single feed is a single point of failure. A manipulated spot pool, or a Chainlink feed stuck on stale data, will happily report a price that lets an attacker over-withdraw on the far side of a bridge. Requiring two sources to agree turns a silent mispricing into a revert.

Requirements

Requirement Where
Query price feeds from both primary and fallback oracle contracts getValidatedPrice() reads both through IPriceOracle
Calculate deviation percentage between price reports calculateDeviationBps()
Revert with OracleDeviationExceeded() past the threshold thrown with observed and permitted bps as arguments
Validates asset prices against multiple independent feeds Chainlink adapter + Uniswap V3 TWAP adapter
Blocks high-value bridging operations during discrepancies validateTransfer()

Two decisions worth reviewing

Both are about failing safe, and both are documented at the point they are made:

Deviation is measured against the smaller of the two prices. The same absolute gap then always yields the larger percentage. Measuring against the mean — the more common shortcut — under-reports precisely when the feeds diverge most, which is exactly when you want the number to be pessimistic.

Valuation returns the higher of the two prices. Since this contract gates on a high-value threshold, overstating value routes more transfers into the stricter path. Understating it would let a large transfer slip underneath the threshold, and that is the failure that actually costs money.

Below the threshold only the primary feed is consulted, which keeps routine transfers cheap; at or above it both must agree. A test covers exactly this: with the feeds disagreeing by 100%, a small transfer still succeeds while a large one reverts.

Adapters

Both normalise to 18 decimals behind a common IPriceOracle, so the aggregator does not care where a price came from.

  • ChainlinkPriceOracle rejects non-positive answers and rounds that never settled (answeredInRound < roundId). Both are real production failure modes, and both otherwise surface as a number that merely looks plausible.
  • UniswapV3TwapOracle averages the tick over a window rather than reading spot, because spot is trivially moved inside a single block; averaging makes manipulation cost proportional to the window.

No new dependencies. The Chainlink and Uniswap interfaces are declared locally rather than pulling in two packages for two interfaces. Only OpenZeppelin, already a dependency, is used.

On the tick maths — and a bug the tests caught

1.0001 ^ tick is computed by exponentiation by squaring from a base derived with integer arithmetic, rather than transcribing Uniswap's precomputed TickMath constants. A mistyped magic constant in a safety contract produces silently wrong prices, so I preferred something auditable by inspection and pinned by tests.

My first draft used plain shifts, (a * b) >> 128. It overflowed, and the tests caught it: the squared base passes 2^128 at fairly modest ticks, and the reciprocal Q128 * Q128 overflows unconditionally. Every fixed-point multiply now goes through OpenZeppelin's Math.mulDiv for its 512-bit intermediate. The supported tick range is capped at ±400,000 and enforced, which keeps the intermediates inside uint256.

Eight tests assert the pricing lands within 1e-6 of independently computed 1.0001^tick values across positive, negative and large ticks — verifying the maths rather than asserting whatever the contract happens to return.

Tests

45 passing. Deviation maths (including symmetry and the measured-against-smaller property) · threshold boundaries · staleness in either feed · future timestamps · zero prices · the high-value gate · non-reverting oraclesAgree() including a feed that reverts · ownership and configuration bounds · both adapters · and end-to-end Chainlink-versus-TWAP cases in both directions.

⚠️ CI cannot verify this, for reasons that predate the PR

npx hardhat compile fails on main before reaching this contract. Five pre-existing failures, none related to this change:

File Problem
contracts/bridge/BridgeGateway.sol:103 a second file's pragma and imports pasted into the middle of a function body
contracts/verifiers/YulEd25519Verifier.sol:98 } else { inside assembly — Yul has no else
test/relayer/MockGasTarget.sol:15,31 let _ := i_ is reserved in Yul
contracts/bridge/YulBatchUnlocker.sol:34 non-literal constant referenced in inline assembly
contracts/lightclient/YulLightClient.sol:97 pure function performing a staticcall

That matches CI, which is red on every recent run on main.

To validate this work I compiled and ran the suite against an isolated Hardhat config containing only these contracts; the harness was removed before committing, so the diff is only the six intended files. Separately, the existing tests under test/ use the Hardhat 2 import style (import { ethers } from "hardhat"), which throws under the Hardhat 3 in package.json; this PR's test uses the Hardhat 3 network.connect() API so it actually runs.

I have deliberately fixed none of the above — it is outside #846. Happy to open a separate PR for the five compile errors, which would unblock every open PR against this repo including this one.

Adds `BridgeMultiOracle`, which reads two independent price sources and refuses
to value a transfer when they disagree by more than a configured margin, plus
adapters for Chainlink and a Uniswap V3 TWAP.

A single feed is a single point of failure. A manipulated spot pool or a
Chainlink feed stuck on stale data will report a price that lets an attacker
over-withdraw on the far side of a bridge. Requiring two independent sources to
agree turns a silent mispricing into a revert.

Two choices, both aimed at failing safe, are documented at the point they are
made:

- Deviation is measured against the smaller of the two prices, so the same
  absolute gap always yields the larger percentage. Measuring against the mean
  would under-report precisely when the feeds diverge most.
- Valuation returns the higher of the two prices. The contract gates on a
  high-value threshold, so overstating value routes more transfers into the
  stricter path, whereas understating it would let a large transfer slip under
  the threshold — the failure that actually costs money.

Below the threshold the primary feed alone is used, keeping routine transfers
cheap; at or above it both feeds must agree. Stale observations, non-positive
prices and timestamps in the future are all rejected, each with its own error.
`oraclesAgree()` answers the same question without reverting so callers can
branch rather than use try/catch.

Adapters normalise to 18 decimals behind a common `IPriceOracle` interface:

- `ChainlinkPriceOracle` rejects non-positive answers and rounds that never
  settled (`answeredInRound < roundId`), both real production failure modes.
- `UniswapV3TwapOracle` averages the tick over a window rather than reading
  spot, since spot is trivially moved within one block.

No new dependencies. Chainlink and Uniswap interfaces are declared locally
rather than pulling in two packages for two interfaces.

On the tick maths: `1.0001 ^ tick` is computed by exponentiation by squaring
from a base derived with integer arithmetic, rather than transcribing Uniswap's
precomputed TickMath constants, so it is auditable by inspection. Every
fixed-point multiply goes through OpenZeppelin's `Math.mulDiv` for its 512-bit
intermediate — a plain `(a * b) >> 128` overflows uint256 once the squared base
passes 2^128, which happens at modest ticks, and the reciprocal `Q128 * Q128`
overflows unconditionally. The first draft used shifts and the tests caught
both overflows.

Tests: 45 covering deviation maths, threshold behaviour, staleness, invalid
prices, the high-value gate, non-reverting agreement checks, ownership and
configuration bounds, both adapters, and an end-to-end Chainlink-versus-TWAP
case. Eight of them assert the tick pricing lands within 1e-6 of independently
computed values of 1.0001^tick rather than trusting whatever the contract
returns.
@mijinummi
mijinummi merged commit 11b0428 into MDTechLabs:main Aug 20, 2026
1 check failed
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.

[FEAT] Implement Multi-Source Oracle Aggregator for High-Value Transfers

2 participants