31383 - [SC - Low] price feeds sanity checks isnt correct in funct...
Submitted on May 17th 2024 at 20:40:50 UTC by @jasonxiale for Boost | Alchemix
Report ID: #31383
Report type: Smart Contract
Report severity: Low
Target: https://github.com/alchemix-finance/alchemix-v2-dao/blob/main/src/RewardsDistributor.sol
Impacts:
Protocol insolvency
Description
Brief/Intro
The timestamp of the price update (priceTimestamp) is checked to be less than 60 days in the past, however the oracle's heartbeat is 24 hours. Hence, any price older than the heartbeat might actually be stale
Vulnerability Details
In RewardsDistributor.amountToCompound, the function use require(block.timestamp - priceTimestamp < staleThreshold, "Price is stale"); to check for stale price, which is not correct.
116     function amountToCompound(uint256 _alcxAmount) public view returns (uint256, uint256[] memory) {
117         // Increased for testing since tests go into future
118         uint256 staleThreshold = 60 days;
119 
120         (uint80 roundId, int256 alcxEthPrice, , uint256 priceTimestamp, uint80 answeredInRound) = priceFeed
121             .latestRoundData();
122 
123         require(answeredInRound >= roundId, "Stale price");
124         require(block.timestamp - priceTimestamp < staleThreshold, "Price is stale"); <<<--- Here the function checks stale price using 60 days
125         require(alcxEthPrice > 0, "Chainlink answer reporting 0");
126 
127         uint256[] memory normalizedWeights = IManagedPool(address(balancerPool)).getNormalizedWeights();
128 
129         uint256 amount = (((_alcxAmount * uint256(alcxEthPrice)) / 1 ether) * normalizedWeights[0]) /
130             normalizedWeights[1];
131 
132         return (amount, normalizedWeights);
133     }Impact Details
The timestamp of the price update (priceTimestamp) is checked to be less than 60 days in the past, however the oracle's heartbeat is 24 hours. Hence, any price older than the heartbeat might actually be stale.
References
Add any relevant links to documentation or code
Proof of Concept
In the follow code, I will demo that RewardsDistributor.amountToCompound can use stale price. And because RewardsDistributor.amountToCompound is used by RewardsDistributor.claim in RewardsDistributor.sol#L175, so the stale price might impact the transaction.
Add the following code in src/test/Minter.t.sol and run
FOUNDRY_PROFILE=default forge test --fork-url https://eth-mainnet.alchemyapi.io/v2/0TbY2mhyGA4gLPShfh-PwBlQ3PDNUdL1 --fork-block-number 17133822 --mc MinterTest --mt testPriceFeedStalePrice -vv
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for src/test/Minter.t.sol:MinterTest
[PASS] testPriceFeedStalePrice() (gas: 68962)
Logs:
  block.timestamp                   : 1682553635
  block.number                      : 17133822
  roundId                           : 18446744073709554224
  alcxEthPrice                      : 9554123643227504
  startedAt                         : 1682506295
  priceTimestamp                    : 1682506295
  answeredInRound                   : 18446744073709554224
  amountToCompound()                : 238853091080687600
  ----------forward 50 days---------
  block.timestamp                   : 1686873635
  block.number                      : 17133822
  roundId                           : 18446744073709554224
  alcxEthPrice                      : 9554123643227504
  startedAt                         : 1682506295
  priceTimestamp                    : 1682506295
  answeredInRound                   : 18446744073709554224
  amountToCompound()                : 238853091080687600
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.71ms (426.97µs CPU time)
Ran 1 test suite in 1.84s (5.71ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)For the output above, we can see that even after 50 days, function RewardsDistributor.amountToCompound doesn't revert, which isn't right.
    function testPriceFeedStalePrice() public {
        AggregatorV3Interface priceFeed = distributor.priceFeed();
        
        uint80 roundId;
        int256 alcxEthPrice;
        uint256 startedAt;
        uint256 priceTimestamp;
        uint80 answeredInRound;
        uint256 amount; 
        (roundId, alcxEthPrice, startedAt, priceTimestamp, answeredInRound) = priceFeed.latestRoundData();
        (amount, ) = distributor.amountToCompound(100e18);
        console2.log("block.timestamp                   :", block.timestamp);
        console2.log("block.number                      :", block.number);
        console2.log("roundId                           :", roundId);
        console2.log("alcxEthPrice                      :", uint(alcxEthPrice));
        console2.log("startedAt                         :", startedAt);
        console2.log("priceTimestamp                    :", priceTimestamp);
        console2.log("answeredInRound                   :", answeredInRound);
        console2.log("amountToCompound()                :", amount);
    
        console2.log("----------forward 50 days---------");
        // Here we forward 50 days, and we can call `distributor.amountToCompound` without revert
        hevm.warp(block.timestamp + 50 days);
        (roundId, alcxEthPrice, startedAt, priceTimestamp, answeredInRound) = priceFeed.latestRoundData();
        (amount, ) = distributor.amountToCompound(100e18);
        console2.log("block.timestamp                   :", block.timestamp);
        console2.log("block.number                      :", block.number);
        console2.log("roundId                           :", roundId);
        console2.log("alcxEthPrice                      :", uint(alcxEthPrice));
        console2.log("startedAt                         :", startedAt);
        console2.log("priceTimestamp                    :", priceTimestamp);
        console2.log("answeredInRound                   :", answeredInRound);
        console2.log("amountToCompound()                :", amount);
    }Last updated
Was this helpful?