diff --git a/contracts/DexOracle.sol b/contracts/DexOracle.sol new file mode 100644 index 00000000..97cf5723 --- /dev/null +++ b/contracts/DexOracle.sol @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.4; + +import {Ownable} from "./_external/Ownable.sol"; +import {SafeMath} from "./_external/SafeMath.sol"; +import {IUniswapV2Pair} from "./_external/IUniswapV2Pair.sol"; +import {UniswapV2OracleLibrary} from "./_external/UniswapV2OracleLibrary.sol"; + +interface IERC20Decimals { + function decimals() external view returns (uint8); +} + +interface IMedianOracle { + function pushReport(uint256 payload) external; +} + +/** + * @title DexOracle + * + * @notice Computes a 24h time-weighted average price (TWAP) for an asset pair + * that has no direct UniswapV2 market by chaining two underlying + * markets, and reports the result to a MedianOracle instance. + * + * For AMPL/USDC the price is bridged through WETH: + * + * AMPL/USDC = (AMPL/WETH) * (WETH/USDC) + * + * - leg1 prices the source asset (AMPL) in the bridge asset (WETH) + * - leg2 prices the bridge asset (WETH) in the quote asset (USDC) + * + * UniswapV2 maintains per-pair price accumulators as UQ112x112 fixed + * point numbers denominated in raw (smallest-unit) reserves. This + * contract bridges that representation into an OUTPUT_DECIMALS (18) + * fixed point decimal price, which is the format MedianOracle expects: + * + * price_18 = (avgRatioUQ112x112 * decimalsFactor) >> 112 + * decimalsFactor = 10**(OUTPUT_DECIMALS + baseDecimals - quoteDecimals) + * + * Intended 24h rebase cadence: + * - `update()` is called right after rebase (appended to the + * Orchestrator's transaction list) to open a fresh + * measurement window. + * - `pushReport()` is called ~2h before the next rebase to close the + * window and report the TWAP. The report then ages + * past the MedianOracle report-delay (security) window + * before it is consumed at the following rebase. + */ +contract DexOracle is Ownable { + using SafeMath for uint256; + + /// @notice Decimals of the reported price; matches MedianOracle.DECIMALS. + uint256 public constant OUTPUT_DECIMALS = 18; + + /// @notice MedianOracle this contract reports to as a registered provider. + IMedianOracle public immutable medianOracle; + + /// @notice First leg market: prices the source asset in the bridge asset. + IUniswapV2Pair public immutable pairLeg1; + /// @notice Second leg market: prices the bridge asset in the quote asset. + IUniswapV2Pair public immutable pairLeg2; + + /// @dev When true the leg reads price1 (token1 priced in token0), + /// otherwise price0 (token0 priced in token1). + bool public immutable leg1UseToken1Price; + bool public immutable leg2UseToken1Price; + + /// @dev 10**(OUTPUT_DECIMALS + baseDecimals - quoteDecimals) per leg, used + /// to convert a raw UQ112x112 reserve ratio into a decimal price. + uint256 public immutable decimalsFactorLeg1; + uint256 public immutable decimalsFactorLeg2; + + /// @notice Raw UniswapV2 price cumulatives captured at the last `update()`. + uint256 public priceLeg1CumulativeLast; + uint256 public priceLeg2CumulativeLast; + /// @notice Timestamp (mod 2**32) of the last `update()`. Zero until the + /// first `update()`, which marks the oracle as uninitialized. + uint32 public blockTimestampLast; + + /// @notice Minimum measurement window length before a report can be pushed. + uint256 public minReportTimeIntervalSec = 22 hours; + + /// @dev Daily cadence and the window (relative to the day) during which + /// `update()` may open a new measurement window. Defaults mirror the + /// policy's rebase window so updates land right after rebase. + uint256 public updateTimeIntervalSec = 1 days; + uint256 public updateWindowOffsetSec = 7200; // 2AM UTC, matches rebase + uint256 public updateWindowLengthSec = 20 minutes; + + event LogPriceUpdate( + uint256 priceLeg1Cumulative, + uint256 priceLeg2Cumulative, + uint32 timestamp + ); + event LogReportPushed(uint256 price, uint32 timeElapsed); + // Emitted once at construction. The two bridge-side tokens (leg1's quote + // and leg2's base) are expected to represent the same value; `matched` is + // true when they are the exact same address. They may legitimately differ + // (e.g. two equivalent wrapped representations), so this is informational + // only and never reverts. + event LogBridgeTokens(address leg1QuoteToken, address leg2BaseToken, bool matched); + + /** + * @param medianOracle_ MedianOracle instance to report to. + * @param pairLeg1_ UniswapV2 pair for the first (source/bridge) leg. + * @param leg1UseToken1Price_ True to read price1 on leg1, false for price0. + * @param pairLeg2_ UniswapV2 pair for the second (bridge/quote) leg. + * @param leg2UseToken1Price_ True to read price1 on leg2, false for price0. + */ + constructor( + address medianOracle_, + address pairLeg1_, + bool leg1UseToken1Price_, + address pairLeg2_, + bool leg2UseToken1Price_ + ) { + Ownable.initialize(msg.sender); + + medianOracle = IMedianOracle(medianOracle_); + + pairLeg1 = IUniswapV2Pair(pairLeg1_); + pairLeg2 = IUniswapV2Pair(pairLeg2_); + leg1UseToken1Price = leg1UseToken1Price_; + leg2UseToken1Price = leg2UseToken1Price_; + + (address leg1Base, address leg1Quote) = _baseQuote(pairLeg1_, leg1UseToken1Price_); + (address leg2Base, address leg2Quote) = _baseQuote(pairLeg2_, leg2UseToken1Price_); + decimalsFactorLeg1 = _decimalsFactor(leg1Base, leg1Quote); + decimalsFactorLeg2 = _decimalsFactor(leg2Base, leg2Quote); + + // The bridge token is leg1's quote (asset AMPL is priced in) and leg2's + // base (asset priced in USDC). Logged for off-chain inspection; the two + // may legitimately be distinct equivalent tokens, so this never reverts. + emit LogBridgeTokens(leg1Quote, leg2Base, leg1Quote == leg2Base); + + // blockTimestampLast is left at 0 to mark the oracle as uninitialized. + } + + /** + * @notice Opens a fresh measurement window by snapshotting the current + * price cumulatives. Intended to be appended to the Orchestrator's + * transaction list so it runs immediately after each rebase. + * @dev Gated to the daily update window so the measurement window cannot be + * reset off-schedule (which would shorten a subsequent report's TWAP). + */ + function update() external { + require(inUpdateWindow(), "DexOracle: NOT_IN_UPDATE_WINDOW"); + + ( + uint256 leg1Cumulative, + uint256 leg2Cumulative, + uint32 blockTimestamp + ) = _currentCumulatives(); + priceLeg1CumulativeLast = leg1Cumulative; + priceLeg2CumulativeLast = leg2Cumulative; + blockTimestampLast = blockTimestamp; + + emit LogPriceUpdate(leg1Cumulative, leg2Cumulative, blockTimestamp); + } + + /** + * @notice Closes the measurement window, computes the chained TWAP and + * reports it to the MedianOracle. Intended to be called ~2h before + * the next rebase, leaving the report to age past the MedianOracle + * report-delay window before it is consumed. + * @return price The reported AMPL/USDC price as an OUTPUT_DECIMALS number. + */ + function pushReport() external returns (uint256 price) { + uint32 timeElapsed; + (price, timeElapsed) = _computePrice(minReportTimeIntervalSec); + + medianOracle.pushReport(price); + emit LogReportPushed(price, timeElapsed); + } + + /** + * @notice Computes the chained TWAP over the current measurement window + * without reporting it. + * @return price The AMPL/USDC price as an OUTPUT_DECIMALS number. + */ + function computePrice() external view returns (uint256 price) { + // Require at least one second of measurement so the average is defined. + (price, ) = _computePrice(1); + } + + /** + * @return True if the current block falls within the daily update window. + */ + function inUpdateWindow() public view returns (bool) { + uint256 timeOfDay = block.timestamp.mod(updateTimeIntervalSec); + return (timeOfDay >= updateWindowOffsetSec && + timeOfDay < updateWindowOffsetSec.add(updateWindowLengthSec)); + } + + /** + * @notice Sets the minimum measurement window length before a report can be + * pushed. + * @param minReportTimeIntervalSec_ The new minimum window length in seconds. + */ + function setMinReportTimeIntervalSec(uint256 minReportTimeIntervalSec_) external onlyOwner { + require(minReportTimeIntervalSec_ < updateTimeIntervalSec, "DexOracle: INTERVAL_TOO_LONG"); + minReportTimeIntervalSec = minReportTimeIntervalSec_; + } + + /** + * @notice Sets the daily update window parameters. + * @param updateTimeIntervalSec_ Length of a full cadence cycle in seconds. + * @param updateWindowOffsetSec_ Offset of the window from the cycle start. + * @param updateWindowLengthSec_ Length of the update window in seconds. + */ + function setUpdateWindow( + uint256 updateTimeIntervalSec_, + uint256 updateWindowOffsetSec_, + uint256 updateWindowLengthSec_ + ) external onlyOwner { + require(updateWindowOffsetSec_ < updateTimeIntervalSec_, "DexOracle: BAD_OFFSET"); + require(updateWindowLengthSec_ <= updateTimeIntervalSec_, "DexOracle: BAD_LENGTH"); + updateTimeIntervalSec = updateTimeIntervalSec_; + updateWindowOffsetSec = updateWindowOffsetSec_; + updateWindowLengthSec = updateWindowLengthSec_; + } + + /** + * @dev Computes the chained TWAP, requiring at least `minElapsedSec` of + * measurement since the last `update()`. + * @return price The chained price as an OUTPUT_DECIMALS number. + * @return timeElapsed The length of the measurement window in seconds. + */ + function _computePrice(uint256 minElapsedSec) + private + view + returns (uint256 price, uint32 timeElapsed) + { + require(blockTimestampLast > 0, "DexOracle: UPDATE_NEVER_CALLED"); + + ( + uint256 leg1Cumulative, + uint256 leg2Cumulative, + uint32 blockTimestamp + ) = _currentCumulatives(); + unchecked { + // Wraparound is desired; both timestamps are taken mod 2**32. + timeElapsed = blockTimestamp - blockTimestampLast; + } + require(timeElapsed >= minElapsedSec, "DexOracle: PERIOD_NOT_ELAPSED"); + + uint256 priceLeg1 = _legPrice( + leg1Cumulative, + priceLeg1CumulativeLast, + timeElapsed, + decimalsFactorLeg1 + ); + uint256 priceLeg2 = _legPrice( + leg2Cumulative, + priceLeg2CumulativeLast, + timeElapsed, + decimalsFactorLeg2 + ); + price = priceLeg1.mul(priceLeg2).div(10**OUTPUT_DECIMALS); + } + + /** + * @dev Reads the current raw price cumulatives for both legs, selecting the + * configured direction. Both legs share the same block timestamp. + */ + function _currentCumulatives() + private + view + returns ( + uint256 leg1Cumulative, + uint256 leg2Cumulative, + uint32 blockTimestamp + ) + { + uint256 price0; + uint256 price1; + + (price0, price1, blockTimestamp) = UniswapV2OracleLibrary.currentCumulativePrices( + address(pairLeg1) + ); + leg1Cumulative = leg1UseToken1Price ? price1 : price0; + + (price0, price1, ) = UniswapV2OracleLibrary.currentCumulativePrices(address(pairLeg2)); + leg2Cumulative = leg2UseToken1Price ? price1 : price0; + } + + /** + * @dev Converts the windowed difference of a raw UQ112x112 cumulative into + * an OUTPUT_DECIMALS decimal price. + */ + function _legPrice( + uint256 cumulativeNow, + uint256 cumulativeLast, + uint32 timeElapsed, + uint256 decimalsFactor + ) private pure returns (uint256) { + uint256 avgRatioUQ112x112; + unchecked { + // The UniswapV2 accumulators are designed to overflow; the windowed + // difference is well-defined modulo 2**256. + avgRatioUQ112x112 = (cumulativeNow - cumulativeLast) / timeElapsed; + } + // Bridge the UQ112x112 raw reserve ratio into a decimal price. The + // windowed average is bounded, so the scaling cannot overflow. + return avgRatioUQ112x112.mul(decimalsFactor) >> 112; + } + + /** + * @dev Resolves the (base, quote) tokens a leg prices, given the read + * direction. price0 prices token0 in token1; price1 prices token1 in + * token0. + */ + function _baseQuote(address pair, bool useToken1Price) + private + view + returns (address base, address quote) + { + if (useToken1Price) { + base = IUniswapV2Pair(pair).token1(); + quote = IUniswapV2Pair(pair).token0(); + } else { + base = IUniswapV2Pair(pair).token0(); + quote = IUniswapV2Pair(pair).token1(); + } + } + + /** + * @dev Computes 10**(OUTPUT_DECIMALS + baseDecimals - quoteDecimals), the + * factor that converts a leg's raw UQ112x112 reserve ratio into an + * OUTPUT_DECIMALS decimal price. + */ + function _decimalsFactor(address base, address quote) private view returns (uint256) { + uint256 baseDecimals = uint256(IERC20Decimals(base).decimals()); + uint256 quoteDecimals = uint256(IERC20Decimals(quote).decimals()); + return 10**(OUTPUT_DECIMALS.add(baseDecimals).sub(quoteDecimals)); + } +} diff --git a/contracts/_external/IUniswapV2Pair.sol b/contracts/_external/IUniswapV2Pair.sol new file mode 100644 index 00000000..e808d460 --- /dev/null +++ b/contracts/_external/IUniswapV2Pair.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.4; + +/** + * @title IUniswapV2Pair + * @dev Minimal interface for the subset of UniswapV2Pair used by the oracle. + * See https://github.com/Uniswap/v2-core for the full interface. + */ +interface IUniswapV2Pair { + function token0() external view returns (address); + + function token1() external view returns (address); + + function getReserves() + external + view + returns ( + uint112 reserve0, + uint112 reserve1, + uint32 blockTimestampLast + ); + + function price0CumulativeLast() external view returns (uint256); + + function price1CumulativeLast() external view returns (uint256); +} diff --git a/contracts/_external/UniswapV2OracleLibrary.sol b/contracts/_external/UniswapV2OracleLibrary.sol new file mode 100644 index 00000000..a2c23e0a --- /dev/null +++ b/contracts/_external/UniswapV2OracleLibrary.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.4; + +import {IUniswapV2Pair} from "./IUniswapV2Pair.sol"; + +/** + * @title UniswapV2OracleLibrary + * @dev Helper methods for oracles that consume the UniswapV2 price + * accumulators. Ported to 0.8.x; the `unchecked` blocks preserve the + * modulo-2**N wraparound that the UniswapV2Pair accumulators rely on. + */ +library UniswapV2OracleLibrary { + // Returns the current block timestamp within the range of uint32, + // i.e. [0, 2**32 - 1]. Matches the truncation used by UniswapV2Pair. + function currentBlockTimestamp() internal view returns (uint32) { + return uint32(block.timestamp % 2**32); + } + + // Produces the cumulative prices using counterfactuals to save gas and + // avoid a call to sync. The returned cumulatives are UQ112x112 fixed point + // numbers denominated in raw (smallest-unit) reserves. + function currentCumulativePrices(address pair) + internal + view + returns ( + uint256 price0Cumulative, + uint256 price1Cumulative, + uint32 blockTimestamp + ) + { + blockTimestamp = currentBlockTimestamp(); + price0Cumulative = IUniswapV2Pair(pair).price0CumulativeLast(); + price1Cumulative = IUniswapV2Pair(pair).price1CumulativeLast(); + + // If time has elapsed since the last update on the pair, mock the + // accumulated price values to bring them current. + (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast) = IUniswapV2Pair(pair) + .getReserves(); + if (blockTimestampLast != blockTimestamp) { + unchecked { + // Subtraction overflow is desired. + uint32 timeElapsed = blockTimestamp - blockTimestampLast; + // Addition overflow is desired (matches UniswapV2Pair). + // counterfactual + price0Cumulative += uint256(_fraction(reserve1, reserve0)) * timeElapsed; + // counterfactual + price1Cumulative += uint256(_fraction(reserve0, reserve1)) * timeElapsed; + } + } + } + + // Encodes (numerator / denominator) as a UQ112x112 fixed point number, + // mirroring UniswapV2's FixedPoint.fraction. + function _fraction(uint112 numerator, uint112 denominator) private pure returns (uint224) { + require(denominator > 0, "UniswapV2OracleLibrary: DIVISION_BY_ZERO"); + return uint224((uint256(numerator) << 112) / denominator); + } +} diff --git a/contracts/mocks/MockMedianOracle.sol b/contracts/mocks/MockMedianOracle.sol new file mode 100644 index 00000000..3c6da392 --- /dev/null +++ b/contracts/mocks/MockMedianOracle.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.4; + +/** + * @title MockMedianOracle + * @dev Records the most recent payload pushed by a provider so tests can assert + * on what DexOracle reports. + */ +contract MockMedianOracle { + uint256 public lastPayload; + uint256 public reportCount; + + event ReportPushed(address provider, uint256 payload); + + function pushReport(uint256 payload) external { + lastPayload = payload; + reportCount += 1; + emit ReportPushed(msg.sender, payload); + } +} diff --git a/contracts/mocks/MockUniswapV2Pair.sol b/contracts/mocks/MockUniswapV2Pair.sol new file mode 100644 index 00000000..0c18347d --- /dev/null +++ b/contracts/mocks/MockUniswapV2Pair.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.4; + +/** + * @title MockERC20Decimals + * @dev Minimal token exposing only `decimals()`, used to back a mock pair's + * token0/token1 so DexOracle can read their decimals. + */ +contract MockERC20Decimals { + uint8 public decimals; + + constructor(uint8 decimals_) { + decimals = decimals_; + } +} + +/** + * @title MockUniswapV2Pair + * @dev Test double exposing the UniswapV2Pair surface DexOracle consumes, with + * setters so cumulatives, reserves and the pair's last-sync timestamp can + * be driven deterministically. + */ +contract MockUniswapV2Pair { + address public token0; + address public token1; + + uint112 private _reserve0; + uint112 private _reserve1; + uint32 private _blockTimestampLast; + + uint256 public price0CumulativeLast; + uint256 public price1CumulativeLast; + + constructor(address token0_, address token1_) { + token0 = token0_; + token1 = token1_; + } + + function setReserves( + uint112 reserve0_, + uint112 reserve1_, + uint32 blockTimestampLast_ + ) external { + _reserve0 = reserve0_; + _reserve1 = reserve1_; + _blockTimestampLast = blockTimestampLast_; + } + + function setCumulatives(uint256 price0CumulativeLast_, uint256 price1CumulativeLast_) external { + price0CumulativeLast = price0CumulativeLast_; + price1CumulativeLast = price1CumulativeLast_; + } + + function getReserves() + external + view + returns ( + uint112 reserve0, + uint112 reserve1, + uint32 blockTimestampLast + ) + { + return (_reserve0, _reserve1, _blockTimestampLast); + } +} diff --git a/scripts/deploy.ts b/scripts/deploy.ts index 0ea1469f..0c532bc5 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -221,3 +221,119 @@ task('deploy:oracle', 'Deploy the median oracle contract') await oracle.deployTransaction.wait(5) await verify(hre, oracle.address, []) }) + +task( + 'deploy:dexoracle', + 'Deploy the indirect AMPL/USDC 24h DEX TWAP oracle (AMPL/WETH x WETH/USDC)', +) + .addParam('medianOracle', 'MedianOracle address the oracle reports to') + // Mainnet defaults (token orderings verified on-chain): + // AMPL/WETH 0xc5be99... : token0=WETH, token1=AMPL -> price1 (WETH per AMPL) + // USDC/WETH 0xb4e16d... : token0=USDC, token1=WETH -> price1 (USDC per WETH) + .addOptionalParam( + 'pairLeg1', + 'AMPL/WETH UniswapV2 pair', + '0xc5be99a02c6857f9eac67bbce58df5572498f40c', + ) + .addOptionalParam('leg1UseToken1Price', 'Read price1 on leg1', 'true') + .addOptionalParam( + 'pairLeg2', + 'WETH/USDC UniswapV2 pair', + '0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc', + ) + .addOptionalParam('leg2UseToken1Price', 'Read price1 on leg2', 'true') + .addOptionalParam( + 'orchestrator', + 'Orchestrator to append update() to (owner only)', + '', + ) + .addFlag( + 'register', + 'addProvider(dexOracle) on the MedianOracle (owner only)', + ) + .addFlag('verify', 'Verify the contract on Etherscan') + .setAction(async (args, hre) => { + console.log(args) + + const leg1UseToken1Price = args.leg1UseToken1Price === 'true' + const leg2UseToken1Price = args.leg2UseToken1Price === 'true' + + // get signers + const deployer = (await hre.ethers.getSigners())[0] + console.log('Deployer', await deployer.getAddress()) + + // Reconfirm on-chain token ordering so the direction flags are correct. + // token0 is the lower address (Uniswap factory invariant); the page UIs + // mislabel base/quote, so always trust token0()/token1() here. + for (const [label, pair, useToken1Price] of [ + ['leg1 (AMPL/WETH)', args.pairLeg1, leg1UseToken1Price], + ['leg2 (WETH/USDC)', args.pairLeg2, leg2UseToken1Price], + ] as [string, string, boolean][]) { + const p = await hre.ethers.getContractAt('IUniswapV2Pair', pair) + const token0 = await p.token0() + const token1 = await p.token1() + const base = useToken1Price ? token1 : token0 + const quote = useToken1Price ? token0 : token1 + console.log( + `${label} ${pair}\n token0=${token0} token1=${token1}\n` + + ` useToken1Price=${useToken1Price} -> pricing base=${base} in quote=${quote}`, + ) + } + + // deploy contract + const params = [ + args.medianOracle, + args.pairLeg1, + leg1UseToken1Price, + args.pairLeg2, + leg2UseToken1Price, + ] + const dexOracle = await deployContract(hre, 'DexOracle', deployer, params) + console.log('DexOracle deployed to:', dexOracle.address) + console.log( + ' decimalsFactorLeg1:', + (await dexOracle.decimalsFactorLeg1()).toString(), + ) + console.log( + ' decimalsFactorLeg2:', + (await dexOracle.decimalsFactorLeg2()).toString(), + ) + + // Register as a MedianOracle provider (deployer must own the MedianOracle). + if (args.register) { + const medianOracle = await hre.ethers.getContractAt( + 'MedianOracle', + args.medianOracle, + ) + await waitFor( + medianOracle.connect(deployer).addProvider(dexOracle.address), + ) + console.log('Registered as provider on MedianOracle:', args.medianOracle) + } + + // Append update() to the Orchestrator so it fires right after each rebase. + // The encoded calldata is always printed so it can be proposed via multisig + // when the deployer is not the Orchestrator owner. + const updateData = dexOracle.interface.encodeFunctionData('update') + console.log('Orchestrator.addTransaction args:') + console.log(' destination:', dexOracle.address) + console.log(' data:', updateData) + if (args.orchestrator) { + const orchestrator = await hre.ethers.getContractAt( + 'Orchestrator', + args.orchestrator, + ) + await waitFor( + orchestrator + .connect(deployer) + .addTransaction(dexOracle.address, updateData), + ) + console.log('Appended update() to Orchestrator:', args.orchestrator) + } + + // wait and verify + if (args.verify) { + await dexOracle.deployTransaction.wait(5) + await verify(hre, dexOracle.address, params) + } + }) diff --git a/test/unit/DexOracle.ts b/test/unit/DexOracle.ts new file mode 100644 index 00000000..f8e12042 --- /dev/null +++ b/test/unit/DexOracle.ts @@ -0,0 +1,335 @@ +import { ethers, waffle } from 'hardhat' +import { Contract, BigNumber } from 'ethers' +import { expect } from 'chai' + +const { loadFixture } = waffle + +const BN = BigNumber.from +const Q112 = BN(2).pow(112) +const TWO256 = BN(2).pow(256) +const E18 = BN(10).pow(18) + +// Token decimals on mainnet. +const WETH_DECIMALS = 18 +const AMPL_DECIMALS = 9 +const USDC_DECIMALS = 6 + +// Per-leg decimals factors: 10**(18 + baseDecimals - quoteDecimals). +// leg1 (AMPL/WETH, price1): base AMPL(9), quote WETH(18) -> 1e9 +// leg2 (USDC/WETH, price1): base WETH(18), quote USDC(6) -> 1e30 +const DF1 = BN(10).pow(18 + AMPL_DECIMALS - WETH_DECIMALS) +const DF2 = BN(10).pow(18 + WETH_DECIMALS - USDC_DECIMALS) + +const DAY = 86400 +const HOUR = 3600 +const PERIOD = 22 * HOUR // default minReportTimeIntervalSec + +// Mirror DexOracle's per-leg fixed point conversion exactly. +const legPrice = ( + cumNow: BigNumber, + cumLast: BigNumber, + dt: number, + df: BigNumber, +) => { + const avg = cumNow.sub(cumLast).mod(TWO256).div(dt) // unchecked diff, then /dt + return avg.mul(df).shr(112) +} +const chainedPrice = ( + l1Now: BigNumber, + l1Last: BigNumber, + l2Now: BigNumber, + l2Last: BigNumber, + dt: number, +) => + legPrice(l1Now, l1Last, dt, DF1) + .mul(legPrice(l2Now, l2Last, dt, DF2)) + .div(E18) + +const setNextTime = (t: number) => + ethers.provider.send('evm_setNextBlockTimestamp', [t]) +const mineAt = async (t: number) => { + await setNextTime(t) + await ethers.provider.send('evm_mine', []) +} +const latestTime = async () => + (await ethers.provider.getBlock('latest')).timestamp + +async function fixture() { + const [deployer] = await ethers.getSigners() + + const tokenFactory = await ethers.getContractFactory('MockERC20Decimals') + const weth = await tokenFactory.deploy(WETH_DECIMALS) + const ampl = await tokenFactory.deploy(AMPL_DECIMALS) + const usdc = await tokenFactory.deploy(USDC_DECIMALS) + + const pairFactory = await ethers.getContractFactory('MockUniswapV2Pair') + // AMPL/WETH: token0 = WETH, token1 = AMPL (address-sorted on mainnet). + const pairLeg1 = await pairFactory.deploy(weth.address, ampl.address) + // USDC/WETH: token0 = USDC, token1 = WETH. + const pairLeg2 = await pairFactory.deploy(usdc.address, weth.address) + + const medianOracle = await ( + await ethers.getContractFactory('MockMedianOracle') + ).deploy() + + const oracle = await ( + await ethers.getContractFactory('DexOracle') + ).deploy( + medianOracle.address, + pairLeg1.address, + true, // leg1UseToken1Price -> WETH-per-AMPL + pairLeg2.address, + true, // leg2UseToken1Price -> USDC-per-WETH + ) + + return { + deployer, + weth, + ampl, + usdc, + pairLeg1, + pairLeg2, + medianOracle, + oracle, + } +} + +// Writes both pairs' price1 cumulatives and records their last-sync timestamp +// as `ts` (mock data — the measuring call later runs at `ts`, so +// currentCumulativePrices adds no counterfactual). price0 is set to a distinct +// sentinel to prove the contract reads the configured direction. +async function setPairState( + pairLeg1: Contract, + pairLeg2: Contract, + l1Price1: BigNumber, + l2Price1: BigNumber, + ts: number, +) { + const sentinel = BN('0xdead') + const reserve = BN(10).pow(20) + await pairLeg1.setCumulatives(sentinel, l1Price1) + await pairLeg2.setCumulatives(sentinel, l2Price1) + await pairLeg1.setReserves(reserve, reserve, ts) + await pairLeg2.setReserves(reserve, reserve, ts) +} + +// Returns an update time aligned to the daily update window (02:00 UTC) and the +// matching report time exactly PERIOD later, both safely in the future. +async function windowTimes() { + const now = await latestTime() + let tUpdate = Math.floor(now / DAY) * DAY + 7200 + while (tUpdate <= now + 100) tUpdate += DAY + return { tUpdate, tReport: tUpdate + PERIOD } +} + +describe('DexOracle', () => { + describe('construction', () => { + it('derives per-leg decimals factors from on-chain decimals', async () => { + const { oracle } = await loadFixture(fixture) + expect(await oracle.decimalsFactorLeg1()).to.equal(DF1) // 1e9 + expect(await oracle.decimalsFactorLeg2()).to.equal(DF2) // 1e30 + expect(await oracle.OUTPUT_DECIMALS()).to.equal(18) + }) + + it('logs the shared bridge token as matched', async () => { + const { oracle, weth } = await loadFixture(fixture) + await expect(oracle.deployTransaction) + .to.emit(oracle, 'LogBridgeTokens') + .withArgs(weth.address, weth.address, true) + }) + + it('sets the deployer as owner', async () => { + const { oracle, deployer } = await loadFixture(fixture) + expect(await oracle.owner()).to.equal(await deployer.getAddress()) + }) + }) + + describe('before the first update', () => { + it('reverts computePrice with UPDATE_NEVER_CALLED', async () => { + const { oracle } = await loadFixture(fixture) + await expect(oracle.computePrice()).to.be.revertedWith( + 'DexOracle: UPDATE_NEVER_CALLED', + ) + }) + + it('reverts pushReport with UPDATE_NEVER_CALLED', async () => { + const { oracle } = await loadFixture(fixture) + await expect(oracle.pushReport()).to.be.revertedWith( + 'DexOracle: UPDATE_NEVER_CALLED', + ) + }) + }) + + describe('update', () => { + it('snapshots the configured (token1) cumulatives in-window', async () => { + const { oracle, pairLeg1, pairLeg2 } = await loadFixture(fixture) + const { tUpdate } = await windowTimes() + const l1 = BN('111').mul(Q112) + const l2 = BN('222').mul(Q112) + + await setPairState(pairLeg1, pairLeg2, l1, l2, tUpdate) + await setNextTime(tUpdate) + await oracle.update() + + expect(await oracle.priceLeg1CumulativeLast()).to.equal(l1) + expect(await oracle.priceLeg2CumulativeLast()).to.equal(l2) + expect(await oracle.blockTimestampLast()).to.equal(tUpdate) + }) + + it('reverts outside the update window', async () => { + const { oracle, pairLeg1, pairLeg2 } = await loadFixture(fixture) + const { tUpdate } = await windowTimes() + const offWindow = tUpdate + 2 * HOUR // 04:00 UTC, outside [02:00, 02:20) + await setPairState(pairLeg1, pairLeg2, BN(1), BN(1), offWindow) + await setNextTime(offWindow) + await expect(oracle.update()).to.be.revertedWith( + 'DexOracle: NOT_IN_UPDATE_WINDOW', + ) + }) + }) + + describe('pushReport', () => { + it('reports the chained 18-decimal TWAP to the median oracle', async () => { + const { oracle, pairLeg1, pairLeg2, medianOracle } = await loadFixture( + fixture, + ) + const { tUpdate, tReport } = await windowTimes() + + // Window opens with both legs' price1 cumulatives at zero. + await setPairState(pairLeg1, pairLeg2, BN(0), BN(0), tUpdate) + await setNextTime(tUpdate) + await oracle.update() + + // Choose realistic averages: WETH-per-AMPL ~ 0.0004, USDC-per-WETH ~ 3000. + const avg1 = BN('400000').mul(Q112) // legPrice1 = 4e14 + const avg2 = BN('3000000000000000000000').mul(Q112).div(DF2) + const l1Report = avg1.mul(PERIOD) + const l2Report = avg2.mul(PERIOD) + await setPairState(pairLeg1, pairLeg2, l1Report, l2Report, tReport) + + const expected = chainedPrice(l1Report, BN(0), l2Report, BN(0), PERIOD) + + await setNextTime(tReport) + await expect(oracle.pushReport()) + .to.emit(oracle, 'LogReportPushed') + .withArgs(expected, PERIOD) + + expect(await medianOracle.lastPayload()).to.equal(expected) + expect(await medianOracle.reportCount()).to.equal(1) + // Sanity: AMPL/USDC should land near $1.20. + const target = ethers.utils.parseUnits('1.2', 18) + expect(expected.sub(target).abs()).to.be.lt(target.div(1000)) + }) + + it('reverts when the minimum period has not elapsed', async () => { + const { oracle, pairLeg1, pairLeg2 } = await loadFixture(fixture) + const { tUpdate } = await windowTimes() + await setPairState(pairLeg1, pairLeg2, BN(0), BN(0), tUpdate) + await setNextTime(tUpdate) + await oracle.update() + + const tEarly = tUpdate + PERIOD - 60 // one minute short of 22h + await setPairState( + pairLeg1, + pairLeg2, + BN(10).mul(Q112), + BN(10).mul(Q112), + tEarly, + ) + await setNextTime(tEarly) + await expect(oracle.pushReport()).to.be.revertedWith( + 'DexOracle: PERIOD_NOT_ELAPSED', + ) + }) + + it('handles UniswapV2 accumulator wraparound', async () => { + const { oracle, pairLeg1, pairLeg2, medianOracle } = await loadFixture( + fixture, + ) + const { tUpdate, tReport } = await windowTimes() + + const avg1 = BN('400000').mul(Q112) + const avg2 = BN('3000000000000000000000').mul(Q112).div(DF2) + const delta1 = avg1.mul(PERIOD) + const delta2 = avg2.mul(PERIOD) + + // Start near the uint256 ceiling so the window straddles a wrap. + const start1 = TWO256.sub(100) + const start2 = TWO256.sub(7) + await setPairState(pairLeg1, pairLeg2, start1, start2, tUpdate) + await setNextTime(tUpdate) + await oracle.update() + + const end1 = start1.add(delta1).mod(TWO256) + const end2 = start2.add(delta2).mod(TWO256) + await setPairState(pairLeg1, pairLeg2, end1, end2, tReport) + + // Wrapped diff must equal the non-wrapped result. + const expected = chainedPrice(delta1, BN(0), delta2, BN(0), PERIOD) + + await setNextTime(tReport) + await oracle.pushReport() + expect(await medianOracle.lastPayload()).to.equal(expected) + }) + }) + + describe('computePrice', () => { + it('returns the chained TWAP without reporting', async () => { + const { oracle, pairLeg1, pairLeg2, medianOracle } = await loadFixture( + fixture, + ) + const { tUpdate, tReport } = await windowTimes() + + await setPairState(pairLeg1, pairLeg2, BN(0), BN(0), tUpdate) + await setNextTime(tUpdate) + await oracle.update() + + const l1 = BN('123456').mul(Q112).mul(PERIOD) + const l2 = BN('654321').mul(Q112).mul(PERIOD) + await setPairState(pairLeg1, pairLeg2, l1, l2, tReport) + // computePrice() is a view (it never mines or writes). This mineAt is a + // test-only device: an eth_call evaluates against the latest block's + // timestamp, so we advance the local chain to tReport so the read sees + // timeElapsed == PERIOD (and block.timestamp == reserves.blockTimestampLast, + // avoiding a counterfactual). On a live chain block.timestamp advances on + // its own; nothing mines a block to read the price. + await mineAt(tReport) + + const expected = chainedPrice(l1, BN(0), l2, BN(0), PERIOD) + expect(await oracle.computePrice()).to.equal(expected) + // computePrice must not push a report. + expect(await medianOracle.reportCount()).to.equal(0) + }) + }) + + describe('price0 direction (useToken1Price = false)', () => { + it('reads price0 and bases the factor on token0', async () => { + const [deployer] = await ethers.getSigners() + const tokenFactory = await ethers.getContractFactory('MockERC20Decimals') + const usdc = await tokenFactory.deploy(USDC_DECIMALS) + const weth = await tokenFactory.deploy(WETH_DECIMALS) + const ampl = await tokenFactory.deploy(AMPL_DECIMALS) + const pairFactory = await ethers.getContractFactory('MockUniswapV2Pair') + // Leg with token0 = USDC: price0 prices USDC(base,6) in WETH(quote,18). + const pairLeg1 = await pairFactory.deploy(usdc.address, weth.address) + const pairLeg2 = await pairFactory.deploy(weth.address, ampl.address) + const medianOracle = await ( + await ethers.getContractFactory('MockMedianOracle') + ).deploy() + const oracle = await (await ethers.getContractFactory('DexOracle')) + .connect(deployer) + .deploy( + medianOracle.address, + pairLeg1.address, + false, + pairLeg2.address, + false, + ) + + // base USDC(6), quote WETH(18) -> 10**(18+6-18) = 1e6 + expect(await oracle.decimalsFactorLeg1()).to.equal(BN(10).pow(6)) + // base WETH(18), quote AMPL(9) -> 10**(18+18-9) = 1e27 + expect(await oracle.decimalsFactorLeg2()).to.equal(BN(10).pow(27)) + }) + }) +})