Tadle

Tadle
DeFiFoundry
27,750 USDC
View results
Submission Details
Severity: low
Invalid

Locked Funds Issue: Conditional Approvals Needed for Certain ERC20 Tokens

Summary

The TokenManager and CapitalPool contracts implement token handling functionalities such as deposits (tillIn) and withdrawals. During withdrawals, the TokenManager attempts to approve and transfer tokens from the CapitalPool to the user. However, for certain ERC20 tokens like UNI and COMP that revert on approvals exceeding type(uint96).max, this process fails, potentially leading to indefinite lock-up of user funds.

Vulnerability Details

When a user deposits tokens into the TokenManager, the contract executes the tillIn function, which transfers these tokens to the CapitalPool. To facilitate future withdrawals, the TokenManager relies on the approve function of the CapitalPool to grant unlimited approval to the TokenManager. This is illustrated in the code excerpt below:

function approve(address tokenAddr) external {
address tokenManager = tadleFactory.relatedContracts(RelatedContractLibraries.TOKEN_MANAGER);
(bool success, ) = tokenAddr.call(
abi.encodeWithSelector(APPROVE_SELECTOR, tokenManager, type(uint256).max)
);
if (!success) {
revert ApproveFailed();
}
}

However, certain ERC20 tokens enforce restrictions on the maximum approval value. For example, both UNI and COMP tokens revert when the value passed to the approve function exceeds type(uint96).max.

As a result, when the TokenManager calls the approve function on these tokens with type(uint256).max, an error occurs. During withdrawal, the contract subsequently fails to transfer tokens back to the user, resulting in a lock-up of their funds.

Impact

The primary impact of this vulnerability is the indefinite lock-up of user funds for specific ERC20 tokens that implement approval restrictions. Users can successfully deposit such tokens into the TokenManager, but will be unable to withdraw them due to the approval failure during the withdrawal process.

POC

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Test, console2} from "forge-std/Test.sol";
import {SystemConfig} from "../src/core/SystemConfig.sol";
import {CapitalPool} from "../src/core/CapitalPool.sol";
import {TokenManager} from "../src/core/TokenManager.sol";
import {PreMarktes} from "../src/core/PreMarkets.sol";
import {DeliveryPlace} from "../src/core/DeliveryPlace.sol";
import {TadleFactory} from "../src/factory/TadleFactory.sol";
import {OfferStatus, StockStatus, AbortOfferStatus, OfferType, StockType, OfferSettleType} from "../src/storage/OfferStatus.sol";
import {IPerMarkets, OfferInfo, StockInfo, MakerInfo, CreateOfferParams} from "../src/interfaces/IPerMarkets.sol";
import {TokenBalanceType, ITokenManager} from "../src/interfaces/ITokenManager.sol";
import {GenerateAddress} from "../src/libraries/GenerateAddress.sol";
import {MockERC20Token} from "./mocks/MockERC20Token.sol";
import {WETH9} from "./mocks/WETH9.sol";
import {UpgradeableProxy} from "../src/proxy/UpgradeableProxy.sol";
contract MockToken is IERC20 {
string public constant name = "Mock Token";
string public constant symbol = "M1hI";
uint8 public constant decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) private balances;
mapping(address => mapping(address => uint256)) private allowances;
// Mint some initial supply to the deployer
constructor(uint256 initialSupply) {
totalSupply = initialSupply;
balances[msg.sender] = initialSupply;
}
function balanceOf(address account) external view override returns (uint256) {
return balances[account];
}
function transfer(address recipient, uint256 amount) external override returns (bool) {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
balances[recipient] += amount;
return true;
}
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) {
// Revert if amount is more than the maximum uint96
require(amount <= type(uint96).max, "Approval exceeds token maximum");
allowances[msg.sender][spender] = amount;
return true;
}
function transferFrom(address sender, address recipient, uint256 amount) external override returns (bool) {
require(balances[sender] >= amount, "Insufficient balance");
require(allowances[sender][msg.sender] >= amount, "Allowance exceeded");
allowances[sender][msg.sender] -= amount;
balances[sender] -= amount;
balances[recipient] += amount;
return true;
}
}
contract TokenManagerTest is Test {
// Mocking the Foundry Vm
uint256 constant initialSupply = 5000 * 10**18;
SystemConfig systemConfig;
CapitalPool capitalPool;
TokenManager tokenManager;
PreMarktes preMarktes;
DeliveryPlace deliveryPlace;
MockToken public mockToken;
TadleFactory tadleFactory;
address marketPlace;
WETH9 weth9;
MockERC20Token mockUSDCToken;
MockERC20Token mockPointToken;
address user = vm.addr(1);
address user1 = vm.addr(2);
address user2 = vm.addr(3);
address user3 = vm.addr(4);
uint256 basePlatformFeeRate = 5_000;
uint256 baseReferralRate = 300_000;
bytes4 private constant INITIALIZE_OWNERSHIP_SELECTOR =
bytes4(keccak256(bytes("initializeOwnership(address)")));
function setUp() public {
// Deploy mocks
weth9 = new WETH9();
tadleFactory = new TadleFactory(user1);
mockToken = new MockToken(initialSupply);
mockUSDCToken = new MockERC20Token();
mockPointToken = new MockERC20Token();
SystemConfig systemConfigLogic = new SystemConfig();
CapitalPool capitalPoolLogic = new CapitalPool();
TokenManager tokenManagerLogic = new TokenManager();
PreMarktes preMarktesLogic = new PreMarktes();
DeliveryPlace deliveryPlaceLogic = new DeliveryPlace();
bytes memory deploy_data = abi.encodeWithSelector(
INITIALIZE_OWNERSHIP_SELECTOR,
user1
);
vm.startPrank(user1);
address systemConfigProxy = tadleFactory.deployUpgradeableProxy(
1,
address(systemConfigLogic),
bytes(deploy_data)
);
address preMarktesProxy = tadleFactory.deployUpgradeableProxy(
2,
address(preMarktesLogic),
bytes(deploy_data)
);
address deliveryPlaceProxy = tadleFactory.deployUpgradeableProxy(
3,
address(deliveryPlaceLogic),
bytes(deploy_data)
);
address capitalPoolProxy = tadleFactory.deployUpgradeableProxy(
4,
address(capitalPoolLogic),
bytes(deploy_data)
);
address tokenManagerProxy = tadleFactory.deployUpgradeableProxy(
5,
address(tokenManagerLogic),
bytes(deploy_data)
);
systemConfig = SystemConfig(systemConfigProxy);
capitalPool = CapitalPool(capitalPoolProxy);
tokenManager = TokenManager(tokenManagerProxy);
preMarktes = PreMarktes(preMarktesProxy);
deliveryPlace = DeliveryPlace(deliveryPlaceProxy);
// Initialize
systemConfig.initialize(basePlatformFeeRate, baseReferralRate);
tokenManager.initialize(address(weth9));
address[] memory tokenAddressList = new address[](2);
//tokenAddressList[0] = address(mockUSDCToken);
tokenAddressList[1] = address(weth9);
tokenAddressList[0] = address(mockToken);
tokenManager.updateTokenWhiteListed(tokenAddressList, true);
systemConfig.createMarketPlace("Backpack", false);
// Simulate capital pool approval
capitalPool.approve(address(mockUSDCToken));
vm.stopPrank();
deal(address(mockToken), user, 100000 * 10 ** 18);
deal(address(mockPointToken), user, 100000000 * 10 ** 18);
deal(user, 100000000 * 10 ** 18);
deal(address(mockUSDCToken), user1, 100000000 * 10 ** 18);
deal(address(mockUSDCToken), user2, 1000 * 10 ** 18);
deal(address(mockUSDCToken), user3, 100000000 * 10 ** 18);
deal(address(mockPointToken), user2, 100000000 * 10 ** 18);
marketPlace = GenerateAddress.generateMarketPlaceAddress("Backpack");
vm.warp(1719826275);
// Ensure the capital pool has enough tokens
deal(address(mockUSDCToken), capitalPoolProxy, 100000 * 10 ** 18);
}
function testTokensStuckInPool() public {
vm.deal(user, 10 ether);
uint256 amount = 10 * 10**18;
// User approves the token manager to spend their tokens
vm.startPrank(user);
mockToken.approve(address(tokenManager), amount);
// Simulating the deposit via tillIn - Expected to Pass
vm.stopPrank();
vm.prank(address(preMarktes));
tokenManager.tillIn(
user, address(mockToken), amount, false // false indicates it's not a point token
);
// Add token balance to reflect the deposit
vm.prank(address(preMarktes));
tokenManager.addTokenBalance(
TokenBalanceType.RemainingCash,
user,
address(mockToken),
amount
);
// Attempt to withdraw and expect revert due to large approval handling
vm.startPrank(user);
//vm.expectRevert(); // Specify expected revert reason, if known, or use generic
try tokenManager.withdraw(
address(mockToken),
TokenBalanceType.RemainingCash
) {
console2.log("Withdrawal succeeded unexpectedly");
} catch (bytes memory error) {
console2.log("Withdrawal failed as expected with error:");
console2.logBytes(error);
}
// Assertion for ensuring issue presence
vm.stopPrank();
}
function testApproveInCapitalPool() public {
// Attempt to call approve function in the CapitalPool for the MockToken
vm.prank(address(tokenManager));
try capitalPool.approve(address(mockToken)) {
console2.log("Approval succeeded unexpectedly");
} catch (bytes memory error) {
console2.log("Approval failed as expected with error:");
console2.logBytes(error);
}
}
}

Tools Used

Foundry

Recommendations

Implement Conditional Approval Logic:

Modify the approve function within the CapitalPool contract to handle conditional approval amounts. If a large approval amount is requested, split it into smaller approved amounts that do not exceed the token's constraints.
For example, instead of approving type(uint256).max, the contract could approve type(uint96).max and dynamically adjust according to the token's specific limits.

function approve(address tokenAddr) external {
address tokenManager = tadleFactory.relatedContracts(RelatedContractLibraries.TOKEN_MANAGER);
uint256 allowanceMax = type(uint256).max;
uint256 allowanceTest = type(uint96).max; // Reduced approval amount for problematic tokens
(bool success, ) = tokenAddr.call(
abi.encodeWithSelector(APPROVE_SELECTOR, tokenManager, allowanceTest)
);
if (success) {
// If we succeed with a reduced allowance, maintain and use this threshold
(success, ) = tokenAddr.call(
abi.encodeWithSelector(APPROVE_SELECTOR, tokenManager, allowanceTest)
);
} else {
// Attempt maximum allowance as fallback for tokens without constraints
(success, ) = tokenAddr.call(
abi.encodeWithSelector(APPROVE_SELECTOR, tokenManager, allowanceMax)
);
}
if (!success) {
revert ApproveFailed();
}
}
Updates

Lead Judging Commences

0xnevi Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

[invalid] finding-CapitalPool-approve-uint256-max

Thanks for flagging, indeed since uint(-1) is representative of max uint256 value, when entering the `if` statement, it will be converted to uint96 max amout, so it will not revert as described. In issue #361, the mockToken utilized does not correctly reflect the below approval behavior. ```Solidity function approve(address spender, uint rawAmount) external returns (bool) { uint96 amount; if (rawAmount == uint(-1)) { amount = uint96(-1); } else { amount = safe96(rawAmount, "Comp::approve: amount exceeds 96 bits"); } ```

Appeal created

kiteweb3 Judge
about 1 year ago
0xnevi Lead Judge
about 1 year ago
0xnevi Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
Assigned finding tags:

[invalid] finding-CapitalPool-approve-uint256-max

Thanks for flagging, indeed since uint(-1) is representative of max uint256 value, when entering the `if` statement, it will be converted to uint96 max amout, so it will not revert as described. In issue #361, the mockToken utilized does not correctly reflect the below approval behavior. ```Solidity function approve(address spender, uint rawAmount) external returns (bool) { uint96 amount; if (rawAmount == uint(-1)) { amount = uint96(-1); } else { amount = safe96(rawAmount, "Comp::approve: amount exceeds 96 bits"); } ```

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.