feat(oracle): add multi-source price aggregator for high-value transfers - #898
Merged
mijinummi merged 1 commit intoAug 20, 2026
Conversation
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.
2 tasks
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 #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
getValidatedPrice()reads both throughIPriceOraclecalculateDeviationBps()OracleDeviationExceeded()past the thresholdvalidateTransfer()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.ChainlinkPriceOraclerejects 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.UniswapV3TwapOracleaverages 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 ^ tickis 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 reciprocalQ128 * Q128overflows unconditionally. Every fixed-point multiply now goes through OpenZeppelin'sMath.mulDivfor 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^tickvalues 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.npx hardhat compilefails onmainbefore reaching this contract. Five pre-existing failures, none related to this change:contracts/bridge/BridgeGateway.sol:103pragmaand imports pasted into the middle of a function bodycontracts/verifiers/YulEd25519Verifier.sol:98} else {inside assembly — Yul has noelsetest/relayer/MockGasTarget.sol:15,31let _ := i—_is reserved in Yulcontracts/bridge/YulBatchUnlocker.sol:34contracts/lightclient/YulLightClient.sol:97purefunction performing astaticcallThat 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 inpackage.json; this PR's test uses the Hardhat 3network.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.