diff --git a/contracts/oracles/ConstantPriceFeed.sol b/contracts/oracles/ConstantPriceFeed.sol new file mode 100644 index 0000000..0b6121a --- /dev/null +++ b/contracts/oracles/ConstantPriceFeed.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.23; + +import {IPriceFeed} from "@gearbox-protocol/core-v3/contracts/interfaces/base/IPriceFeed.sol"; +import {SanityCheckTrait} from "@gearbox-protocol/core-v3/contracts/traits/SanityCheckTrait.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {IncorrectPriceException} from "@gearbox-protocol/core-v3/contracts/interfaces/IExceptions.sol"; + +/// @title Constant price feed +/// @notice A simple price feed that returns a constant value set in the constructor +contract ConstantPriceFeed is IPriceFeed, SanityCheckTrait { + /// @notice Contract version + uint256 public constant override version = 3_10; + + /// @notice Contract type + bytes32 public constant override contractType = "PRICE_FEED::CONSTANT"; + + /// @notice Answer precision (always 8 decimals for USD price feeds) + uint8 public constant override decimals = 8; + + /// @notice Indicates that price oracle can skip checks for this price feed's answers + bool public constant override skipPriceCheck = true; + + /// @notice The token address this price feed is for + address public immutable token; + + /// @notice The constant price value to return + int256 public immutable price; + + /// @notice Price feed description + string public description; + + /// @notice Constructor + /// @param _token The token address this price feed is for + /// @param _price The constant price value to return (with 8 decimals) + constructor(address _token, int256 _price) nonZeroAddress(_token) { + if (_price <= 0) revert IncorrectPriceException(); + + token = _token; + price = _price; + + string memory tokenSymbol = IERC20Metadata(_token).symbol(); + description = string.concat(tokenSymbol, " / USD constant price feed"); + } + + /// @notice Serialized price feed parameters + function serialize() external view override returns (bytes memory) { + return abi.encode(token, price); + } + + /// @notice Returns the constant USD price of the token with 8 decimals + function latestRoundData() external view override returns (uint80, int256 answer, uint256, uint256, uint80) { + return (0, price, 0, block.timestamp, 0); + } +} diff --git a/contracts/oracles/curve/CurveTWAPPriceFeed.sol b/contracts/oracles/curve/CurveTWAPPriceFeed.sol new file mode 100644 index 0000000..787ef70 --- /dev/null +++ b/contracts/oracles/curve/CurveTWAPPriceFeed.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.23; + +import {ICurvePool} from "../../interfaces/curve/ICurvePool.sol"; +import {IPriceFeed} from "@gearbox-protocol/core-v3/contracts/interfaces/base/IPriceFeed.sol"; +import {WAD} from "@gearbox-protocol/core-v3/contracts/libraries/Constants.sol"; +import {PriceFeedValidationTrait} from "@gearbox-protocol/core-v3/contracts/traits/PriceFeedValidationTrait.sol"; +import {SanityCheckTrait} from "@gearbox-protocol/core-v3/contracts/traits/SanityCheckTrait.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +/// @title Curve TWAP price feed +/// @notice Computes price of coin 1 in a Curve pool in terms of units of coin 0, based on the pool's TWAP, which is then +/// multiplied by the price of coin 0. +contract CurveTWAPPriceFeed is IPriceFeed, PriceFeedValidationTrait, SanityCheckTrait { + using Address for address; + + /// @notice Thrown when the curve oracle value is out of bounds + error CurveOracleOutOfBoundsException(); + + /// @notice Thrown when the upper bound is less than the lower bound passed in constructor + error UpperBoundTooLowException(); + + /// @notice Contract version + uint256 public constant override version = 3_10; + + /// @notice Contract type + bytes32 public constant override contractType = "PRICE_FEED::CURVE_TWAP"; + + /// @notice Answer precision (always 8 decimals for USD price feeds) + uint8 public constant override decimals = 8; + + /// @notice Indicates that price oracle can skip checks for this price feed's answers + bool public constant override skipPriceCheck = true; + + /// @notice token token address + address public immutable token; + + /// @notice Curve pool address + address public immutable pool; + + /// @notice Coin 0 price feed address + address public immutable priceFeed; + + /// @notice Staleness period for the coin 0 price feed + uint32 public immutable stalenessPeriod; + + /// @notice Flag indicating if price feed checks can be skipped + bool public immutable skipCheck; + + /// @notice Lower bound for the exchange rate + uint256 public immutable lowerBound; + + /// @notice Upper bound for the exchange rate + uint256 public immutable upperBound; + + /// @notice Price feed description + string public description; + + /// @notice Constructor + /// @param _lowerBound Lower bound for the pool exchange rate + /// @param _upperBound Upper bound for the pool exchange rate (must be greater than lower bound) + /// @param _token Address of the coin 1 in the pool + /// @param _pool Address of the curve pool + /// @param _priceFeed Address of the coin 0 price feed + /// @param _stalenessPeriod Staleness period for the coin 0 price feed + constructor( + uint256 _lowerBound, + uint256 _upperBound, + address _token, + address _pool, + address _priceFeed, + uint32 _stalenessPeriod + ) nonZeroAddress(_token) nonZeroAddress(_pool) nonZeroAddress(_priceFeed) { + if (_upperBound < _lowerBound) revert UpperBoundTooLowException(); + + token = _token; + pool = _pool; + priceFeed = _priceFeed; + stalenessPeriod = _stalenessPeriod; + skipCheck = _validatePriceFeedMetadata(_priceFeed, _stalenessPeriod); + lowerBound = _lowerBound; + upperBound = _upperBound; + + string memory tokenSymbol = IERC20Metadata(_token).symbol(); + description = string.concat(tokenSymbol, " / USD Curve TWAP price feed"); + } + + /// @notice Serialized price feed parameters + function serialize() external view override returns (bytes memory) { + return abi.encode(token, pool, priceFeed, lowerBound, upperBound); + } + + /// @notice Returns USD price of the token token with 8 decimals + function latestRoundData() external view override returns (uint80, int256 answer, uint256, uint256, uint80) { + uint256 exchangeRate = ICurvePool(pool).price_oracle(); + + if (exchangeRate < lowerBound) revert CurveOracleOutOfBoundsException(); + if (exchangeRate > upperBound) exchangeRate = upperBound; + + int256 underlyingPrice = _getValidatedPrice(priceFeed, stalenessPeriod, skipCheck); + + answer = int256((exchangeRate * uint256(underlyingPrice)) / WAD); + + return (0, answer, 0, 0, 0); + } +} diff --git a/contracts/oracles/curve/CurveUSDPriceFeed.sol b/contracts/oracles/curve/CurveUSDPriceFeed.sol deleted file mode 100644 index 64be3fd..0000000 --- a/contracts/oracles/curve/CurveUSDPriceFeed.sol +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -// Gearbox Protocol. Generalized leverage for DeFi protocols -// (c) Gearbox Foundation, 2024. -pragma solidity ^0.8.23; - -import {SingleAssetLPPriceFeed} from "../SingleAssetLPPriceFeed.sol"; -import {ICurvePool} from "../../interfaces/curve/ICurvePool.sol"; -import {WAD} from "@gearbox-protocol/core-v3/contracts/libraries/Constants.sol"; - -/// @title crvUSD price feed -/// @notice Computes crvUSD price as product of crvUSD-USDC stableswap pool exchange rate and USDC price feed. -/// While crvUSD is not an LP token itself, the pricing logic is fairly similar, so existing infrastructure -/// is reused. Particularly, the same bounding mechanism is applied to the pool exchange rate. -contract CurveUSDPriceFeed is SingleAssetLPPriceFeed { - uint256 public constant override version = 3_10; - bytes32 public constant override contractType = "PRICE_FEED::CURVE_USD"; - - constructor( - address _owner, - uint256 _lowerBound, - address _crvUSD, - address _pool, - address _priceFeed, - uint32 _stalenessPeriod - ) - SingleAssetLPPriceFeed(_owner, _crvUSD, _pool, _priceFeed, _stalenessPeriod) // U:[CRV-D-1] - { - _setLimiter(_lowerBound); // U:[CRV-D-1] - } - - function getLPExchangeRate() public view override returns (uint256) { - return ICurvePool(lpContract).price_oracle(); // U:[CRV-D-1] - } - - function getScale() public pure override returns (uint256) { - return WAD; // U:[CRV-D-1] - } -} diff --git a/contracts/test/suites/PriceFeedDeployer.sol b/contracts/test/suites/PriceFeedDeployer.sol index 45ce2b1..7871c91 100644 --- a/contracts/test/suites/PriceFeedDeployer.sol +++ b/contracts/test/suites/PriceFeedDeployer.sol @@ -38,7 +38,7 @@ import {BPTStablePriceFeed} from "../../oracles/balancer/BPTStablePriceFeed.sol" import {BPTWeightedPriceFeed} from "../../oracles/balancer/BPTWeightedPriceFeed.sol"; import {CurveCryptoLPPriceFeed} from "../../oracles/curve/CurveCryptoLPPriceFeed.sol"; import {CurveStableLPPriceFeed} from "../../oracles/curve/CurveStableLPPriceFeed.sol"; -import {CurveUSDPriceFeed} from "../../oracles/curve/CurveUSDPriceFeed.sol"; +import {CurveTWAPPriceFeed} from "../../oracles/curve/CurveTWAPPriceFeed.sol"; import {ERC4626PriceFeed} from "../../oracles/erc4626/ERC4626PriceFeed.sol"; import {WstETHPriceFeed} from "../../oracles/lido/WstETHPriceFeed.sol"; import {RedstonePriceFeed} from "../../oracles/updatable/RedstonePriceFeed.sol"; @@ -316,10 +316,11 @@ contract PriceFeedDeployer is Test, PriceFeedDataLive { address pool = supportedContracts.addressOf(crvUSDPriceFeeds[i].pool); address underlying = tokenTestSuite.addressOf(crvUSDPriceFeeds[i].underlying); + address pf = address( - new CurveUSDPriceFeed( - owner, - ICurvePool(pool).get_virtual_price() * 99 / 100, + new CurveTWAPPriceFeed( + 0, + 1e18, token, pool, _getDeployedFeed(underlying, crvUSDPriceFeeds[i].reserve), diff --git a/contracts/test/unit/ConstantPriceFeed.unit.t.sol b/contracts/test/unit/ConstantPriceFeed.unit.t.sol new file mode 100644 index 0000000..4186133 --- /dev/null +++ b/contracts/test/unit/ConstantPriceFeed.unit.t.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: UNLICENSED +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.23; + +import {Test} from "forge-std/Test.sol"; +import {ConstantPriceFeed} from "../../oracles/ConstantPriceFeed.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {IncorrectPriceException} from "@gearbox-protocol/core-v3/contracts/interfaces/IExceptions.sol"; + +contract ConstantPriceFeedUnitTest is Test { + ConstantPriceFeed priceFeed; + address token; + int256 constantPrice; + + function setUp() public { + token = makeAddr("TOKEN"); + constantPrice = 12345678; // $1.23456789 with 8 decimals + + // Mock token symbol call + vm.mockCall(token, abi.encodeCall(IERC20Metadata.symbol, ()), abi.encode("TKN")); + + priceFeed = new ConstantPriceFeed(token, constantPrice); + } + + /// @notice U:[CPF-1]: Price feed initialization works as expected + function test_U_CPF_1_initialization() public { + assertEq(priceFeed.token(), token, "Incorrect token address"); + assertEq(priceFeed.price(), constantPrice, "Incorrect constant price"); + assertEq(priceFeed.description(), "TKN / USD constant price feed", "Incorrect description"); + assertEq(priceFeed.decimals(), 8, "Incorrect decimals"); + assertTrue(priceFeed.skipPriceCheck(), "skipPriceCheck should be true"); + assertEq(priceFeed.contractType(), "PRICE_FEED::CONSTANT", "Incorrect contract type"); + } + + /// @notice U:[CPF-2]: Price feed returns constant price as expected + function test_U_CPF_2_latestRoundData() public { + (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) = + priceFeed.latestRoundData(); + + assertEq(roundId, 0, "Incorrect roundId"); + assertEq(answer, constantPrice, "Incorrect price answer"); + assertEq(startedAt, 0, "Incorrect startedAt"); + assertEq(updatedAt, block.timestamp, "Incorrect updatedAt"); + assertEq(answeredInRound, 0, "Incorrect answeredInRound"); + } + + /// @notice U:[CPF-3]: Price feed serialization works as expected + function test_U_CPF_3_serialize() public { + bytes memory serialized = priceFeed.serialize(); + (address serializedToken, int256 serializedPrice) = abi.decode(serialized, (address, int256)); + + assertEq(serializedToken, token, "Incorrect serialized token"); + assertEq(serializedPrice, constantPrice, "Incorrect serialized price"); + } + + /// @notice U:[CPF-4]: Price feed constructor validates parameters + function test_U_CPF_4_constructor_validation() public { + // Test with zero address token + vm.expectRevert(); + new ConstantPriceFeed(address(0), constantPrice); + + // Test with zero price + vm.expectRevert(IncorrectPriceException.selector); + new ConstantPriceFeed(token, 0); + + // Test with negative price + vm.expectRevert(IncorrectPriceException.selector); + new ConstantPriceFeed(token, -1); + } +} diff --git a/contracts/test/unit/curve/CurveTWAPPriceFeed.unit.t.sol b/contracts/test/unit/curve/CurveTWAPPriceFeed.unit.t.sol new file mode 100644 index 0000000..caed9ef --- /dev/null +++ b/contracts/test/unit/curve/CurveTWAPPriceFeed.unit.t.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: UNLICENSED +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.23; + +import {PriceFeedUnitTestHelper} from "../PriceFeedUnitTestHelper.sol"; + +import {CurvePoolMock} from "../../mocks/curve/CurvePoolMock.sol"; + +import {ICurvePool} from "../../../interfaces/curve/ICurvePool.sol"; +import {CurveTWAPPriceFeed} from "../../../oracles/curve/CurveTWAPPriceFeed.sol"; +import {WAD} from "@gearbox-protocol/core-v3/contracts/libraries/Constants.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +contract CurveTWAPPriceFeedUnitTest is PriceFeedUnitTestHelper { + CurveTWAPPriceFeed priceFeed; + CurvePoolMock curvePool; + address token; + uint256 lowerBound; + uint256 upperBound; + + function setUp() public { + _setUp(); + + token = makeAddr("token"); + vm.mockCall(token, abi.encodeCall(IERC20Metadata.symbol, ()), abi.encode("TOKEN")); + + curvePool = new CurvePoolMock(); + curvePool.hack_price_oracle(1.03 ether); + + lowerBound = 1.02 ether; + upperBound = 1.05 ether; + + priceFeed = new CurveTWAPPriceFeed( + lowerBound, upperBound, token, address(curvePool), address(underlyingPriceFeed), 1 days + ); + } + + /// @notice U:[CTWAP-1]: Price feed works as expected + function test_U_CTWAP_1_price_feed_works_as_expected() public { + // constructor + assertEq(priceFeed.token(), token, "Incorrect token address"); + assertEq(priceFeed.pool(), address(curvePool), "Incorrect pool address"); + assertEq(priceFeed.priceFeed(), address(underlyingPriceFeed), "Incorrect price feed"); + assertEq(priceFeed.lowerBound(), lowerBound, "Incorrect lower bound"); + assertEq(priceFeed.upperBound(), upperBound, "Incorrect upper bound"); + assertEq(priceFeed.description(), "TOKEN / USD Curve TWAP price feed", "Incorrect description"); + + // latestRoundData + vm.expectCall(address(curvePool), abi.encodeCall(ICurvePool.price_oracle, ())); + (, int256 price,,,) = priceFeed.latestRoundData(); + assertEq(price, int256((1.03 ether * 2e8) / WAD), "Incorrect price"); + } + + /// @notice U:[CTWAP-2]: Price feed handles exchange rate bounds properly + function test_U_CTWAP_2_price_feed_handles_exchange_rate_bounds() public { + // Test when exchange rate is below lower bound + curvePool.hack_price_oracle(1.01 ether); // Below lower bound + vm.expectRevert(CurveTWAPPriceFeed.CurveOracleOutOfBoundsException.selector); + priceFeed.latestRoundData(); + + // Test when exchange rate equals lower bound + curvePool.hack_price_oracle(lowerBound); + (, int256 price,,,) = priceFeed.latestRoundData(); + assertEq(price, int256((lowerBound * 2e8) / WAD), "Incorrect price at lower bound"); + + // Test when exchange rate equals upper bound + curvePool.hack_price_oracle(upperBound); + (, int256 price2,,,) = priceFeed.latestRoundData(); + assertEq(price2, int256((upperBound * 2e8) / WAD), "Incorrect price at upper bound"); + + // Test when exchange rate exceeds upper bound (should be capped) + curvePool.hack_price_oracle(1.06 ether); // Above upper bound + (, int256 price3,,,) = priceFeed.latestRoundData(); + assertEq(price3, int256((upperBound * 2e8) / WAD), "Incorrect price when above upper bound"); + } + + /// @notice U:[CTWAP-3]: Price feed constructor validates bounds correctly + function test_U_CTWAP_3_constructor_validates_bounds() public { + // Test upper bound < lower bound + vm.expectRevert(CurveTWAPPriceFeed.UpperBoundTooLowException.selector); + new CurveTWAPPriceFeed( + lowerBound, + lowerBound - 1, // Upper less than lower + token, + address(curvePool), + address(underlyingPriceFeed), + 1 days + ); + } +} diff --git a/contracts/test/unit/curve/CurveUSDPriceFeed.unit.t.sol b/contracts/test/unit/curve/CurveUSDPriceFeed.unit.t.sol deleted file mode 100644 index 334a8f6..0000000 --- a/contracts/test/unit/curve/CurveUSDPriceFeed.unit.t.sol +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -// Gearbox Protocol. Generalized leverage for DeFi protocols -// (c) Gearbox Foundation, 2024. -pragma solidity ^0.8.23; - -import {PriceFeedUnitTestHelper} from "../PriceFeedUnitTestHelper.sol"; - -import {CurvePoolMock} from "../../mocks/curve/CurvePoolMock.sol"; - -import {ICurvePool} from "../../../interfaces/curve/ICurvePool.sol"; -import {CurveUSDPriceFeed} from "../../../oracles/curve/CurveUSDPriceFeed.sol"; - -contract CurveUSDPriceFeedUnitTest is PriceFeedUnitTestHelper { - CurveUSDPriceFeed priceFeed; - CurvePoolMock curvePool; - address crvUSD; - - function setUp() public { - _setUp(); - - crvUSD = makeAddr("crvUSD"); - curvePool = new CurvePoolMock(); - curvePool.hack_price_oracle(1.03 ether); - - priceFeed = - new CurveUSDPriceFeed(owner, 1.02 ether, crvUSD, address(curvePool), address(underlyingPriceFeed), 1 days); - } - - /// @notice U:[CRV-D-1]: Price feed works as expected - function test_U_CRV_D_01_price_feed_works_as_expected() public { - // constructor - assertEq(priceFeed.lpToken(), crvUSD, "Incorrect lpToken"); - assertEq(priceFeed.lpContract(), address(curvePool), "Incorrect lpToken"); - assertEq(priceFeed.lowerBound(), 1.02 ether, "Incorrect lower bound"); - - // overriden functions - vm.expectCall(address(curvePool), abi.encodeCall(ICurvePool.price_oracle, ())); - assertEq(priceFeed.getLPExchangeRate(), 1.03 ether, "Incorrect getLPExchangeRate"); - assertEq(priceFeed.getScale(), 1 ether, "Incorrect getScale"); - } -}