From b9415010c18f7445076f9c90fb54380b8f9384d7 Mon Sep 17 00:00:00 2001 From: 08xmt Date: Sat, 8 Feb 2025 03:58:45 +0100 Subject: [PATCH 1/2] Add Chainlink2CoinsFeed --- src/feeds/ChainlinkBridgeAssetFeed.sol | 114 ++++++++++ .../ChainlinkBridgeAssetBase.t.sol | 139 ++++++++++++ test/feedForkTests/WbtcFeedFork.t.sol | 200 ++---------------- 3 files changed, 271 insertions(+), 182 deletions(-) create mode 100644 src/feeds/ChainlinkBridgeAssetFeed.sol create mode 100644 test/feedForkTests/ChainlinkBridgeAssetBase.t.sol diff --git a/src/feeds/ChainlinkBridgeAssetFeed.sol b/src/feeds/ChainlinkBridgeAssetFeed.sol new file mode 100644 index 00000000..cb3a462a --- /dev/null +++ b/src/feeds/ChainlinkBridgeAssetFeed.sol @@ -0,0 +1,114 @@ +pragma solidity ^0.8.20; + +interface IChainlinkFeed { + function aggregator() external view returns (address aggregator); + function decimals() external view returns (uint8 decimals); + function latestRoundData() + external + view + returns ( + uint80 roundId, + int256 crvUsdPrice, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ); + function latestAnswer() external view returns (int256 price); +} +interface IAggregator { + function maxAnswer() external view returns (int192); + function minAnswer() external view returns (int192); +} +interface ICurvePool { + function price_oracle(uint k) external view returns (uint256); +} + +contract ChainlinkBridgeAssetFeed { + IChainlinkFeed public immutable collateralToBridgeAsset; + IChainlinkFeed public immutable bridgeAssetToUsd; + bool public immutable bridgeAssetDenominator; + string public description; + + /** + * @notice Oracle for the USD price of a collateral asset derived by combining a collateral-bridgeAsset oracle and bridgeAsset-USD oracle + * @param _collateralToBridgeAsset Chainlink oracle returning the collateral/bridgeAsset OR bridgeAsset/collateral price + * @param _bridgeAssetToUsd Chainlink oracle returning the bridgeAsset/USD price + * @param _bridgeAssetDenominator If true, the `_collateralToBridgeAsset` oracle will return collateral/bridgeAsset, if false, bridgeAsset/collateral. + * @dev We assume the underlying oracles have already been normalized using our standard chainlink feed. These feeds should also be used for fallback logic. + */ + constructor(address _collateralToBridgeAsset, address _bridgeAssetToUsd, bool _bridgeAssetDenominator){ + collateralToBridgeAsset = IChainlinkFeed(_collateralToBridgeAsset); + bridgeAssetToUsd = IChainlinkFeed(_bridgeAssetToUsd); + bridgeAssetDenominator = _bridgeAssetDenominator; + //TODO: Do string concat + description = "Combine oracle of 2 underlying oracles"; + require(collateralToBridgeAsset.decimals() == 18, "collateralToBridgeAsset feed not normalized"); + require(bridgeAssetToUsd.decimals() == 18, "bridgeAssetToUsd feed not normalize"); + } + + function decimals() external view returns (uint8) { + return 18; + } + + /** + * @notice Retrieves the latest round data for the collateral token price feed + * @dev This function calculates the collateral price in USD by combining the bridgeAsset to USD price from a Chainlink oracle and the collateral to bridgeAsset ratio from the bridgeAsset Chainlink oracle + * @return roundId The round ID of the Chainlink price feed for the feed with the lowest updatedAt feed + * @return bridgeAssetToUsdPrice The latest collateral price in USD computed from the collateral/bridgeAsset and bridgeAsset/USD feeds + * @return startedAt The timestamp when the latest round of Chainlink price feed started of the lowest last updatedAt feed + * @return updatedAt The lowest timestamp when either of the latest round of Chainlink price feed was updated + * @return answeredInRound The round ID in which the answer was computed of the lowest updatedAt feed + */ + function latestRoundData() + public + view + returns (uint80, int256, uint256, uint256, uint80) + { + ( + uint80 collateralToBridgeAssetRoundId, + int256 collateralToBridgeAssetPrice, + uint collateralToBridgeAssetStartedAt, + uint collateralToBridgeAssetUpdatedAt, + uint80 collateralToBridgeAssetAnsweredInRound + ) = collateralToBridgeAsset.latestRoundData(); + ( + uint80 bridgeAssetToUsdRoundId, + int256 bridgeAssetToUsdPrice, + uint bridgeAssetToUsdStartedAt, + uint bridgeAssetToUsdUpdatedAt, + uint80 bridgeAssetToUsdAnsweredInRound + ) = bridgeAssetToUsd.latestRoundData(); + int price; + if(bridgeAssetDenominator){ + price = bridgeAssetToUsdPrice * collateralToBridgeAssetPrice / 10 ** 18; + } else { + price = bridgeAssetToUsdPrice * 10 ** 18 / collateralToBridgeAssetPrice; + } + if (collateralToBridgeAssetUpdatedAt < bridgeAssetToUsdUpdatedAt) { + return ( + collateralToBridgeAssetRoundId, + price, + collateralToBridgeAssetStartedAt, + collateralToBridgeAssetUpdatedAt, + collateralToBridgeAssetAnsweredInRound + ); + } else { + return ( + bridgeAssetToUsdRoundId, + price, + bridgeAssetToUsdStartedAt, + bridgeAssetToUsdUpdatedAt, + bridgeAssetToUsdAnsweredInRound + ); + } + } + /** + * @notice Returns the latest price only + * @dev Unlike chainlink oracles, the latestAnswer will always be the same as in the latestRoundData + * @return int256 Returns the last finalized price of the chainlink oracle + */ + function latestAnswer() external view returns (int256) { + (, int256 latestPrice, , , ) = latestRoundData(); + return latestPrice; + } +} diff --git a/test/feedForkTests/ChainlinkBridgeAssetBase.t.sol b/test/feedForkTests/ChainlinkBridgeAssetBase.t.sol new file mode 100644 index 00000000..fe893cd8 --- /dev/null +++ b/test/feedForkTests/ChainlinkBridgeAssetBase.t.sol @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import {console} from "forge-std/console.sol"; +import "src/feeds/ChainlinkBasePriceFeed.sol"; +import {ChainlinkBridgeAssetFeed} from "src/feeds/ChainlinkBridgeAssetFeed.sol"; + +abstract contract ChainlinkBridgeAssetBase is Test { + ChainlinkBridgeAssetFeed feed; + ChainlinkBasePriceFeed collateralToBridgeAssetFeed; // main coin1 feed + ChainlinkBasePriceFeed bridgeAssetToUsdFeed; // main coin2 feed + + uint256 public constant SCALE = 1e18; + + function init( + address _collateralToBridgeAssetFeed, + address _bridgeAssetToUsdFeed, + bool _bridgeAssetDenominator + ) public { + collateralToBridgeAssetFeed = ChainlinkBasePriceFeed(_collateralToBridgeAssetFeed); + bridgeAssetToUsdFeed = ChainlinkBasePriceFeed(_bridgeAssetToUsdFeed); + feed = new ChainlinkBridgeAssetFeed(_collateralToBridgeAssetFeed, _bridgeAssetToUsdFeed, _bridgeAssetDenominator); + console.log("feed :", feed.description()); + } + + function test_decimals() public { + assertEq(feed.decimals(), 18); + } + + function test_latestAnswer() public { + (, int256 lpUsdPrice, , , ) = feed.latestRoundData(); + + assertEq(feed.latestAnswer(), lpUsdPrice); + } + + function test_collateralToBridgeAssetIncrease() public { + (,int256 priceBefore,,,) = feed.latestRoundData(); + + (uint80 roundId, int256 price, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + = collateralToBridgeAssetFeed.latestRoundData(); + + _mockLatestRoundData( + address(collateralToBridgeAssetFeed), + roundId, + price * 110 / 100, + startedAt, + updatedAt, + answeredInRound + ); + + (,int256 priceAfter,,,) = feed.latestRoundData(); + + if(feed.bridgeAssetDenominator()){ + assertGt(priceAfter, priceBefore); + } else { + assertLt(priceAfter, priceBefore); + } + } + + function test_collateralToBridgeAssetDecrease() public { + (,int256 priceBefore,,,) = feed.latestRoundData(); + + (uint80 roundId, int256 price, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + = collateralToBridgeAssetFeed.latestRoundData(); + + _mockLatestRoundData( + address(collateralToBridgeAssetFeed), + roundId, + price * 90 / 100, + startedAt, + updatedAt, + answeredInRound + ); + + (,int256 priceAfter,,,) = feed.latestRoundData(); + + if(feed.bridgeAssetDenominator()){ + assertLt(priceAfter, priceBefore); + } else { + assertGt(priceAfter, priceBefore); + } + } + + function test_bridgeAssetToUsdIncrease() public { + (,int256 priceBefore,,,) = feed.latestRoundData(); + + (uint80 roundId, int256 price, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + = collateralToBridgeAssetFeed.latestRoundData(); + + _mockLatestRoundData( + address(bridgeAssetToUsdFeed), + roundId, + price * 110 / 100, + startedAt, + updatedAt, + answeredInRound + ); + + (,int256 priceAfter,,,) = feed.latestRoundData(); + + assertLt(priceAfter, priceBefore); + } + + function test_bridgeAssetToUsdDecrease() public { + (,int256 priceBefore,,,) = feed.latestRoundData(); + + (uint80 roundId, int256 price, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + = collateralToBridgeAssetFeed.latestRoundData(); + + _mockLatestRoundData( + address(bridgeAssetToUsdFeed), + roundId, + price * 90 / 100, + startedAt, + updatedAt, + answeredInRound + ); + + (,int256 priceAfter,,,) = feed.latestRoundData(); + + assertLt(priceAfter, priceBefore); + } + + function _mockLatestRoundData( + address target, + uint80 roundId, + int256 price, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) internal { + vm.mockCall( + target, + abi.encodeWithSelector(IChainlinkFeed.latestRoundData.selector), + abi.encode(roundId, price, startedAt, updatedAt, answeredInRound) + ); + } +} diff --git a/test/feedForkTests/WbtcFeedFork.t.sol b/test/feedForkTests/WbtcFeedFork.t.sol index 00368dfe..5bba104d 100644 --- a/test/feedForkTests/WbtcFeedFork.t.sol +++ b/test/feedForkTests/WbtcFeedFork.t.sol @@ -2,194 +2,30 @@ pragma solidity ^0.8.20; import "forge-std/Test.sol"; -import "src/feeds/WbtcPriceFeed.sol"; -import {IAggregator} from "src/feeds/WbtcPriceFeed.sol"; -contract WbtcFeedFork is Test { - WbtcPriceFeed feed; +import "src/feeds/ChainlinkBridgeAssetFeed.sol"; +import {ChainlinkBasePriceFeed} from "src/feeds/ChainlinkBasePriceFeed.sol"; +import {ChainlinkBridgeAssetBase} from "test/feedForkTests/ChainlinkBridgeAssetBase.t.sol"; + +contract WbtcFeedFork is ChainlinkBridgeAssetBase { + address wbtcToBtcBase = 0xfdFD9C85aD200c506Cf9e21F1FD8dd01932FBB23; + uint wbtcToBtcHeartbeat = 3660; + address btcToUsdBase = 0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c; + uint btcToUsdHeartbeat = 3660; + address gov; + ChainlinkBasePriceFeed wbtcToBtc; + ChainlinkBasePriceFeed btcToUsd; function setUp() public { string memory url = vm.rpcUrl("mainnet"); vm.createSelectFork(url); - feed = new WbtcPriceFeed(); + wbtcToBtc = new ChainlinkBasePriceFeed(gov, wbtcToBtcBase, address(0), wbtcToBtcHeartbeat); + btcToUsd = new ChainlinkBasePriceFeed(gov, btcToUsdBase, address(0), btcToUsdHeartbeat); + init(address(wbtcToBtc), address(btcToUsd), true); } - // function test_latestRoundData_NominalCaseWithinBounds() public view { - // { - // ( - // uint80 clRoundId1, - // int256 btcToUsdPrice, - // uint clStartedAt1, - // uint clUpdatedAt1, - // uint80 clAnsweredInRound1 - // ) = feed.btcToUsd().latestRoundData(); - - // ( - // uint80 clRoundId2, - // int256 wbtcToBtcPrice, - // uint clStartedAt2, - // uint clUpdatedAt2, - // uint80 clAnsweredInRound2 - // ) = feed.wbtcToBtc().latestRoundData(); - - // ( - // uint80 roundId, - // int256 wbtcUsdPrice, - // uint startedAt, - // uint updatedAt, - // uint80 answeredInRound - // ) = feed.latestRoundData(); - - // if (clUpdatedAt1 < clUpdatedAt2) { - // assertEq(clRoundId1, roundId); - // assertEq(clStartedAt1, startedAt); - // assertEq(clUpdatedAt1, updatedAt); - // assertEq(clAnsweredInRound1, answeredInRound); - // } else { - // assertEq(clRoundId2, roundId); - // assertEq(clStartedAt2, startedAt); - // assertEq(clUpdatedAt2, updatedAt); - // assertEq(clAnsweredInRound2, answeredInRound); - // } - - // assertGt(wbtcUsdPrice, 10 ** 8); - // assertEq((btcToUsdPrice * 10 ** 8) / wbtcToBtcPrice, wbtcUsdPrice); - // if (wbtcToBtcPrice > 10 ** 8) assertGt(btcToUsdPrice, wbtcUsdPrice); - // if (wbtcToBtcPrice < 10 ** 8) assertGt(wbtcUsdPrice, btcToUsdPrice); - // } - // } - - function test_latestRoundData_WillReturnFallbackWhenOutOfMaxBounds() - public - { - ( - uint80 clRoundId1, - int256 btcToUsdPrice, - uint clStartedAt1, - uint clUpdatedAt1, - uint80 clAnsweredInRound1 - ) = feed.btcToUsd().latestRoundData(); - ( - uint80 clRoundId2, - int256 wbtcToBtcPrice, - uint clStartedAt2, - uint clUpdatedAt2, - uint80 clAnsweredInRound2 - ) = feed.wbtcToBtc().latestRoundData(); - vm.mockCall( - address(feed.wbtcToBtc()), - abi.encodeWithSelector(IChainlinkFeed.latestRoundData.selector), - abi.encode( - clRoundId2, - IAggregator(feed.wbtcToBtc().aggregator()).maxAnswer(), - clStartedAt2, - clUpdatedAt2, - clAnsweredInRound2 - ) - ); - ( - uint80 roundId, - int256 wbtcUsdPrice, - uint startedAt, - uint updatedAt, - uint80 answeredInRound - ) = feed.latestRoundData(); - - if (clUpdatedAt1 < clUpdatedAt2) { - assertEq(clRoundId1, roundId); - assertEq(clStartedAt1, startedAt); - assertEq(clUpdatedAt1, updatedAt); - assertEq(clAnsweredInRound1, answeredInRound); - } else { - assertEq(clRoundId2, roundId); - assertEq(clStartedAt2, startedAt); - assertEq(clUpdatedAt2, updatedAt); - assertEq(clAnsweredInRound2, answeredInRound); - } - assertLt( - wbtcUsdPrice, - (feed.btcToUsd().latestAnswer() * 110) / 100, - "Fallback price more than 10% higher than oracle" - ); - assertGt( - wbtcUsdPrice, - (feed.btcToUsd().latestAnswer() * 90) / 100, - "Wbtc more than 10% lower than oracle" - ); - assertEq( - feed.wbtcToUsdFallbackOracle(), - wbtcUsdPrice, - "Did not return fallback price" - ); - } - - function test_latestRoundData_WillReturnFallbackWhenOutOfMinBounds() - public - { - ( - uint80 clRoundId1, - int256 btcToUsdPrice, - uint clStartedAt1, - uint clUpdatedAt1, - uint80 clAnsweredInRound1 - ) = feed.btcToUsd().latestRoundData(); - ( - uint80 clRoundId2, - int256 wbtcToBtcPrice, - uint clStartedAt2, - uint clUpdatedAt2, - uint80 clAnsweredInRound2 - ) = feed.wbtcToBtc().latestRoundData(); - vm.mockCall( - address(feed.wbtcToBtc()), - abi.encodeWithSelector(IChainlinkFeed.latestRoundData.selector), - abi.encode( - clRoundId2, - IAggregator(feed.wbtcToBtc().aggregator()).minAnswer(), - clStartedAt2, - clUpdatedAt2, - clAnsweredInRound2 - ) - ); - ( - uint80 roundId, - int256 wbtcUsdPrice, - uint startedAt, - uint updatedAt, - uint80 answeredInRound - ) = feed.latestRoundData(); - - if (clUpdatedAt1 < clUpdatedAt2) { - assertEq(clRoundId1, roundId); - assertEq(clStartedAt1, startedAt); - assertEq(clUpdatedAt1, updatedAt); - assertEq(clAnsweredInRound1, answeredInRound); - } else { - assertEq(clRoundId2, roundId); - assertEq(clStartedAt2, startedAt); - assertEq(clUpdatedAt2, updatedAt); - assertEq(clAnsweredInRound2, answeredInRound); - } - - assertLt( - wbtcUsdPrice, - (feed.btcToUsd().latestAnswer() * 110) / 100, - "Fallback price more than 10% higher than oracle" - ); - assertGt( - wbtcUsdPrice, - (feed.btcToUsd().latestAnswer() * 90) / 100, - "Wbtc more than 10% lower than oracle" - ); - assertEq( - feed.wbtcToUsdFallbackOracle(), - wbtcUsdPrice, - "Did not return fallback price" - ); - } - - function test_latestAnswer_ReturnSameAsLatestRoundData() public { - (, int btcLRD, , , ) = feed.wbtcToBtc().latestRoundData(); - int btcLA = feed.wbtcToBtc().latestAnswer(); + function test_latestAnswer_returnSameAsLatestRoundData() public { + (, int btcLRD, , , ) = feed.collateralToBridgeAsset().latestRoundData(); + int btcLA = feed.collateralToBridgeAsset().latestAnswer(); assertEq(btcLRD, btcLA); } From 6aea66c97296ec4bdaca2943405b38351ca6fd95 Mon Sep 17 00:00:00 2001 From: 08xmt Date: Thu, 20 Feb 2025 08:50:19 +0100 Subject: [PATCH 2/2] Add programmatic description --- src/feeds/ChainlinkBridgeAssetFeed.sol | 31 +++++--------------------- test/feedForkTests/WbtcFeedFork.t.sol | 4 ++++ 2 files changed, 10 insertions(+), 25 deletions(-) diff --git a/src/feeds/ChainlinkBridgeAssetFeed.sol b/src/feeds/ChainlinkBridgeAssetFeed.sol index cb3a462a..13a789ca 100644 --- a/src/feeds/ChainlinkBridgeAssetFeed.sol +++ b/src/feeds/ChainlinkBridgeAssetFeed.sol @@ -1,27 +1,5 @@ pragma solidity ^0.8.20; - -interface IChainlinkFeed { - function aggregator() external view returns (address aggregator); - function decimals() external view returns (uint8 decimals); - function latestRoundData() - external - view - returns ( - uint80 roundId, - int256 crvUsdPrice, - uint256 startedAt, - uint256 updatedAt, - uint80 answeredInRound - ); - function latestAnswer() external view returns (int256 price); -} -interface IAggregator { - function maxAnswer() external view returns (int192); - function minAnswer() external view returns (int192); -} -interface ICurvePool { - function price_oracle(uint k) external view returns (uint256); -} +import {IChainlinkFeed} from "src/interfaces/IChainlinkFeed.sol"; contract ChainlinkBridgeAssetFeed { IChainlinkFeed public immutable collateralToBridgeAsset; @@ -40,8 +18,11 @@ contract ChainlinkBridgeAssetFeed { collateralToBridgeAsset = IChainlinkFeed(_collateralToBridgeAsset); bridgeAssetToUsd = IChainlinkFeed(_bridgeAssetToUsd); bridgeAssetDenominator = _bridgeAssetDenominator; - //TODO: Do string concat - description = "Combine oracle of 2 underlying oracles"; + if(_bridgeAssetDenominator){ + description = string(abi.encodePacked(collateralToBridgeAsset.description(), " * ", bridgeAssetToUsd.description())); + } else { + description = string(abi.encodePacked(bridgeAssetToUsd.description(), " / (", collateralToBridgeAsset.description(),")")); + } require(collateralToBridgeAsset.decimals() == 18, "collateralToBridgeAsset feed not normalized"); require(bridgeAssetToUsd.decimals() == 18, "bridgeAssetToUsd feed not normalize"); } diff --git a/test/feedForkTests/WbtcFeedFork.t.sol b/test/feedForkTests/WbtcFeedFork.t.sol index 5bba104d..b602ef26 100644 --- a/test/feedForkTests/WbtcFeedFork.t.sol +++ b/test/feedForkTests/WbtcFeedFork.t.sol @@ -23,6 +23,10 @@ contract WbtcFeedFork is ChainlinkBridgeAssetBase { init(address(wbtcToBtc), address(btcToUsd), true); } + function test_correctDescription() public { + assertEq(feed.description(), "WBTC / BTC * BTC / USD"); + } + function test_latestAnswer_returnSameAsLatestRoundData() public { (, int btcLRD, , , ) = feed.collateralToBridgeAsset().latestRoundData(); int btcLA = feed.collateralToBridgeAsset().latestAnswer();