Copy // SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/StakeV2.sol";
import {MockERC20} from "./mocks/MockERC20.sol";
import {MockWETH} from "./mocks/MockWBERA.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "./mocks/SimpleZapperMock.sol";
import "../src/interfaces/IZapper.sol";
contract KodiakVaultV1Mock {
IERC20 public token0;
IERC20 public token1;
constructor(IERC20 _token0, IERC20 _token1) {
token0 = _token0;
token1 = _token1;
}
}
contract testAccountingBug is Test {
StakeV2 public stakeV2;
MockERC20 public token;
MockWETH public wbera;
SimpleZapperMock public mockZapper;
KodiakVaultV1Mock public kodiakVault;
address public owner;
address public manager;
address[] public users;
uint256 constant NUM_USERS = 10;
uint256 constant STAKE_AMOUNT = 10 ether;
function setUp() public {
token = new MockERC20("MockERC20", "MockERC20", 18);
wbera = new MockWETH();
owner = address(this);
manager = address(this);
mockZapper = new SimpleZapperMock(token, wbera);
stakeV2 = new StakeV2(token, mockZapper, owner, manager, IWETH(wbera));
kodiakVault = new KodiakVaultV1Mock(token, IERC20(address(wbera)));
mockZapper.setReturnValues(1, 1);
for (uint256 i = 0; i < NUM_USERS; i++) {
users.push(address(uint160(0x1000 + i)));
token.mint(users[i], STAKE_AMOUNT);
vm.startPrank(users[i]);
token.approve(address(stakeV2), STAKE_AMOUNT);
vm.stopPrank();
}
}
function test_unstakingVulnerability() public {
for (uint256 i = 0; i < NUM_USERS; i++) {
vm.startPrank(users[i]);
stakeV2.stake(STAKE_AMOUNT);
vm.stopPrank();
}
assertEq(token.balanceOf(address(stakeV2)), NUM_USERS * STAKE_AMOUNT, "Contract should have all staked tokens");
assertEq(stakeV2.totalSupply(), NUM_USERS * STAKE_AMOUNT, "Total supply should match staked amount");
assertEq(stakeV2.accumulatedDeptRewardsYeet(), 0, "No excess rewards initially");
console.log("Initial state:");
console.log(" Contract balance:", token.balanceOf(address(stakeV2)) / 1 ether, "tokens");
console.log(" Total supply:", stakeV2.totalSupply() / 1 ether, "tokens");
console.log(" Accumulated rewards:", stakeV2.accumulatedDeptRewardsYeet() / 1 ether, "tokens");
console.log("\nStep 2: 8 users start unstaking");
for (uint256 i = 0; i < 8; i++) {
vm.startPrank(users[i]);
stakeV2.startUnstake(STAKE_AMOUNT);
vm.stopPrank();
}
assertEq(token.balanceOf(address(stakeV2)), NUM_USERS * STAKE_AMOUNT, "Contract should still have all tokens");
assertEq(stakeV2.totalSupply(), NUM_USERS * STAKE_AMOUNT - 8 * STAKE_AMOUNT, "Total supply should be reduced");
assertEq(stakeV2.accumulatedDeptRewardsYeet(), 8 * STAKE_AMOUNT, "80% of tokens now appear as rewards");
console.log("After 8 users start unstaking:");
console.log(" Contract balance:", token.balanceOf(address(stakeV2)) / 1 ether, "tokens");
console.log(" Total supply:", stakeV2.totalSupply() / 1 ether, "tokens");
console.log(" Accumulated rewards:", stakeV2.accumulatedDeptRewardsYeet() / 1 ether, "tokens");
console.log("\nStep 3: Manager distributes rewards");
uint256 distributionAmount = stakeV2.accumulatedDeptRewardsYeet() * 75 / 100;
IZapper.SingleTokenSwap memory swap = IZapper.SingleTokenSwap({
inputAmount: distributionAmount,
outputQuote: 0,
outputMin: 0,
executor: address(0),
path: ""
});
IZapper.KodiakVaultStakingParams memory stakingParams = IZapper.KodiakVaultStakingParams({
kodiakVault: address(kodiakVault),
amount0Max: 0,
amount1Max: 0,
amount0Min: 0,
amount1Min: 0,
amountSharesMin: 0,
receiver: address(0)
});
IZapper.VaultDepositParams memory vaultParams = IZapper.VaultDepositParams({
vault: address(token),
receiver: address(this),
minShares: 0
});
stakeV2.executeRewardDistributionYeet(swap, stakingParams, vaultParams);
uint256 expectedRemainingBalance = NUM_USERS * STAKE_AMOUNT - distributionAmount;
assertEq(token.balanceOf(address(stakeV2)), expectedRemainingBalance, "Contract should have distributed rewards");
console.log("After distributing rewards:");
console.log(" Contract balance:", token.balanceOf(address(stakeV2)) / 1 ether, "tokens");
console.log(" Total supply:", stakeV2.totalSupply() / 1 ether, "tokens");
console.log(" Accumulated rewards:", stakeV2.accumulatedDeptRewardsYeet() / 1 ether, "tokens");
vm.warp(block.timestamp + 11 days);
console.log("\nStep 4: Users try to unstake");
uint256 successfulUnstakes = 0;
uint256 failedUnstakes = 0;
for (uint256 i = 0; i < 8; i++) {
vm.startPrank(users[i]);
try stakeV2.unstake(0) {
successfulUnstakes++;
console.log(string(abi.encodePacked("User ", vm.toString(i), " unstaked successfully")));
console.log(" User balance:", token.balanceOf(users[i]) / 1 ether, "tokens");
} catch Error(string memory reason) {
failedUnstakes++;
console.log(string(abi.encodePacked("User ", vm.toString(i), " FAILED to unstake: ", reason)));
}
vm.stopPrank();
}
console.log("\nFinal state:");
console.log(" Contract balance:", token.balanceOf(address(stakeV2)) / 1 ether, "tokens");
console.log(" Total supply:", stakeV2.totalSupply() / 1 ether, "tokens");
console.log(" Users who successfully unstaked:", successfulUnstakes);
console.log(" Users who couldn't unstake:", failedUnstakes);
uint256 remainingDebt = (8 - successfulUnstakes) * STAKE_AMOUNT;
bool isInsolvent = token.balanceOf(address(stakeV2)) < remainingDebt;
console.log(" Tokens owed to remaining users:", remainingDebt / 1 ether, "tokens");
console.log(" Tokens available in contract:", token.balanceOf(address(stakeV2)) / 1 ether, "tokens");
console.log("Contract is insolvent:", isInsolvent ? "YES" : "NO");
assertTrue(failedUnstakes > 0, "Some unstaking operations should fail");
assertTrue(isInsolvent, "Contract should be insolvent");
assertLt(token.balanceOf(address(stakeV2)), remainingDebt, "Contract should have fewer tokens than it owes");
}
}