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
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
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 }
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.
Add any relevant links to documentation or code
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);
}