Tadle

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

Collateral for sold points is not refunded in `PreMarkets::closeOffer`

Summary

PreMarkets::closeOffer does not refund the full collateral amount to users, specifically failing to return the collateral for points that have been sold. This results in users losing funds that should rightfully be returned to them.

Even though the remaining collateral can be refunded via abortOffer, there are differences in usecases. As per sponsor's comments:

Example:

  1. Maker is selling 100 points

  2. Taker buys 60 points from the offer

If the 2 Takers don't relist the holdings/stock pts, then the Maker can abortOffer and all deals between the Maker and the Takers will be set to INVALID. The Maker get all the collateral fund back. No liability to settle any token.

Whether the Takers relist or not, the Maker can cancelOffer and get the REST collateral (for remaining 40 pts) back. Then no taker can buy pts from this CANCELLED offer. When TGE comes, the Maker still obtain the liability to settle the tokens related to the sold 60 points before.

Therefore, abortAskOffer is used in cases where the offer is completely cancelled and no sales are going to occur, whereas cancelOffer is used in cases where only new offers are cancelled and the old ones stay and should be respected when TGE comes.

That being said, cancelOffer should return any remaining collateral from completed purchases, because they will indeed go through in the future.

Impact

Users who create offers and then close them after partial fills will lose a portion of their collateral. This loss is proportional to the number of points sold before closing the offer.

Proof of Concept

In the PreMarkets::closeOffer function, the refund calculation does not account for the collateral of sold points. OfferLibraries.getRefundAmount only calculates the refund based on the remaining (unsold) points, leaving the collateral for sold points trapped in the contract.

Consider this scenario:

  1. Alice creates an offer for 100,000 points for 10,000 with 110% collateral rate (therefore deposits 11,000).

  2. Bob buys 50,000 points, using 5500 USDC worth of Alice's collateral and paying her 5000 USDC.

  3. Alice decides to close the offer.

  4. The current implementation only refunds Alice's collateral for the remaining 50,000 points (so 5500), effectively losing 500 USDC of her original collateral.

function test_closeOffer_lackingCollateralRefund() public {
// ~~~~~~~~~~~~~~~~~~~~ Setup ~~~~~~~~~~~~~~~~~~~~
address alice = makeAddr("alice");
address bob = makeAddr("bob");
uint256 MAKER_POINTS = 100_000;
uint256 TAKER_POINTS = MAKER_POINTS / 2; // 50%
uint256 TOKEN_AMOUNT = 10_000 * 1e18;
uint256 INITIAL_BALANCE = 100_000 * 1e18;
deal(address(mockUSDCToken), alice, INITIAL_BALANCE);
deal(address(mockUSDCToken), bob, INITIAL_BALANCE);
vm.startPrank(alice);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
console.log("- Alice's balance initially: %18e\n", INITIAL_BALANCE);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ~~~~~~~~~~ 1) Create a maker offer ~~~~~~~~~~
preMarktes.createMaker(
CreateMakerParams(
marketPlace,
address(mockUSDCToken),
MAKER_POINTS, // points
TOKEN_AMOUNT, // amount per point
10_000 * 1.1, // collateral rate (110%)
0, // trade tax (0%)
OfferType.Ask,
OfferSettleType.Protected
)
);
address offerAddr = GenerateAddress.generateOfferAddress(0);
address stockAddr = GenerateAddress.generateStockAddress(0);
console.log("> Alice's balance after creating an offer: %18e\n", mockUSDCToken.balanceOf(alice));
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ~~~~~~~~~~ 2) Partially fill the offer ~~~~~~~~~~
vm.startPrank(bob);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
preMarktes.createTaker(offerAddr, TAKER_POINTS);
uint256 balanceAfterPartialFill =
tokenManager.userTokenBalanceMap(alice, address(mockUSDCToken), TokenBalanceType.SalesRevenue);
console.log("> Alice's balance after partial fill: %18e", mockUSDCToken.balanceOf(alice));
console.log("> Alice's userTokenBalance (SalesRevenue) after partial fill: %18e\n", balanceAfterPartialFill);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ~~~~~~~~~~ 3) Close the offer ~~~~~~~~~~
vm.startPrank(alice);
preMarktes.closeOffer(stockAddr, offerAddr);
// Check balance after first refund
uint256 balanceAfterClose =
tokenManager.userTokenBalanceMap(alice, address(mockUSDCToken), TokenBalanceType.MakerRefund);
console.log(">> Alice's balance after closing an offer: %18e", mockUSDCToken.balanceOf(alice));
console.log(">> Alice's userTokenBalance (MakerRefund) after closing an offer: %18e\n", balanceAfterClose);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ~~~~~~~~~~ 4) Checks ~~~~~~~~~~
// Withdraw earnings
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.SalesRevenue);
// Withdraw refunded collateral
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.MakerRefund);
uint256 CURRENT_BALANCE = mockUSDCToken.balanceOf(alice);
console.log("- Alice's initial balance: %18e", INITIAL_BALANCE);
console.log("- Alice's final balance, after withdrawing sales revenue and refunds: %18e", CURRENT_BALANCE);
// Confirm that alice has received less funds
assertLt(CURRENT_BALANCE, INITIAL_BALANCE, "Alice's balance is actually larger than it initially was.");
if (INITIAL_BALANCE > CURRENT_BALANCE) {
console.log(unicode"❌ Alice has lost %18e USDC of collateral!", INITIAL_BALANCE - CURRENT_BALANCE);
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
}

Tools Used

Foundry, Manual review

Recommended Mitigation Steps

Modify PreMarkets::closeOffer to refund the full collateral amount, including the portion for sold points:

function closeOffer(address _stock, address _offer) external {
// ... (existing checks)
if (makerInfo.offerSettleType == OfferSettleType.Protected || stockInfo.preOffer == address(0x0)) {
- uint256 refundAmount = OfferLibraries.getRefundAmount(
- offerInfo.offerType, offerInfo.amount, offerInfo.points, offerInfo.usedPoints, offerInfo.collateralRate
- );
+ uint256 totalCollateral = OfferLibraries.getDepositAmount(
+ offerInfo.offerType, offerInfo.collateralRate, offerInfo.amount, true, Math.Rounding.Floor
+ );
+ uint256 usedCollateral = OfferLibraries.getDepositAmount(
+ offerInfo.offerType,
+ offerInfo.collateralRate,
+ offerInfo.amount.mulDiv(offerInfo.usedPoints, offerInfo.points, Math.Rounding.Ceil),
+ false,
+ Math.Rounding.Ceil
+ );
+ uint256 refundAmount = totalCollateral - usedCollateral;
ITokenManager tokenManager = tadleFactory.getTokenManager();
tokenManager.addTokenBalance(
TokenBalanceType.MakerRefund, _msgSender(), makerInfo.tokenAddress, refundAmount
);
}
offerInfo.offerStatus = OfferStatus.Canceled;
emit CloseOffer(_offer, _msgSender());
}
Updates

Lead Judging Commences

0xnevi Lead Judge
over 1 year ago
0xnevi Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Appeal created

irondevx Submitter
over 1 year ago
0xnevi Lead Judge
over 1 year ago
0xnevi Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!