From ccb6e9b7e80c13689e9a5ca41138b63d44d0fd2e Mon Sep 17 00:00:00 2001 From: Kevin Cheng Date: Thu, 3 Aug 2023 10:36:37 -1000 Subject: [PATCH] Multiplicative Price Feed (#757) This generalizes the logic used in the recently audited [`WBTCPriceFeed`](https://github.com/compound-finance/comet/pull/737) to create a `MultiplicativePriceFeed` that derives its price from multiplying the prices from two other price feeds together. This is a flexible wrapper price feed that can be used to generate prices for any asset that does not have a price feed that fits Comet's expected price denomination. e.g. if we wanted to add `cbETH` to `cUSDCv3`, there is no Chainlink price feed for `cbETH / USD`, so we would need to multiply the `cbETH / ETH` and `ETH / USD` price feeds together to get `cbETH / USD` --- .../pricefeeds/MultiplicativePriceFeed.sol | 89 +++++++++ .../constant-price-feed-test.ts | 4 +- test/pricefeeds/multiplicative-price-feed.ts | 179 ++++++++++++++++++ .../scaling-price-feed-test.ts | 4 +- test/{ => pricefeeds}/wbtc-price-feed.ts | 4 +- test/{ => pricefeeds}/wsteth-price-feed.ts | 4 +- 6 files changed, 276 insertions(+), 8 deletions(-) create mode 100644 contracts/pricefeeds/MultiplicativePriceFeed.sol rename test/{ => pricefeeds}/constant-price-feed-test.ts (95%) create mode 100644 test/pricefeeds/multiplicative-price-feed.ts rename test/{ => pricefeeds}/scaling-price-feed-test.ts (97%) rename test/{ => pricefeeds}/wbtc-price-feed.ts (98%) rename test/{ => pricefeeds}/wsteth-price-feed.ts (97%) diff --git a/contracts/pricefeeds/MultiplicativePriceFeed.sol b/contracts/pricefeeds/MultiplicativePriceFeed.sol new file mode 100644 index 000000000..44bb8350b --- /dev/null +++ b/contracts/pricefeeds/MultiplicativePriceFeed.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.15; + +import "../vendor/@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; +import "../IPriceFeed.sol"; + +/** + * @title Multiplicative price feed + * @notice A custom price feed that multiplies the prices from two price feeds and returns the result + * @author Compound + */ +contract MultiplicativePriceFeed is IPriceFeed { + /** Custom errors **/ + error BadDecimals(); + error InvalidInt256(); + + /// @notice Version of the price feed + uint public constant VERSION = 1; + + /// @notice Description of the price feed + string public override description; + + /// @notice Number of decimals for returned prices + uint8 public immutable override decimals; + + /// @notice Chainlink price feed A + address public immutable priceFeedA; + + /// @notice Chainlink price feed B + address public immutable priceFeedB; + + /// @notice Combined scale of the two underlying Chainlink price feeds + int public immutable combinedScale; + + /// @notice Scale of this price feed + int public immutable priceFeedScale; + + /** + * @notice Construct a new multiplicative price feed + * @param priceFeedA_ The address of the first price feed to fetch prices from + * @param priceFeedB_ The address of the second price feed to fetch prices from + * @param decimals_ The number of decimals for the returned prices + * @param description_ The description of the price feed + **/ + constructor(address priceFeedA_, address priceFeedB_, uint8 decimals_, string memory description_) { + priceFeedA = priceFeedA_; + priceFeedB = priceFeedB_; + uint8 priceFeedADecimals = AggregatorV3Interface(priceFeedA_).decimals(); + uint8 priceFeedBDecimals = AggregatorV3Interface(priceFeedB_).decimals(); + combinedScale = signed256(10 ** (priceFeedADecimals + priceFeedBDecimals)); + + if (decimals_ > 18) revert BadDecimals(); + decimals = decimals_; + description = description_; + priceFeedScale = int256(10 ** decimals); + } + + /** + * @notice Calculates the latest round data using data from the two price feeds + * @return roundId Round id from price feed B + * @return answer Latest price + * @return startedAt Timestamp when the round was started; passed on from price feed B + * @return updatedAt Timestamp when the round was last updated; passed on from price feed B + * @return answeredInRound Round id in which the answer was computed; passed on from price feed B + * @dev Note: Only the `answer` really matters for downstream contracts that use this price feed (e.g. Comet) + **/ + function latestRoundData() override external view returns (uint80, int256, uint256, uint256, uint80) { + (, int256 priceA, , , ) = AggregatorV3Interface(priceFeedA).latestRoundData(); + (uint80 roundId_, int256 priceB, uint256 startedAt_, uint256 updatedAt_, uint80 answeredInRound_) = AggregatorV3Interface(priceFeedB).latestRoundData(); + + if (priceA <= 0 || priceB <= 0) return (roundId_, 0, startedAt_, updatedAt_, answeredInRound_); + + int256 price = priceA * priceB * priceFeedScale / combinedScale; + return (roundId_, price, startedAt_, updatedAt_, answeredInRound_); + } + + function signed256(uint256 n) internal pure returns (int256) { + if (n > uint256(type(int256).max)) revert InvalidInt256(); + return int256(n); + } + + /** + * @notice Price for the latest round + * @return The version of the price feed contract + **/ + function version() external pure returns (uint256) { + return VERSION; + } +} \ No newline at end of file diff --git a/test/constant-price-feed-test.ts b/test/pricefeeds/constant-price-feed-test.ts similarity index 95% rename from test/constant-price-feed-test.ts rename to test/pricefeeds/constant-price-feed-test.ts index 309d194c4..ca576ab18 100644 --- a/test/constant-price-feed-test.ts +++ b/test/pricefeeds/constant-price-feed-test.ts @@ -1,7 +1,7 @@ -import { ethers, exp, expect, getBlock } from './helpers'; +import { ethers, exp, expect, getBlock } from '../helpers'; import { ConstantPriceFeed__factory -} from '../build/types'; +} from '../../build/types'; export async function makeConstantPriceFeed({ decimals, constantPrice }) { const constantPriceFeedFactory = (await ethers.getContractFactory('ConstantPriceFeed')) as ConstantPriceFeed__factory; diff --git a/test/pricefeeds/multiplicative-price-feed.ts b/test/pricefeeds/multiplicative-price-feed.ts new file mode 100644 index 000000000..56caed504 --- /dev/null +++ b/test/pricefeeds/multiplicative-price-feed.ts @@ -0,0 +1,179 @@ +import { ethers, exp, expect } from '../helpers'; +import { + SimplePriceFeed__factory, + MultiplicativePriceFeed__factory +} from '../../build/types'; + +export async function makeMultiplicativePriceFeed({ priceA, priceB, decimalsA = 8, decimalsB = 8 }) { + const SimplePriceFeedFactory = (await ethers.getContractFactory( + 'SimplePriceFeed' + )) as SimplePriceFeed__factory; + const PriceFeedA = await SimplePriceFeedFactory.deploy(priceA, decimalsA); + await PriceFeedA.deployed(); + + const PriceFeedB = await SimplePriceFeedFactory.deploy(priceB, decimalsB); + await PriceFeedB.deployed(); + + const MultiplicativePriceFeedFactory = (await ethers.getContractFactory( + 'MultiplicativePriceFeed' + )) as MultiplicativePriceFeed__factory; + const MultiplicativePriceFeed = await MultiplicativePriceFeedFactory.deploy( + PriceFeedA.address, + PriceFeedB.address, + 8, + 'Multiplicative Price Feed' + ); + await MultiplicativePriceFeed.deployed(); + + return { + PriceFeedA, + PriceFeedB, + MultiplicativePriceFeed + }; +} + +const testCases = [ + // Existing test cases from WBTC price feed + { + priceA: exp(1, 8), + priceB: exp(30_000, 8), + result: exp(30_000, 8) + }, + { + priceA: exp(2.123456, 8), + priceB: exp(31_333.123, 8), + result: 6653450803308n + }, + { + priceA: exp(100, 8), + priceB: exp(30_000, 8), + result: exp(3_000_000, 8) + }, + { + priceA: exp(0.9999, 8), + priceB: exp(30_000, 8), + result: exp(29_997, 8) + }, + { + priceA: exp(0.987937, 8), + priceB: exp(31_947.71623, 8), + result: 3156233092911n + }, + { + priceA: exp(0.5, 8), + priceB: exp(30_000, 8), + result: exp(15_000, 8) + }, + { + priceA: exp(0.00555, 8), + priceB: exp(30_000, 8), + result: exp(166.5, 8) + }, + { + priceA: exp(0, 8), + priceB: exp(30_000, 8), + result: exp(0, 8) + }, + { + priceA: exp(1, 8), + priceB: exp(0, 8), + result: exp(0, 8) + }, + { + priceA: exp(0, 8), + priceB: exp(0, 8), + result: exp(0, 8) + }, + // e.g. cbETH / ETH (18 decimals) and ETH / USD (8 decimals) + { + priceA: exp(1, 18), + priceB: exp(1800, 8), + decimalsA: 18, + decimalsB: 8, + result: exp(1800, 8) + }, + { + priceA: exp(1.25, 18), + priceB: exp(1800, 8), + decimalsA: 18, + decimalsB: 8, + result: exp(2250, 8) + }, + { + priceA: exp(0.72, 18), + priceB: exp(1800, 8), + decimalsA: 18, + decimalsB: 8, + result: exp(1296, 8) + }, +]; + +describe('Multiplicative price feed', function() { + it('reverts if constructed with bad decimals', async () => { + const SimplePriceFeedFactory = (await ethers.getContractFactory( + 'SimplePriceFeed' + )) as SimplePriceFeed__factory; + const PriceFeedA = await SimplePriceFeedFactory.deploy(exp(1, 8), 8); + await PriceFeedA.deployed(); + + const PriceFeedB = await SimplePriceFeedFactory.deploy(exp(30_000), 8); + await PriceFeedB.deployed(); + + const MultiplicativePriceFeed = (await ethers.getContractFactory( + 'MultiplicativePriceFeed' + )) as MultiplicativePriceFeed__factory; + await expect( + MultiplicativePriceFeed.deploy( + PriceFeedA.address, + PriceFeedB.address, + 20, // decimals_ is too high + 'Multiplicative Price Feed' + ) + ).to.be.revertedWith("custom error 'BadDecimals()'"); + }); + + describe('latestRoundData', function() { + for (const { priceA, priceB, decimalsA, decimalsB, result } of testCases) { + it(`priceA (${priceA}) with ${decimalsA ?? 8} decimals, priceB (${priceB}) with ${decimalsB ?? 8} decimals -> ${result}`, async () => { + const { MultiplicativePriceFeed } = await makeMultiplicativePriceFeed({ priceA, priceB, decimalsA, decimalsB }); + const latestRoundData = await MultiplicativePriceFeed.latestRoundData(); + const price = latestRoundData[1].toBigInt(); + + expect(price).to.eq(result); + }); + } + + it('passes along roundId, startedAt, updatedAt and answeredInRound values from price feed B', async () => { + const { PriceFeedB, MultiplicativePriceFeed } = await makeMultiplicativePriceFeed({ + priceA: exp(1, 18), + priceB: exp(30_000, 18) + }); + + await PriceFeedB.setRoundData( + exp(15, 18), // roundId_, + 1, // answer_, + exp(16, 8), // startedAt_, + exp(17, 8), // updatedAt_, + exp(18, 18) // answeredInRound_ + ); + + const roundData = await MultiplicativePriceFeed.latestRoundData(); + + expect(roundData[0].toBigInt()).to.eq(exp(15, 18)); + expect(roundData[2].toBigInt()).to.eq(exp(16, 8)); + expect(roundData[3].toBigInt()).to.eq(exp(17, 8)); + expect(roundData[4].toBigInt()).to.eq(exp(18, 18)); + }); + }); + + it('getters return correct values', async () => { + const { MultiplicativePriceFeed } = await makeMultiplicativePriceFeed({ + priceA: exp(1, 18), + priceB: exp(30_000, 18) + }); + + expect(await MultiplicativePriceFeed.version()).to.eq(1); + expect(await MultiplicativePriceFeed.description()).to.eq('Multiplicative Price Feed'); + expect(await MultiplicativePriceFeed.decimals()).to.eq(8); + }); +}); diff --git a/test/scaling-price-feed-test.ts b/test/pricefeeds/scaling-price-feed-test.ts similarity index 97% rename from test/scaling-price-feed-test.ts rename to test/pricefeeds/scaling-price-feed-test.ts index 050649cf5..082630657 100644 --- a/test/scaling-price-feed-test.ts +++ b/test/pricefeeds/scaling-price-feed-test.ts @@ -1,8 +1,8 @@ -import { ethers, exp, expect } from './helpers'; +import { ethers, exp, expect } from '../helpers'; import { SimplePriceFeed__factory, ScalingPriceFeed__factory -} from '../build/types'; +} from '../../build/types'; export async function makeScalingPriceFeed({ price, priceFeedDecimals }) { const SimplePriceFeedFactory = (await ethers.getContractFactory('SimplePriceFeed')) as SimplePriceFeed__factory; diff --git a/test/wbtc-price-feed.ts b/test/pricefeeds/wbtc-price-feed.ts similarity index 98% rename from test/wbtc-price-feed.ts rename to test/pricefeeds/wbtc-price-feed.ts index c88d9bccd..a7b417935 100644 --- a/test/wbtc-price-feed.ts +++ b/test/pricefeeds/wbtc-price-feed.ts @@ -1,8 +1,8 @@ -import { ethers, exp, expect } from './helpers'; +import { ethers, exp, expect } from '../helpers'; import { SimplePriceFeed__factory, WBTCPriceFeed__factory -} from '../build/types'; +} from '../../build/types'; export async function makeWBTCPriceFeed({ WBTCToBTCPrice, BTCToUSDPrice }) { const SimplePriceFeedFactory = (await ethers.getContractFactory( diff --git a/test/wsteth-price-feed.ts b/test/pricefeeds/wsteth-price-feed.ts similarity index 97% rename from test/wsteth-price-feed.ts rename to test/pricefeeds/wsteth-price-feed.ts index edfe32807..14d30c540 100644 --- a/test/wsteth-price-feed.ts +++ b/test/pricefeeds/wsteth-price-feed.ts @@ -1,9 +1,9 @@ -import { ethers, exp, expect } from './helpers'; +import { ethers, exp, expect } from '../helpers'; import { SimplePriceFeed__factory, SimpleWstETH__factory, WstETHPriceFeed__factory -} from '../build/types'; +} from '../../build/types'; export async function makeWstETH({ stEthPrice, tokensPerStEth }) { const SimplePriceFeedFactory = (await ethers.getContractFactory('SimplePriceFeed')) as SimplePriceFeed__factory;