Description
The Treasury contract incorrectly assumes that the full amount
specified in deposit
(via IERC20.transferFrom
) is received by the contract. For tokens with transfer fees (e.g., a 1% fee where transferring 100 tokens delivers only 99), the contract receives less than the requested amount but records the full amount in _balances[token]
and _totalValue
. This creates a persistent mismatch between the contract’s internal state and its actual token balance.
Affected Code
function deposit(address token, uint256 amount) external override nonReentrant {
if (token == address(0)) revert InvalidAddress();
if (amount == 0) revert InvalidAmount();
IERC20(token).transferFrom(msg.sender, address(this), amount);
_balances[token] += amount;
_totalValue += amount;
emit Deposited(token, amount);
}
Root Cause
The contract does not verify the actual amount of tokens received after calling transferFrom
.
Fee-on-transfer tokens deduct a percentage of the transfer amount (sent to a fee collector, burned, or redistributed), reducing the amount delivered to the contract.
Reproduction Steps
-
Deploy a fee-on-transfer token (e.g., Token X with a 1% fee).
-
Approve the Treasury contract to spend 100 Token X (100 * 10^18
units).
-
Call deposit(TokenX, 100 * 10^18)
.
-
Observe:
-
Attempt withdraw(TokenX, 100 * 10^18, recipient)
:
Severity
Likelihood: Medium (depends on use of fee-on-transfer tokens, which are less common but exist in the ecosystem).
Impact: High (prevents withdrawals, misrepresents funds, disrupts operations).
Overall: High.
Affected Components
_balances[token]
: Overstates the token balance.
_totalValue
: Overstates the total value held.
withdraw
: Fails when attempting to transfer overstated amounts.
View functions (getBalance
, getTotalValue
): Return inaccurate data.
Impact Analysis
Operational Impact
Withdrawal Failures: Managers cannot withdraw the full recorded balance, halting fund distribution even when _balances[token]
suggests sufficient funds.
Phantom Funds: The contract reports fictitious balances that cannot be accessed, accumulating with each deposit of fee-on-transfer tokens.
Allocation Misalignment: If allocations are based on reported balances, they may exceed actual holdings, risking insolvency.
Financial Impact
Stuck Funds: Small residual amounts (e.g., fee differences) remain unwithdrawable, tying up value indefinitely.
Trust Erosion: Users or external systems relying on getBalance
or getTotalValue
may make decisions based on inflated figures, leading to financial miscalculations.
Example Scenario
-
Initial State: Treasury holds 0 Token X.
-
Deposit: User deposits 100 Token X (1% fee).
-
Second Deposit: Another 100 Token X.
-
Withdrawal Attempt: Manager tries to withdraw 200 Token X.
-
Partial Withdrawal: Withdraws 198 Token X.
PoC
pragma solidity ^0.8.19;
import {Test, console} from "forge-std/Test.sol";
import {Treasury} from "../contracts/core/collectors/Treasury.sol";
import {MockERC20} from "../test/mocks/MockERC20.sol";
import {MockFeeToken} from "../test/mocks/MockFeeToken.sol";
contract TreasuryTest is Test {
Treasury public treasury;
MockERC20 public token;
MockERC20 public token2;
address admin = makeAddr("admin");
address manager = makeAddr("manager");
address allocator = makeAddr("allocator");
address allocator2 = makeAddr("allocator2");
address user = makeAddr("user");
address recipient = makeAddr("recipient");
uint256 constant INITIAL_BALANCE = 1000e18;
event FundsAllocated(address indexed recipient, uint256 amount);
event Deposited(address indexed token, uint256 amount);
event Withdrawn(
address indexed token,
uint256 amount,
address indexed recipient
);
function setUp() public {
treasury = new Treasury(admin);
token = new MockERC20();
token2 = new MockERC20();
token2.setDecimals(6);
vm.startPrank(admin);
treasury.grantRole(treasury.MANAGER_ROLE(), manager);
treasury.grantRole(treasury.ALLOCATOR_ROLE(), allocator);
treasury.grantRole(treasury.ALLOCATOR_ROLE(), allocator2);
vm.stopPrank();
token.mint(user, INITIAL_BALANCE);
token2.mint(user, 1000e6);
}
function testFeeOnTransferTokenVulnerability() public {
MockFeeToken feeToken = new MockFeeToken();
uint256 depositAmount = 1000e18;
feeToken.mint(user, depositAmount);
vm.startPrank(user);
feeToken.approve(address(treasury), depositAmount);
console.log("User balance before deposit:", feeToken.balanceOf(user) / 1e18);
console.log("Treasury balance before deposit:", feeToken.balanceOf(address(treasury)) / 1e18);
treasury.deposit(address(feeToken), depositAmount);
uint256 expectedReceivedAmount = (depositAmount * 99) / 100;
console.log("User balance after deposit:", feeToken.balanceOf(user) / 1e18);
console.log("Treasury actual balance:", feeToken.balanceOf(address(treasury)) / 1e18);
console.log("Treasury recorded balance:", treasury.getBalance(address(feeToken)) / 1e18);
vm.stopPrank();
assertEq(
treasury.getBalance(address(feeToken)),
depositAmount,
"Treasury recorded balance should match deposit amount"
);
assertEq(
feeToken.balanceOf(address(treasury)),
expectedReceivedAmount,
"Treasury actual balance should be 99% of deposit"
);
vm.startPrank(manager);
vm.expectRevert();
treasury.withdraw(address(feeToken), depositAmount, recipient);
treasury.withdraw(address(feeToken), expectedReceivedAmount, recipient);
assertEq(
feeToken.balanceOf(recipient),
(expectedReceivedAmount * 99) / 100,
"Recipient should receive 99% of the actual balance"
);
assertEq(
treasury.getBalance(address(feeToken)),
depositAmount - expectedReceivedAmount,
"Treasury recorded balance should be reduced by withdrawal amount"
);
assertEq(
feeToken.balanceOf(address(treasury)),
0,
"Treasury actual balance should be 0"
);
vm.stopPrank();
}
}
MockFeeToken:
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract MockFeeToken is IERC20 {
string public constant name = "Mock Fee Token";
string public constant symbol = "FEE";
uint8 public constant decimals = 18;
uint256 public constant FEE_DENOMINATOR = 100;
uint256 public constant FEE_NUMERATOR = 1;
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
uint256 private _totalSupply;
function totalSupply() external view override returns (uint256) {
return _totalSupply;
}
function balanceOf(address account) external view override returns (uint256) {
return _balances[account];
}
function allowance(address owner, address spender) external view override returns (uint256) {
return _allowances[owner][spender];
}
function approve(address spender, uint256 amount) external override returns (bool) {
_allowances[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transfer(address to, uint256 amount) external override returns (bool) {
return _transfer(msg.sender, to, amount);
}
function transferFrom(address from, address to, uint256 amount) external override returns (bool) {
require(_allowances[from][msg.sender] >= amount, "ERC20: insufficient allowance");
_allowances[from][msg.sender] -= amount;
return _transfer(from, to, amount);
}
function mint(address to, uint256 amount) external {
_totalSupply += amount;
_balances[to] += amount;
emit Transfer(address(0), to, amount);
}
function _transfer(address from, address to, uint256 amount) internal returns (bool) {
require(from != address(0), "ERC20: transfer from the zero address");
require(to != address(0), "ERC20: transfer to the zero address");
require(_balances[from] >= amount, "ERC20: transfer amount exceeds balance");
uint256 fee = (amount * FEE_NUMERATOR) / FEE_DENOMINATOR;
uint256 actualAmount = amount - fee;
_balances[from] -= amount;
_balances[to] += actualAmount;
_totalSupply -= fee;
emit Transfer(from, to, actualAmount);
emit Transfer(from, address(0), fee);
return true;
}
}
MockERC20:
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockERC20 is ERC20 {
uint8 private _decimals = 18;
constructor() ERC20("Mock Token", "MOCK") {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function setDecimals(uint8 decimals_) external {
_decimals = decimals_;
}
function decimals() public view override returns (uint8) {
return _decimals;
}
function approve(address spender, uint256 amount) public virtual override returns (bool) {
_approve(_msgSender(), spender, amount);
return true;
}
}
Root Cause Analysis
The flaw stems from a design assumption that all ERC-20 tokens follow a standard where transferFrom(amount)
delivers exactly amount
to the recipient. While true for most tokens, fee-on-transfer tokens violate this by delivering less than the requested amount, and the contract does not account for this discrepancy.
Recommendations
Proposed Fix
Modify the deposit
function to track the actual amount received by measuring the contract’s balance before and after the transfer:
function deposit(address token, uint256 amount) external override nonReentrant {
if (token == address(0)) revert InvalidAddress();
if (amount == 0) revert InvalidAmount();
uint256 balanceBefore = IERC20(token).balanceOf(address(this));
IERC20(token).transferFrom(msg.sender, address(this), amount);
uint256 balanceAfter = IERC20(token).balanceOf(address(this));
uint256 actualAmount = balanceAfter - balanceBefore;
_balances[token] += actualAmount;
_totalValue += actualAmount;
emit Deposited(token, actualAmount);
}
Key Changes
Balance Check: Use balanceOf
to calculate the exact amount received.
Accurate Updates: Adjust _balances
and _totalValue
with actualAmount
instead of the requested amount
.
Event Adjustment: Emit the actual amount deposited, ensuring transparency.
Considerations
Gas Cost: Adds two external calls (balanceOf
), increasing gas usage slightly but ensuring correctness.
Edge Case: If a token’s balanceOf
is manipulated (e.g., via self-destruct sending ETH or unexpected token transfers), the fix remains robust as it relies on the delta, not absolute values.
Withdrawals: No change needed, as transfer
will revert if the actual balance is insufficient, aligning with the updated _balances
.
Alternative Approaches
Token Whitelist: Restrict deposits to a curated list of standard ERC-20 tokens without fees. (Limits flexibility.)
SafeERC20: Use OpenZeppelin’s SafeERC20
library with safeTransferFrom
to handle transfer quirks, though it doesn’t inherently fix fee discrepancies.
Additional Improvements
Validation
Pre-Conditions
Post-Fix Behavior
-
Deposit 100 Token X:
-
Withdraw 99 Token X:
-
Withdraw 100 Token X:
The fix ensures recorded balances match actual holdings, resolving the issue.