Tadle

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

Loss of user funds due to locked eth in the Capital Pool

Summary

The PreMarktes contract's createOffer() createTaker(), and listOffer() functions are payable and allows users to send ETH with function calls. This ETH sent to the pool is not accounted for or refundable, resulting in permanent loss of funds.

Vulnerability Details

https://github.com/Cyfrin/2024-08-tadle/blob/04fd8634701697184a3f3a5558b41c109866e5f8/src/core/TokenManager.sol#L56

When PreMarktes functions call tillIn() with ETH value for any token type, the sent ETH becomes trapped in the contract if the transaction involves a non-native token. This occurs because the function only processes msg.value for native token transactions, ignoring it otherwise. Consequently, users can unknowingly send ETH along with non-native token transactions, resulting in these funds becoming permanently locked in the contract. The trapped ETH is neither tracked in the contract's accounting system nor refundable through existing mechanisms, leading to irretrievable loss of user funds.

function tillIn(
address _accountAddress,
address _tokenAddress,
uint256 _amount,
bool _isPointToken
)
external
payable
onlyRelatedContracts(tadleFactory, _msgSender())
onlyInTokenWhiteList(_isPointToken, _tokenAddress)
{
// ... (other code omitted for brevity)
if (_tokenAddress == wrappedNativeToken) {
// Handles wrapped native token (ETH) case
if (msg.value < _amount) {
revert Errors.NotEnoughMsgValue(msg.value, _amount);
}
IWrappedNativeToken(wrappedNativeToken).deposit{value: _amount}();
_safe_transfer(wrappedNativeToken, capitalPoolAddr, _amount);
} else {
// For non-native tokens, msg.value is ignored
_transfer(
_tokenAddress,
_accountAddress,
capitalPoolAddr,
_amount,
capitalPoolAddr
);
}
// ... (remaining code omitted)
}

This function is payable, allowing it to receive ETH. However, for non-native tokens (i.e., when _tokenAddress != wrappedNativeToken), the function completely ignores msg.value. There's no check or handling of ETH sent with the transaction in this case

Proof of Concept

// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.13;
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 LockedEth is Test {
SystemConfig systemConfig;
CapitalPool capitalPool;
TokenManager tokenManager;
PreMarktes preMarktes;
DeliveryPlace deliveryPlace;
address marketPlace;
WETH9 weth9;
MockERC20Token mockUSDCToken;
MockERC20Token mockPointToken;
address admin = vm.addr(0x123);
address user = vm.addr(0x124);
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 tadleFactory = new TadleFactory(admin);
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,
admin
);
vm.startPrank(admin);
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)
);
// Deploy Proxy
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);
tokenManager.updateTokenWhiteListed(tokenAddressList, true);
// create market place
systemConfig.createMarketPlace("Backpack", false);
marketPlace = GenerateAddress.generateMarketPlaceAddress("Backpack");
deal(address(mockUSDCToken), admin, 100000000 * 10 ** 18);
vm.stopPrank();
vm.warp(1719826275);
}
function test_Locked_Ether () public {
// Give user some balance
deal(user, 1000);
deal(address(mockUSDCToken), user, 2000);
deal(address(weth9), address(capitalPool), 2000);
uint256 offerId = 0 ;
// Generate makerAddr Address
address makerAddr = GenerateAddress.generateMakerAddress(offerId);
// Generate offer Address
address offerAddr = GenerateAddress.generateOfferAddress(offerId);
// Generate offer Stock Address
address stockAddr = GenerateAddress.generateStockAddress(offerId);
address stock1Addr = GenerateAddress.generateStockAddress(1);
address offer1Addr = GenerateAddress.generateOfferAddress(1);
// points
uint256 _points = 500;
// amount
uint256 _amount = 63 ;
// Collateral Rate
uint256 collateralRate = 12000;
uint256 tax = 500 ;
// Create offer params
CreateOfferParams memory params = CreateOfferParams(
marketPlace,
address(mockUSDCToken),
_points,
_amount,
collateralRate,
tax,
OfferType.Ask,
OfferSettleType.Protected
);
vm.startPrank(user);
// Aprove TokenManager
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
//starting user balance
uint256 startingUserBalance = mockUSDCToken.balanceOf(user);
//@audit-info Expected Event Emitted by the function CreateOffer()
vm.expectEmit(true, true, true, true , address(preMarktes));
emit CreateOffer(
offerAddr, // offer address
makerAddr, // makeraddress
stockAddr, // stock address
marketPlace, // marketPlace
user, // user
_points, // point
_amount // amount
);
// Create offer Pay
preMarktes.createOffer{value : 500}(params);
// log the user balance after creating offer
uint256 userBalanceAfterCreateOffer = mockUSDCToken.balanceOf(user);
// check if the balance has decreased
assertEq(userBalanceAfterCreateOffer, startingUserBalance - 76);
uint256 poolbalance = mockUSDCToken.balanceOf(address(capitalPool));
assertEq(poolbalance , 76);
// create taker user pays and sends eth along
preMarktes.createTaker{value: 400}(offerAddr, 400);
uint256 poolbalance1 = mockUSDCToken.balanceOf(address(capitalPool));
assertEq(poolbalance1 , 129);
// list offer and sends eth along
preMarktes.listOffer{value: 100}(stock1Addr, _amount, collateralRate);
// pool balance for tokens updated but not eth sent
uint256 poolbalance3 = mockUSDCToken.balanceOf(address(capitalPool));
assertEq(poolbalance3 , 205);
assertTrue(userbefore1 > userbefore2);
// close offer
preMarktes.closeOffer(stock1Addr, offer1Addr);
uint256 poolbalance4 = mockUSDCToken.balanceOf(address(capitalPool));
assertTrue(poolbalance4 == poolbalance3);
// relist offer
preMarktes.relistOffer(stock1Addr, offer1Addr);
uint256 poolbalance5 = mockUSDCToken.balanceOf(address(capitalPool));
assertTrue(poolbalance5 > poolbalance4);
// Abort offer
preMarktes.abortAskOffer(stock1Addr, offer1Addr);
// Log the balance of pool balance of user
// after offer is aborted , user becomes TokenBalanceType.MakerRefund
uint256 amount = tokenManager.userTokenBalanceMap(user, address(mockUSDCToken), TokenBalanceType.MakerRefund);
assertTrue(amount == 150);
// eth transferred is not tracked
uint256 amount2 = tokenManager.userTokenBalanceMap(user, address(weth9), TokenBalanceType.MakerRefund);
assertTrue(amount2 == 0);
//user balance is now zero
uint256 remainingEth_Balance = address(user).balance;
assertTrue(remainingEth_Balance == 0);
vm.stopPrank();
}
// expected events
event CreateOffer(
address indexed _offer,
address indexed _maker,
address indexed _stock,
address _marketPlace,
address _authority,
uint256 _points,
uint256 _amount
);
}

Run command :

forge test --mt test_Locked_Ether -vvvv --via-ir

Impact

Users funds will be permanently locked in the pool.

Tools Used

Manual Review

Recommendations

  1. Account or Track eth transfer in the pool

2. Revert transactions with non-zero msg.value for non-WETH tokens:

if (_tokenAddress != wrappedNativeToken) {
require(msg.value == 0, "ETH sent for non-ETH token");
// ... existing non-WETH token logic ...
}
Updates

Lead Judging Commences

0xnevi Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Known issue
Assigned finding tags:

[invalid] finding-TokenManager-tillin-excess

Invalid, these are by default, invalid based on codehawks [general guidelines](https://docs.codehawks.com/hawks-auditors/how-to-determine-a-finding-validity#findings-that-may-be-invalid). The check implemented is simply a sufficiency check, it is users responsibility to only send an appropriate amount of native tokens where amount == msg.value when native token is intended to be used as collateral (which will subsequently be deposited as wrapped token). All excess ETH can be rescued using the `Rescuable.sol` contract. > Users sending ETH/native tokens > If contracts allow users to send tokens acc111identally.

Support

FAQs

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