You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Energetic Opaque Elephant - Insufficient Handling of Transfer Fees in DecentralizedIndex::_transferFromAndValidate Causes Legitimate Transactions to Revert
#561
Insufficient Handling of Transfer Fees in DecentralizedIndex::_transferFromAndValidate Causes Legitimate Transactions to Revert
Summary
The DecentralizedIndex:_transferFromAndValidate function in the contract performs a token transfer and validates the balance update. However, the current implementation uses a strict comparison that does not account for token transfer fees. This can cause legitimate transactions to revert unnecessarily. While the NatSpec mentions fees, the current implementation does not handle them correctly.
Root Cause
The function does not account for tokens that deduct fees during transfers. It validates the balance change using >= _amount, which fails when the received amount is less than _amount due to fees. The NatSpec explicitly states the function should "revert if balances aren’t updated as expected on transfer (i.e., transfer fees, etc.)", but the implementation does not handle this correctly..
Internal Pre-conditions
None
External Pre-conditions
None
Attack Path
Step 1: A token with transfer fees (e.g., 5% fee) is added to the index.
Step 2: A user attempts to bond using this token.
Step 3: _transferFromAndValidate transfers _amount tokens, but the contract receives _amount * 0.95.
Step 4: The check require(balance >= _balanceBefore + _amount) fails, reverting the transaction.
Result: Legitimate users cannot bond with the token.
Impact
Denial-of-Service (DoS): Transactions involving fee-charging tokens will always revert.
Protocol Usability: Limits the protocol’s ability to support popular tokens with transfer fees.
PoC
See PoC below;
The below exposes the _bond function for use in our test
import"../contracts/WeightedIndex.sol";
import"../contracts/interfaces/IDecentralizedIndex.sol";
contractTestWeightedIndexisWeightedIndex {
/// @notice Public wrapper to call __WeightedIndex_init for testing purposes.function publicInit(
IDecentralizedIndex.Config memory_config,
address[] memory_tokens,
uint256[] memory_weights,
bytesmemory_immutables
) public {
__WeightedIndex_init(_config, _tokens, _weights, _immutables);
}
/// @notice Returns the number of tokens stored in the indexTokens array.function indexTokenCount() publicviewreturns (uint256) {
return indexTokens.length;
}
/// @notice Returns the index stored in the _fundTokenIdx mapping for a given token.function getFundTokenIdx(addresstoken) publicviewreturns (uint256) {
return _fundTokenIdx[token];
}
function publicBond(
address_token,
uint256_amount,
uint256_amountMintMin,
address_user
) public {
_bond(_token, _amount, _amountMintMin, _user);
}
}
The below includes the MockFeeToken for testing token with fees
pragma solidity^0.8.0;
import"@openzeppelin/contracts/token/ERC20/ERC20.sol";
import"../contracts/interfaces/IPEAS.sol";
contractMockERC20 {
stringpublic name;
stringpublic symbol;
uint8public decimals;
mapping(address=>uint256) public balanceOf;
mapping(address=>mapping(address=>uint256)) public allowance;
constructor(stringmemory_name, stringmemory_symbol, uint8_decimals) {
name = _name;
symbol = _symbol;
decimals = _decimals;
}
function transfer(addressrecipient, uint256amount) publicreturns (bool) {
balanceOf[msg.sender] -= amount;
balanceOf[recipient] += amount;
returntrue;
}
function approve(addressspender, uint256amount) publicreturns (bool) {
allowance[msg.sender][spender] = amount;
returntrue;
}
// ... Add other mocked functions (like decimals, transferFrom, etc.) as required ...function mint(addressto, uint256amount) public {
balanceOf[to] += amount;
}
// function decimals() public view returns (uint8) { // Add decimals function// return decimals;// }
}
contractMockFeeTokenisMockERC20 {
uint256public feePercentage;
constructor(uint256_feePercentage) MockERC20("MockFeeToken", "MFT", 18) {
feePercentage = _feePercentage;
}
function transferFrom(addresssender, addressrecipient, uint256amount) publicreturns (bool) {
uint256 fee = amount * feePercentage /100;
uint256 amountAfterFee = amount - fee;
transfer(recipient, amountAfterFee);
transfer(address(this), fee);
returntrue;
}
}
// interface IERC20Metadata {// function decimals() external view returns (uint8);// }// Mock contract that DOES NOT implement IERC20MetadatacontractMockNonERC20 {
// This contract has NO decimals() functionuint256public someValue;
constructor(uint256_value) {
someValue = _value;
}
}
interfaceIUniswapV2Router02 {
function WETH() externalviewreturns (address);
function factory() externalviewreturns (address);
}
contractMockUniswapV2RouterisIUniswapV2Router02 {
addresspublic WETH;
addresspublic factory;
constructor(address_weth, address_factory) {
WETH = _weth;
factory = _factory;
}
// function WETH() external view returns (address) {// return WETH;// }// function factory() external view returns (address) {// return factory;// }// ... other functions as needed
}
contractMockPEASisIPEAS, ERC20 {
//uint8 public _decimals; // Store decimals as a state variableconstructor(stringmemory_name, stringmemory_symbol, uint8/* _decimalsValue */)
ERC20(_name, _symbol)
{
_mint(msg.sender, 10_000_000*10**18); // Mint to the deployer for testing// Do not store any additional decimals value; rely on ERC20's default.
}
function burn(uint256_amount) externalvirtualoverride {
_burn(msg.sender, _amount); // Burn from the test contract (msg.sender)emitBurn(msg.sender, _amount);
}
// function decimals() public view virtual override returns (uint8) {// return _decimals; // Return the stored decimals value// }// Add a mint function for testing purposes:function mint(address_to, uint256_amount) public {
_mint(_to, _amount);
}
// Add a setDecimals function to allow changing the decimals value for testing:// function setDecimals(uint8 _newDecimals) public {// _decimals = _newDecimals;// }// ... other functions as needed for your tests ...
}
contractMockUniswapV2Factory {
mapping(address=>mapping(address=>address)) public getPair;
function setPair(addresstokenA, addresstokenB, addresspairAddress) public {
getPair[tokenA][tokenB] = pairAddress;
getPair[tokenB][tokenA] = pairAddress;
}
// Simple createPair that deploys a new pair and stores it.function createPair(addresstokenA, addresstokenB) publicreturns (addresspair) {
MockUniswapV2Pair newPair =newMockUniswapV2Pair(tokenA, tokenB);
pair =address(newPair);
setPair(tokenA, tokenB, pair);
}
}
// Mock Uniswap V2 Pair.contractMockUniswapV2Pair {
IERC20public token0;
IERC20public token1;
constructor(address_tokenA, address_tokenB) {
token0 =IERC20(_tokenA);
token1 =IERC20(_tokenB);
}
// ... other pair functionality as needed for your tests
}
The test file, include in your test folder
function test_BondingRevertsWithFeeToken() public {
// Deploy mock token with configurable transfer fee
MockFeeToken feeToken =newMockFeeToken(5); // 5% fee // ... (Add feeToken to the index as before) // Mint tokens to useruint256 amount =100e18;
feeToken.mint(alice, amount);
// Approve pod to spend tokens
vm.startPrank(alice);
feeToken.approve(address(pod), amount);
vm.stopPrank();
// Get balance of pod before bonduint256 balanceBefore = feeToken.balanceOf(address(pod));
// Attempt to bond with feeToken
vm.startPrank(alice);
vm.expectRevert(); // Expect any revert reason
podLarge.publicBond(address(feeToken), amount, 0, alice);
vm.stopPrank();
// Get balance of pod after bonduint256 balanceAfter = feeToken.balanceOf(address(pod));
// Assert that the balance increased by less than the transfer amountassertLt(balanceAfter - balanceBefore, amount, "Balance should increase by less than the bond amount due to fees");
// Assert that the balance increased (it should increase by the amount after fees)assertGt(balanceAfter, balanceBefore, "Balance should increase after bond");
// Optionally, get the revert reason and assert it contains "TV" or your custom error message// bytes memory revertReason = vm.getRevertReason();// assertContains(string(revertReason), "TV"); // Or your custom error message
}
Mitigation
Modify _transferFromAndValidate to track the actual received amount and use it in downstream logic:
Energetic Opaque Elephant
Medium
Insufficient Handling of Transfer Fees in
DecentralizedIndex::_transferFromAndValidate
Causes Legitimate Transactions to RevertSummary
The
DecentralizedIndex:_transferFromAndValidate
function in the contract performs a token transfer and validates the balance update. However, the current implementation uses a strict comparison that does not account for token transfer fees. This can cause legitimate transactions to revert unnecessarily. While the NatSpec mentions fees, the current implementation does not handle them correctly.Root Cause
The function does not account for tokens that deduct fees during transfers. It validates the balance change using
>= _amount
, which fails when the received amount is less than_amount
due to fees. The NatSpec explicitly states the function should "revert if balances aren’t updated as expected on transfer (i.e., transfer fees, etc.)", but the implementation does not handle this correctly..Internal Pre-conditions
None
External Pre-conditions
None
Attack Path
Step 1: A token with transfer fees (e.g., 5% fee) is added to the index.
Step 2: A user attempts to bond using this token.
Step 3:
_transferFromAndValidate
transfers _amount tokens, but the contract receives_amount * 0.95
.Step 4: The check
require(balance >= _balanceBefore + _amount)
fails, reverting the transaction.Result: Legitimate users cannot bond with the token.
Impact
Denial-of-Service (DoS): Transactions involving fee-charging tokens will always revert.
Protocol Usability: Limits the protocol’s ability to support popular tokens with transfer fees.
PoC
See PoC below;
The below exposes the
_bond
function for use in our testThe below includes the MockFeeToken for testing token with fees
The test file, include in your test folder
Mitigation
Modify
_transferFromAndValidate
to track the actual received amount and use it in downstream logic:Downstream Adjustments:
Update the
_bond
function to use_received
instead of_amount
for calculations (e.g.,_totalAssets
updates).If modifying
_bond
is not feasible, ensure only tokens without fees are allowed in the index (document this limitation).Why This Matters
User Trust: Ensures the protocol works as advertised for all supported tokens.
Future-Proofing: Prepares the protocol to handle diverse ERC20 tokens (including those with fees).
This fix aligns the implementation with the NatSpec’s intent and restores functionality for fee-charging tokens.
The text was updated successfully, but these errors were encountered: