From 324b36490b91d80a534194ec23ad9d25f27d8b49 Mon Sep 17 00:00:00 2001 From: web3rover Date: Wed, 10 Dec 2025 23:09:23 +0530 Subject: [PATCH] feat: added sentinel oracle --- contracts/SentinelOracle.sol | 68 ++++++++++++ contracts/interfaces/IPancakeV3Pool.sol | 23 ++++ contracts/interfaces/IUniswapV3Pool.sol | 20 ++++ contracts/libraries/FixedPoint96.sol | 6 ++ contracts/libraries/FullMath.sol | 50 +++++++++ contracts/oracles/PancakeSwapOracle.sol | 137 ++++++++++++++++++++++++ contracts/oracles/UniswapOracle.sol | 137 ++++++++++++++++++++++++ test/fork/SentinelOracle.prices.ts | 93 ++++++++++++++++ 8 files changed, 534 insertions(+) create mode 100644 contracts/SentinelOracle.sol create mode 100644 contracts/interfaces/IPancakeV3Pool.sol create mode 100644 contracts/interfaces/IUniswapV3Pool.sol create mode 100644 contracts/libraries/FixedPoint96.sol create mode 100644 contracts/libraries/FullMath.sol create mode 100644 contracts/oracles/PancakeSwapOracle.sol create mode 100644 contracts/oracles/UniswapOracle.sol create mode 100644 test/fork/SentinelOracle.prices.ts diff --git a/contracts/SentinelOracle.sol b/contracts/SentinelOracle.sol new file mode 100644 index 00000000..fc23b8c0 --- /dev/null +++ b/contracts/SentinelOracle.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.25; + +import { OracleInterface } from "./interfaces/OracleInterface.sol"; +import { AccessControlledV8 } from "@venusprotocol/governance-contracts/contracts/Governance/AccessControlledV8.sol"; + +/** + * @title SentinelOracle + * @author Venus + * @notice Aggregator oracle that routes price requests to appropriate DEX oracles + */ +contract SentinelOracle is AccessControlledV8 { + /// @notice Configuration for token price source + /// @param oracle Address of the DEX oracle to use for this token + struct TokenConfig { + address oracle; + } + + /// @notice Mapping of token addresses to their oracle configuration + mapping(address => TokenConfig) public tokenConfigs; + + /// @notice Emitted when a token's oracle configuration is updated + /// @param token The token address + /// @param oracle The oracle address + event TokenOracleConfigUpdated(address indexed token, address indexed oracle); + + /// @notice Thrown when a zero address is provided + error ZeroAddress(); + + /// @notice Thrown when token is not configured + error TokenNotConfigured(); + + /// @notice Constructor for PriceSentinelOracle + constructor() { + // Note that the contract is upgradeable. Use initialize() or reinitializers + // to set the state variables. + _disableInitializers(); + } + + /// @notice Initialize the contract + /// @param accessControlManager_ Address of the access control manager + function initialize(address accessControlManager_) external initializer { + __AccessControlled_init(accessControlManager_); + } + + /// @notice Set oracle configuration for a token + /// @param token Address of the token + /// @param oracle Address of the DEX oracle to use + function setTokenOracleConfig(address token, address oracle) external { + _checkAccessAllowed("setTokenOracleConfig(address,address)"); + + if (token == address(0)) revert ZeroAddress(); + if (oracle == address(0)) revert ZeroAddress(); + + tokenConfigs[token] = TokenConfig({ oracle: oracle }); + emit TokenOracleConfigUpdated(token, oracle); + } + + /// @notice Get the price of an asset from the configured DEX oracle + /// @param asset Address of the asset + /// @return price Price in (36 - asset decimals) format, same as ResilientOracle + function getPrice(address asset) external view returns (uint256 price) { + TokenConfig memory config = tokenConfigs[asset]; + if (config.oracle == address(0)) revert TokenNotConfigured(); + + return OracleInterface(config.oracle).getPrice(asset); + } +} diff --git a/contracts/interfaces/IPancakeV3Pool.sol b/contracts/interfaces/IPancakeV3Pool.sol new file mode 100644 index 00000000..8bbbd3e3 --- /dev/null +++ b/contracts/interfaces/IPancakeV3Pool.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.25; + +interface IPancakeV3Pool { + function token0() external view returns (address); + + function token1() external view returns (address); + + function fee() external view returns (uint24); + + function slot0() + external + view + returns ( + uint160 sqrtPriceX96, + int24 tick, + uint16 observationIndex, + uint16 observationCardinality, + uint16 observationCardinalityNext, + uint32 feeProtocol, + bool unlocked + ); +} diff --git a/contracts/interfaces/IUniswapV3Pool.sol b/contracts/interfaces/IUniswapV3Pool.sol new file mode 100644 index 00000000..86f0416d --- /dev/null +++ b/contracts/interfaces/IUniswapV3Pool.sol @@ -0,0 +1,20 @@ +pragma solidity ^0.8.25; + +interface IUniswapV3Pool { + function slot0() + external + view + returns ( + uint160 sqrtPriceX96, + int24 tick, + uint16 observationIndex, + uint16 observationCardinality, + uint16 observationCardinalityNext, + uint8 feeProtocol, + bool unlocked + ); + + function token0() external view returns (address); + + function token1() external view returns (address); +} diff --git a/contracts/libraries/FixedPoint96.sol b/contracts/libraries/FixedPoint96.sol new file mode 100644 index 00000000..c1dec3f5 --- /dev/null +++ b/contracts/libraries/FixedPoint96.sol @@ -0,0 +1,6 @@ +pragma solidity ^0.8.25; + +library FixedPoint96 { + uint8 internal constant RESOLUTION = 96; + uint256 internal constant Q96 = 0x1000000000000000000000000; +} diff --git a/contracts/libraries/FullMath.sol b/contracts/libraries/FullMath.sol new file mode 100644 index 00000000..872da37a --- /dev/null +++ b/contracts/libraries/FullMath.sol @@ -0,0 +1,50 @@ +pragma solidity ^0.8.25; + +library FullMath { + function mulDiv(uint256 a, uint256 b, uint256 denominator) internal pure returns (uint256 result) { + uint256 prod0; + uint256 prod1; + assembly { + let mm := mulmod(a, b, not(0)) + prod0 := mul(a, b) + prod1 := sub(sub(mm, prod0), lt(mm, prod0)) + } + + if (prod1 == 0) { + require(denominator > 0); + assembly { + result := div(prod0, denominator) + } + return result; + } + + require(denominator > prod1); + + uint256 remainder; + assembly { + remainder := mulmod(a, b, denominator) + prod1 := sub(prod1, gt(remainder, prod0)) + prod0 := sub(prod0, remainder) + } + + uint256 twos = denominator & (~denominator + 1); + assembly { + denominator := div(denominator, twos) + prod0 := div(prod0, twos) + twos := add(div(sub(0, twos), twos), 1) + } + + prod0 |= prod1 * twos; + + uint256 inverse = (3 * denominator) ^ 2; + inverse *= 2 - denominator * inverse; + inverse *= 2 - denominator * inverse; + inverse *= 2 - denominator * inverse; + inverse *= 2 - denominator * inverse; + inverse *= 2 - denominator * inverse; + inverse *= 2 - denominator * inverse; + + result = prod0 * inverse; + return result; + } +} diff --git a/contracts/oracles/PancakeSwapOracle.sol b/contracts/oracles/PancakeSwapOracle.sol new file mode 100644 index 00000000..4560a09b --- /dev/null +++ b/contracts/oracles/PancakeSwapOracle.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.25; + +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import { IPancakeV3Pool } from "../interfaces/IPancakeV3Pool.sol"; +import { ResilientOracleInterface } from "../interfaces/OracleInterface.sol"; +import { AccessControlledV8 } from "@venusprotocol/governance-contracts/contracts/Governance/AccessControlledV8.sol"; +import { FixedPoint96 } from "../libraries/FixedPoint96.sol"; +import { FullMath } from "../libraries/FullMath.sol"; + +/** + * @title PancakeSwapOracle + * @author Venus + * @notice Oracle contract for fetching asset prices from PancakeSwap V3 + */ +contract PancakeSwapOracle is AccessControlledV8 { + /// @notice Resilient Oracle for getting reference token prices + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + ResilientOracleInterface public immutable RESILIENT_ORACLE; + + /// @notice Mapping of token addresses to their pool addresses + mapping(address => address) public tokenPools; + + /// @notice Emitted when a token's pool configuration is updated + /// @param token The token address + /// @param pool The pool address + event PoolConfigUpdated(address indexed token, address indexed pool); + + /// @notice Thrown when a zero address is provided + error ZeroAddress(); + + /// @notice Thrown when an invalid pool address is provided + error InvalidPool(); + + /// @notice Thrown when token is not configured + error TokenNotConfigured(); + + /// @notice Thrown when price calculation fails + error PriceCalculationError(); + + /// @notice Constructor for PancakeSwapPriceOracle + /// @param resilientOracle_ Address of the resilient oracle + constructor(ResilientOracleInterface resilientOracle_) { + RESILIENT_ORACLE = resilientOracle_; + + // Note that the contract is upgradeable. Use initialize() or reinitializers + // to set the state variables. + _disableInitializers(); + } + + /// @notice Initialize the contract + /// @param accessControlManager_ Address of the access control manager + function initialize(address accessControlManager_) external initializer { + __AccessControlled_init(accessControlManager_); + } + + /// @notice Set pool configuration for a token + /// @param token Address of the token + /// @param pool Address of the PancakeSwap V3 pool + function setPoolConfig(address token, address pool) external { + _checkAccessAllowed("setPoolConfig(address,address)"); + + if (token == address(0)) revert ZeroAddress(); + if (pool == address(0)) revert ZeroAddress(); + + tokenPools[token] = pool; + emit PoolConfigUpdated(token, pool); + } + + /// @notice Get the price of an asset from PancakeSwap V3 + /// @param asset Address of the asset + /// @return price Price in (36 - asset decimals) format, same as ResilientOracle + function getPrice(address asset) external view returns (uint256 price) { + address pool = tokenPools[asset]; + if (pool == address(0)) revert TokenNotConfigured(); + + return _getPancakeSwapV3Price(pool, asset); + } + + /// @notice Get token price from PancakeSwap V3 pool + /// @param pool PancakeSwap V3 pool address + /// @param token Target token address + /// @return price Price in (36 - token decimals) format + function _getPancakeSwapV3Price(address pool, address token) internal view returns (uint256 price) { + if (pool == address(0)) revert InvalidPool(); + + IPancakeV3Pool v3Pool = IPancakeV3Pool(pool); + address token0 = v3Pool.token0(); + address token1 = v3Pool.token1(); + (uint160 sqrtPriceX96, , , , , , ) = v3Pool.slot0(); + + uint256 priceX96 = FullMath.mulDiv(sqrtPriceX96, sqrtPriceX96, FixedPoint96.Q96); + + address targetToken = token; + address referenceToken; + bool targetIsToken0; + + if (token == token0) { + targetIsToken0 = true; + referenceToken = token1; + } else if (token == token1) { + targetIsToken0 = false; + referenceToken = token0; + } else { + revert InvalidPool(); + } + + uint256 referencePrice = RESILIENT_ORACLE.getPrice(referenceToken); + uint8 targetDecimals = IERC20Metadata(targetToken).decimals(); + uint8 referenceDecimals = IERC20Metadata(referenceToken).decimals(); + uint8 targetPriceDecimals = 36 - targetDecimals; + + uint256 targetTokensPerReferenceToken; + + if (targetIsToken0) { + targetTokensPerReferenceToken = FullMath.mulDiv(FixedPoint96.Q96 * (10 ** 18), 1, priceX96); + } else { + targetTokensPerReferenceToken = FullMath.mulDiv(priceX96 * (10 ** 18), 1, FixedPoint96.Q96); + } + + // Calculate intermediate price in 18 decimals + price = FullMath.mulDiv( + referencePrice * (10 ** targetDecimals), + (10 ** 18), + targetTokensPerReferenceToken * (10 ** referenceDecimals) + ); + + // Convert from 18 decimals to target price decimals + if (targetPriceDecimals != 18) { + if (targetPriceDecimals > 18) { + price = price * (10 ** (targetPriceDecimals - 18)); + } else { + price = price / (10 ** (18 - targetPriceDecimals)); + } + } + } +} diff --git a/contracts/oracles/UniswapOracle.sol b/contracts/oracles/UniswapOracle.sol new file mode 100644 index 00000000..91dec3f8 --- /dev/null +++ b/contracts/oracles/UniswapOracle.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.25; + +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import { IUniswapV3Pool } from "../interfaces/IUniswapV3Pool.sol"; +import { ResilientOracleInterface } from "../interfaces/OracleInterface.sol"; +import { AccessControlledV8 } from "@venusprotocol/governance-contracts/contracts/Governance/AccessControlledV8.sol"; +import { FixedPoint96 } from "../libraries/FixedPoint96.sol"; +import { FullMath } from "../libraries/FullMath.sol"; + +/** + * @title UniswapOracle + * @author Venus + * @notice Oracle contract for fetching asset prices from Uniswap V3 + */ +contract UniswapOracle is AccessControlledV8 { + /// @notice Resilient Oracle for getting reference token prices + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + ResilientOracleInterface public immutable RESILIENT_ORACLE; + + /// @notice Mapping of token addresses to their pool addresses + mapping(address => address) public tokenPools; + + /// @notice Emitted when a token's pool configuration is updated + /// @param token The token address + /// @param pool The pool address + event PoolConfigUpdated(address indexed token, address indexed pool); + + /// @notice Thrown when a zero address is provided + error ZeroAddress(); + + /// @notice Thrown when an invalid pool address is provided + error InvalidPool(); + + /// @notice Thrown when token is not configured + error TokenNotConfigured(); + + /// @notice Thrown when price calculation fails + error PriceCalculationError(); + + /// @notice Constructor for UniswapPriceOracle + /// @param resilientOracle_ Address of the resilient oracle + constructor(ResilientOracleInterface resilientOracle_) { + RESILIENT_ORACLE = resilientOracle_; + + // Note that the contract is upgradeable. Use initialize() or reinitializers + // to set the state variables. + _disableInitializers(); + } + + /// @notice Initialize the contract + /// @param accessControlManager_ Address of the access control manager + function initialize(address accessControlManager_) external initializer { + __AccessControlled_init(accessControlManager_); + } + + /// @notice Set pool configuration for a token + /// @param token Address of the token + /// @param pool Address of the Uniswap V3 pool + function setPoolConfig(address token, address pool) external { + _checkAccessAllowed("setPoolConfig(address,address)"); + + if (token == address(0)) revert ZeroAddress(); + if (pool == address(0)) revert ZeroAddress(); + + tokenPools[token] = pool; + emit PoolConfigUpdated(token, pool); + } + + /// @notice Get the price of an asset from Uniswap V3 + /// @param asset Address of the asset + /// @return price Price in (36 - asset decimals) format, same as ResilientOracle + function getPrice(address asset) external view returns (uint256 price) { + address pool = tokenPools[asset]; + if (pool == address(0)) revert TokenNotConfigured(); + + return _getUniswapV3Price(pool, asset); + } + + /// @notice Get token price from Uniswap V3 pool + /// @param pool Uniswap V3 pool address + /// @param token Target token address + /// @return price Price in (36 - token decimals) format + function _getUniswapV3Price(address pool, address token) internal view returns (uint256 price) { + if (pool == address(0)) revert InvalidPool(); + + IUniswapV3Pool v3Pool = IUniswapV3Pool(pool); + address token0 = v3Pool.token0(); + address token1 = v3Pool.token1(); + (uint160 sqrtPriceX96, , , , , , ) = v3Pool.slot0(); + + uint256 priceX96 = FullMath.mulDiv(sqrtPriceX96, sqrtPriceX96, FixedPoint96.Q96); + + address targetToken = token; + address referenceToken; + bool targetIsToken0; + + if (token == token0) { + targetIsToken0 = true; + referenceToken = token1; + } else if (token == token1) { + targetIsToken0 = false; + referenceToken = token0; + } else { + revert InvalidPool(); + } + + uint256 referencePrice = RESILIENT_ORACLE.getPrice(referenceToken); + uint8 targetDecimals = IERC20Metadata(targetToken).decimals(); + uint8 referenceDecimals = IERC20Metadata(referenceToken).decimals(); + uint8 targetPriceDecimals = 36 - targetDecimals; + + uint256 targetTokensPerReferenceToken; + + if (targetIsToken0) { + targetTokensPerReferenceToken = FullMath.mulDiv(FixedPoint96.Q96 * (10 ** 18), 1, priceX96); + } else { + targetTokensPerReferenceToken = FullMath.mulDiv(priceX96 * (10 ** 18), 1, FixedPoint96.Q96); + } + + // Calculate intermediate price in 18 decimals + price = FullMath.mulDiv( + referencePrice * (10 ** targetDecimals), + (10 ** 18), + targetTokensPerReferenceToken * (10 ** referenceDecimals) + ); + + // Convert from 18 decimals to target price decimals + if (targetPriceDecimals != 18) { + if (targetPriceDecimals > 18) { + price = price * (10 ** (targetPriceDecimals - 18)); + } else { + price = price / (10 ** (18 - targetPriceDecimals)); + } + } + } +} diff --git a/test/fork/SentinelOracle.prices.ts b/test/fork/SentinelOracle.prices.ts new file mode 100644 index 00000000..f7566590 --- /dev/null +++ b/test/fork/SentinelOracle.prices.ts @@ -0,0 +1,93 @@ +import { impersonateAccount, loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { expect } from "chai"; +import { parseUnits } from "ethers/lib/utils"; +import { ethers, upgrades } from "hardhat"; + +import type { IAccessControlManagerV8, PancakeSwapOracle, SentinelOracle, UniswapOracle } from "../../typechain-types"; +import { IAccessControlManagerV8__factory } from "../../typechain-types"; +import { forking } from "./utils"; + +const TRX = "0xCE7de646e7208a4Ef112cb6ed5038FA6cC6b12e3"; +const BTCB = "0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c"; +const USDT = "0x55d398326f99059fF775485246999027B3197955"; +const NORMAL_TIMELOCK = "0x939bD8d64c0A9583A7Dcea9933f7b21697ab6396"; +const ACM = "0x4788629abc6cfca10f9f969efdeaa1cf70c23555"; +const ORACLE = "0x6592b5DE802159F3E74B2486b091D11a8256ab8A"; + +const FORKED_NETWORK: string = process.env.FORKED_NETWORK || ""; +const FORK: boolean = process.env.FORK === "true"; + +async function setupFixture() { + await impersonateAccount(NORMAL_TIMELOCK); + const timelock = await ethers.getSigner(NORMAL_TIMELOCK); + + const pancakeSwapOracleFactory = await ethers.getContractFactory("PancakeSwapOracle"); + const pancakeSwapOracle = await upgrades.deployProxy(pancakeSwapOracleFactory, [ACM], { + constructorArgs: [ORACLE], + unsafeAllow: ["constructor", "internal-function-storage"], + }); + + const uniswapOracleFactory = await ethers.getContractFactory("UniswapOracle"); + const uniswapOracle = await upgrades.deployProxy(uniswapOracleFactory, [ACM], { + constructorArgs: [ORACLE], + unsafeAllow: ["constructor", "internal-function-storage"], + }); + + const sentinelOracleFactory = await ethers.getContractFactory("SentinelOracle"); + const sentinelOracle = (await upgrades.deployProxy(sentinelOracleFactory, [ACM], { + unsafeAllow: ["constructor", "internal-function-storage"], + })) as SentinelOracle; + + const acm = IAccessControlManagerV8__factory.connect(ACM, timelock) as IAccessControlManagerV8; + await acm + .connect(timelock) + .giveCallPermission(pancakeSwapOracle.address, "setPoolConfig(address,address)", NORMAL_TIMELOCK); + await acm + .connect(timelock) + .giveCallPermission(uniswapOracle.address, "setPoolConfig(address,address)", NORMAL_TIMELOCK); + await acm + .connect(timelock) + .giveCallPermission(sentinelOracle.address, "setTokenOracleConfig(address,address)", NORMAL_TIMELOCK); + + return { timelock, pancakeSwapOracle, uniswapOracle, sentinelOracle }; +} + +if (FORK && FORKED_NETWORK === "bscmainnet") { + forking(70909246, () => { + describe("SentinelOracle DEX prices", () => { + let sentinelOracle: SentinelOracle; + let pancakeSwapOracle: PancakeSwapOracle; + let uniswapOracle: UniswapOracle; + let timelock: SignerWithAddress; + + beforeEach(async () => { + ({ timelock, pancakeSwapOracle, uniswapOracle, sentinelOracle } = await loadFixture(setupFixture)); + + await pancakeSwapOracle.connect(timelock).setPoolConfig(TRX, "0xF683113764E4499c473aCd38Fc4b37E71554E4aD"); + await pancakeSwapOracle.connect(timelock).setPoolConfig(USDT, "0x172fcD41E0913e95784454622d1c3724f546f849"); + + await uniswapOracle.connect(timelock).setPoolConfig(BTCB, "0x28dF0835942396B7a1b7aE1cd068728E6ddBbAfD"); + + await sentinelOracle.connect(timelock).setTokenOracleConfig(TRX, pancakeSwapOracle.address); + await sentinelOracle.connect(timelock).setTokenOracleConfig(USDT, pancakeSwapOracle.address); + await sentinelOracle.connect(timelock).setTokenOracleConfig(BTCB, uniswapOracle.address); + }); + + it("check TRX price from PancakeSwap", async () => { + const price = await sentinelOracle.getPrice(TRX); + expect(price).to.be.equal(parseUnits("0.287615712885971478", 30)); + }); + + it("check USDT price from PancakeSwap", async () => { + const price = await sentinelOracle.getPrice(USDT); + expect(price).to.be.equal(parseUnits("0.999676428802385649", 18)); + }); + + it("check BTCB price from Uniswap", async () => { + const price = await sentinelOracle.getPrice(BTCB); + expect(price).to.be.equal(parseUnits("91784.949423700465674501", 18)); + }); + }); + }); +}