This is a lower bound: only assets with reliable historical USD prices are counted, so the actual loss may be higher.
0xa1ce40702e15d0417a6c74d0bab96772f36f4e99EthereumAn unprivileged EOA 0x394ba273315240510b61ca22ba152e3478a45892 used an owner-gated helper contract 0x93d4f6f84d242c7959f8d1f1917ddbc9fb925ada to stake WIF into WIFStaking plan 3, call the buggy claimEarned function once to mint a large APR-based WIF reward without any time- or claim-based limits, and then unwind the WIF reward back into ETH via two UniswapV2 swaps. Across the four transactions in sequence b, the adversary’s net ETH balance increased by exactly 3.609928334830556174 ETH after all gas and fees, while the staked WIF principal remained locked in WIFStaking.
The root cause is a faulty reward calculation and accounting design in WIFStaking.claimEarned: for plan-3 stakes, it computes a reward proportional only to stake size and APR, ignores elapsed time, and does not track previously claimed amounts, then transfers the full computed reward from WIFStaking’s WIF balance on every call. This implementation breaks the intended staking reward invariant and allows unbounded WIF extraction by addresses that control plan-3 stakes while their principal remains staked.
0x362e092c5d7d0a30706a171133667d7297c753a8e5eedf21e6eb0c738c09808e0x6cbe3aae3bd106c42b7007b650619b85011014be64646b4d5835f937e52e3bf9The root cause is a faulty reward calculation and accounting design in WIFStaking.claimEarned: for plan-3 stakes, it computes a reward proportional only to stake size and APR, ignores elapsed time, and does not track previously claimed amounts, then transfers the full computed reward from WIFStaking’s WIF balance on every call. This implementation breaks the intended staking reward invariant and allows unbounded WIF extraction by addresses that control plan-3 stakes while their principal remains staked.
Invariant: For each staking position, the total rewards that a user can claim over time must be bounded by principal_amount * effective_rate * elapsed_time and cannot exceed the protocol’s intended issuance schedule; in particular, reward claims must depend on elapsed time since stake or last claim and must not allow the same APR-based reward to be recomputed and transferred repeatedly without accounting for prior claims.
Breakpoint: In WIFStaking.claimEarned, the code iterates over the caller’s stakes in the specified plan and computes _earned = amount * plan.apr / 10000 for each stake, then sums these values into a variable (e.g., reward) and transfers reward * 10% to 0x000000000000000000000000000000000000dEaD and reward * 90% to msg.sender using WIF.transferFrom(WIFStaking, recipient, amount) without considering block.timestamp or any claimed-amount field. This loop and transfer sequence is the first concrete operation that violates the invariant, because it allows the same plan-3 principal to generate the same APR-based reward on every call as long as WIFStaking still holds enough WIF to cover the transfer.
Verified WIFStaking.sol shows that plan parameters, including APRs, are stored in a plans mapping, and that claimEarned accepts a planId and an integer count parameter used to limit the number of stakes processed in a single call. Inside claimEarned, the contract loads plan = plans[planId], obtains the caller’s stakes array for that plan, and iterates up to count entries, reading each stake’s amount and computing _earned = amount * plan.apr / 10000. The implementation sums all _earned values into a reward accumulator, and then immediately transfers reward * 10% WIF to burn address 0x000000000000000000000000000000000000dEaD and reward * 90% WIF to msg.sender by calling the WIF token’s transferFrom function with WIFStaking as the source. At no point does claimEarned read or update a field that tracks how much reward was previously claimed for a given stake, and it does not use block.timestamp or any time-based notion when computing _earned. As a result, a caller that controls an address with at least one active plan-3 stake can invoke claimEarned(3, count) whenever WIFStaking holds enough WIF, and each invocation recomputes the same APR-based reward _earned for each processed stake and transfers it again, independent of how much time has passed since the stake or since a prior claim. This behavior deterministically transfers WIF from WIFStaking’s WIF balance to the caller and the burn address on every call and is fully reachable by any address that holds plan-3 stakes, with no owner-only or admin-only guards on claimEarned.
Key vulnerable implementation in WIFStaking.claimEarned (verified source for 0xa1ce40702e15d0417a6c74d0bab96772f36f4e99):
function claimEarned(uint256 _stakingId, uint256 _burnRate) public override {
require(_burnRate == 10 || _burnRate == 25 || _burnRate == 40, "Invalid burn rate");
uint256 _earned = 0;
Plan storage plan = plans[_stakingId];
require(stakes[_stakingId][msg.sender].length > 0, "No stakes found");
for (uint256 i = 0; i < stakes[_stakingId][msg.sender].length; i++) {
Staking storage _staking = stakes[_stakingId][msg.sender][i];
_earned = _earned.add(
_staking
.amount
.mul(plan.apr)
.div(10000)
);
totalRewards = totalRewards.add(_earned);
totalRewardsPerPlan[_stakingId] = totalRewardsPerPlan[_stakingId].add(_earned);
totalRewardsPerWalletPerPlan[_stakingId][msg.sender] = totalRewardsPerWalletPerPlan[_stakingId][msg.sender].add(_earned);
totalEarnedRewardsPerWallet[msg.sender] += _earned;
_staking.stakeAt = block.timestamp;
}
require(_earned > 0, "There is no amount to claim");
uint256 burnAmount = _earned.mul(_burnRate).div(100);
IERC20(stakingToken).transfer(BURN_ADDRESS, burnAmount);
IERC20(stakingToken).transfer(msg.sender, _earned.sub(burnAmount));
}
Adversary-crafted transaction sequence b on Ethereum mainnet (chainid 1) from pre-state sigma_B at block 20103190:
Representative trace excerpt for the buggy reward claim (seed trace for tx 0x58424115c6576b19cfb78b0b7ff00e0c13daa06d259f2a67210c112731519e09):
EOA 0x394ba2… -> helper 0x93d4… selector 0x71563727
helper 0x93d4… -> WIFStaking 0xa1ce40… :: claimEarned(3, 10)
WIFStaking 0xa1ce40… -> WIF token 0xbfae33… :: transfer(0x000000000000000000000000000000000000dEaD, 113520737092118552)
WIFStaking 0xa1ce40… -> WIF token 0xbfae33… :: transfer(0x93d4f6f84d242c7959f8d1f1917ddbc9fb925ada, 1021686633829068144)
In the observed sequence b executed from pre-state sigma_B, WIFStaking transfers 1,135,207,370,921,186,696 WIF out of its balance in a single claimEarned call: 113,520,737,092,118,552 WIF to the burn address 0x000000000000000000000000000000000000dEaD and 1,021,686,633,829,068,144 WIF to helper 0x93d4…, which then swaps the entire 1,021,686,633,829,068,144 WIF to WETH/ETH through UniswapV2 in two transactions. The adversary EOA’s net ETH holdings increase by exactly 3.609928334830556174 ETH across the four transactions in b, while its plan-3 WIF principal remains staked in WIFStaking. These effects are directly confirmed by the victim contract source code, transaction traces, and balance_diff.json artifacts and describe the concrete on-chain loss and value transfer realized in this incident.