Root + Impact
Description
The deposit() function mints shares based on the intended transfer amount without verifying how much actually arrived at the vault.
Impact: With fee-on-transfer tokens the vault receives less than expected but mints full shares, leading to insolvency.
function deposit(uint256 assets, address receiver) public override returns (uint256) {
require(receiver != address(0));
if (block.timestamp >= eventStartDate) {
revert eventStarted();
}
uint256 fee = _getParticipationFee(assets);
if (minimumAmount + fee > assets) {
revert lowFeeAndAmount();
}
@ uint256 stakeAsset = assets - fee;
stakedAsset[receiver] = stakeAsset;
@ uint256 participantShares = _convertToShares(stakeAsset);
IERC20(asset()).safeTransferFrom(msg.sender, participationFeeAddress, fee);
IERC20(asset()).safeTransferFrom(msg.sender, address(this), stakeAsset);
@> _mint(msg.sender, participantShares);
@> emit deposited(receiver, stakeAsset);
return participantShares;
}
Risk
Likelihood: High
Impact: High
Proof of Concept
Setup:
User1 deposits 100 ETH:
Participation fee: 1.5 ETH (goes to fee address)
Intended for vault: 98.5 ETH
Transfer fee (1%): 0.985 ETH (burned/lost)
Actually arrives: 97.515 ETH
Shares minted: Based on 98.5 ETH
User2 deposits 100 ETH:
Total:
Users deposited: 200 ETH
Vault received: 195.03 ETH (lost 2x 0.985 ETH to transfer fees)
Shares minted: Based on 197 ETH
Insolvency: 1.97 ETH shortfall
Compounding losses:
Deposit: Lost ~1% on each deposit (vault got less)
Withdraw: Lost ~1% on each withdrawal (winner got less)
Total: ~2% loss per user's full cycle!
Recommended Mitigation
Measure actual balance change instead of assuming transfer amount
function deposit(uint256 assets, address receiver) public override returns (uint256) {
require(receiver != address(0));
if (block.timestamp >= eventStartDate) {
revert eventStarted();
}
uint256 fee = _getParticipationFee(assets);
if (minimumAmount + fee > assets) {
revert lowFeeAndAmount();
}
uint256 stakeAsset = assets - fee;
IERC20(asset()).safeTransferFrom(msg.sender, participationFeeAddress, fee);
uint256 balanceBefore = IERC20(asset()).balanceOf(address(this));
IERC20(asset()).safeTransferFrom(msg.sender, address(this), stakeAsset);
uint256 balanceAfter = IERC20(asset()).balanceOf(address(this));
uint256 actualReceived = balanceAfter - balanceBefore;
stakedAsset[receiver] = actualReceived;
uint256 participantShares = _convertToShares(actualReceived);
_mint(msg.sender, participantShares);
emit deposited(receiver, actualReceived);
return participantShares;
}
Alternative: Explicitly Disallow Fee-on-Transfer Tokens
function deposit(uint256 assets, address receiver) public override returns (uint256) {
uint256 stakeAsset = assets - fee;
IERC20(asset()).safeTransferFrom(msg.sender, participationFeeAddress, fee);
uint256 balanceBefore = IERC20(asset()).balanceOf(address(this));
IERC20(asset()).safeTransferFrom(msg.sender, address(this), stakeAsset);
uint256 balanceAfter = IERC20(asset()).balanceOf(address(this));
require(
balanceAfter - balanceBefore == stakeAsset,
"Fee-on-transfer tokens not supported"
);
}