DeFiFoundry
60,000 USDC
View results
Submission Details
Severity: low
Invalid

DoS on Liquidation/Settlement due to zero transfer

Summary

When liquidating an account or filling an order, fees are deducted by iterating through the deposited collateral until all required fees are paid. Ref:

https://github.com/Cyfrin/2024-07-zaros/blob/d687fe96bb7ace8652778797052a38763fbcbb1b/src/perpetuals/leaves/TradingAccount.sol#L488-L578

However, there is no minimum deposit enforced in depositMargin, allowing users to deposit very small amounts like 1 USDC instead of 1e6, or to reach fractional values over time after paying fees.

This becomes problematic with tokens that revert on zero-value transfers, as detailed here: https://github.com/d-xo/weird-erc20#revert-on-zero-value-transfers. When the protocol scales down to token decimals, fractional values can become zero.

This can cause a denial of service (DoS) during settlement and liquidation due to zero-value transfers.

https://github.com/Cyfrin/2024-07-zaros/blob/d687fe96bb7ace8652778797052a38763fbcbb1b/src/perpetuals/leaves/TradingAccount.sol#L462

The function withdrawMarinUsdwill attempt to transfer 0 when marginCollateralBalanceX18cannot cover the entire requiredMarginInCollateralX18.

function withdrawMarginUsd(
Data storage self,
address collateralType,
UD60x18 marginCollateralPriceUsdX18,
UD60x18 amountUsdX18,
address recipient
)
internal
returns (UD60x18 withdrawnMarginUsdX18, bool isMissingMargin)
{
MarginCollateralConfiguration.Data storage marginCollateralConfiguration =
MarginCollateralConfiguration.load(collateralType);
UD60x18 marginCollateralBalanceX18 = getMarginCollateralBalance(self, collateralType);
UD60x18 requiredMarginInCollateralX18 = amountUsdX18.div(marginCollateralPriceUsdX18);
uint256 amountToTransfer;
if (marginCollateralBalanceX18.gte(requiredMarginInCollateralX18)) {
withdraw(self, collateralType, requiredMarginInCollateralX18);
amountToTransfer =
marginCollateralConfiguration.convertUd60x18ToTokenAmount(requiredMarginInCollateralX18);
IERC20(collateralType).safeTransfer(recipient, amountToTransfer);
withdrawnMarginUsdX18 = amountUsdX18;
isMissingMargin = false;
} else {
withdraw(self, collateralType, marginCollateralBalanceX18);
amountToTransfer = marginCollateralConfiguration.convertUd60x18ToTokenAmount(marginCollateralBalanceX18);
@> IERC20(collateralType).safeTransfer(recipient, amountToTransfer);
withdrawnMarginUsdX18 = marginCollateralPriceUsdX18.mul(marginCollateralBalanceX18);
isMissingMargin = true;
}
}

Note that withdrawMarginUsd is used to deduct all fees from deductAccountMarginwhile it iterates through all the collaterals:

function deductAccountMargin(
Data storage self,
FeeRecipients.Data memory feeRecipients,
UD60x18 pnlUsdX18,
UD60x18 settlementFeeUsdX18,
UD60x18 orderFeeUsdX18
)
internal
returns (UD60x18 marginDeductedUsdX18)
{
// working data
DeductAccountMarginContext memory ctx;
// fetch storage slot for global config
GlobalConfiguration.Data storage globalConfiguration = GlobalConfiguration.load();
// cache collateral liquidation priority length
uint256 cachedCollateralLiquidationPriorityLength = globalConfiguration.collateralLiquidationPriority.length();
// loop through configured collateral types
for (uint256 i; i < cachedCollateralLiquidationPriorityLength; i++) {
// get ith collateral type
@> address collateralType = globalConfiguration.collateralLiquidationPriority.at(i);
// fetch storage slot for this collateral's config config
MarginCollateralConfiguration.Data storage marginCollateralConfiguration =
MarginCollateralConfiguration.load(collateralType);
// get trader's collateral balance for this collateral, scaled to 18 decimals
ctx.marginCollateralBalanceX18 = getMarginCollateralBalance(self, collateralType);
// skip to next loop iteration if trader hasn't deposited any of this collateral
if (ctx.marginCollateralBalanceX18.isZero()) continue;
// get this collateral's USD price
ctx.marginCollateralPriceUsdX18 = marginCollateralConfiguration.getPrice();
// if:
// settlement fee > 0 AND
// amount of settlement fee paid so far < settlement fee
if (settlementFeeUsdX18.gt(UD60x18_ZERO) && ctx.settlementFeeDeductedUsdX18.lt(settlementFeeUsdX18)) {
// attempt to deduct from this collateral difference between settlement fee
// and amount of settlement fee paid so far
@> (ctx.withdrawnMarginUsdX18, ctx.isMissingMargin) = withdrawMarginUsd(
self,
collateralType,
ctx.marginCollateralPriceUsdX18,
settlementFeeUsdX18.sub(ctx.settlementFeeDeductedUsdX18),
feeRecipients.settlementFeeRecipient
);
// update amount of settlement fee paid so far by the amount
// that was actually withdraw from this collateral
ctx.settlementFeeDeductedUsdX18 = ctx.settlementFeeDeductedUsdX18.add(ctx.withdrawnMarginUsdX18);
}
// order fee logic same as settlement fee above
if (orderFeeUsdX18.gt(UD60x18_ZERO) && ctx.orderFeeDeductedUsdX18.lt(orderFeeUsdX18)) {
@> (ctx.withdrawnMarginUsdX18, ctx.isMissingMargin) = withdrawMarginUsd(
self,
collateralType,
ctx.marginCollateralPriceUsdX18,
orderFeeUsdX18.sub(ctx.orderFeeDeductedUsdX18),
feeRecipients.orderFeeRecipient
);
ctx.orderFeeDeductedUsdX18 = ctx.orderFeeDeductedUsdX18.add(ctx.withdrawnMarginUsdX18);
}
// pnl logic same as settlement & order fee above
if (pnlUsdX18.gt(UD60x18_ZERO) && ctx.pnlDeductedUsdX18.lt(pnlUsdX18)) {
@> (ctx.withdrawnMarginUsdX18, ctx.isMissingMargin) = withdrawMarginUsd(
self,
collateralType,
ctx.marginCollateralPriceUsdX18,
pnlUsdX18.sub(ctx.pnlDeductedUsdX18),
feeRecipients.marginCollateralRecipient
);
ctx.pnlDeductedUsdX18 = ctx.pnlDeductedUsdX18.add(ctx.withdrawnMarginUsdX18);
}
// if there is no missing margin then exit the loop
// since all amounts have been deducted
if (!ctx.isMissingMargin) {
break;
}
}
}

PoC

Scenario for testing:

  • Create a long position for BTC using WETH as a collateral

  • deposit 1 in USDC and make the long position liquidatable by dropping the BTC price.

  • try to liquidate the long position.

  • Transaction reverts due to zero transfer amount.

Add the following snippet code on MockERC20.solto simulate a revert on zero transfer token:

function transfer(address to, uint256 value) public override virtual returns (bool) {
require(value != 0, "zero-value-transfer");
return super.transfer(to, value);
}

Then add the following test on checkLiquidatableAccounts.t.soland run: forge test --match-test testDoS_onDeductionFees_dueToSmallAmountForCollateral

function testDoS_onDeductionFees_dueToSmallAmountForCollateral() public {
// pre conditions
// 1. user open a long position that becomes liquidatable.
uint256 USER_STARTING_BALANCE = 100e18;
uint256 USER_STARTING_BALANCE_USDC = 1;
int128 USER_POS_SIZE_DELTA = 10e18;
address collateralType = address(wEth);
deal({ token: collateralType, to: users.naruto.account, give: USER_STARTING_BALANCE });
changePrank({ msgSender: users.naruto.account });
uint128 tradingAccountId = createAccountAndDeposit(USER_STARTING_BALANCE, collateralType);
openManualPosition(BTC_USD_MARKET_ID, BTC_USD_STREAM_ID, MOCK_BTC_USD_PRICE, tradingAccountId, USER_POS_SIZE_DELTA);
skip(1 minutes);
deal({ token: address(usdc), to: users.naruto.account, give: USER_STARTING_BALANCE_USDC });
perpsEngine.depositMargin(tradingAccountId, address(usdc), USER_STARTING_BALANCE_USDC);
// action: drop the price to make the position liquidatable
updateMockPriceFeed(BTC_USD_MARKET_ID, 45_000e18);
// post condition: check account is liquidable
uint128[] memory liquidatableAccountIds = perpsEngine.checkLiquidatableAccounts(0, 1);
assertEq(liquidatableAccountIds[0], 1); // tradingAccountId 1 is liquidatable
// action: liquidate the account
changePrank({ msgSender: liquidationKeeper });
vm.expectRevert("zero-value-transfer");
perpsEngine.liquidateAccounts(liquidatableAccountIds);
}

Output: transaction reverts with zero-value-transfer.

[PASS] testDoS_onDeductionFees_dueToSmallAmountForCollateral() (gas: 1654621)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 16.78ms (3.00ms CPU time)

Impact

  • Denial of Service (DoS): Settlement and liquidation processes can fail if a zero-value transfer occurs.

  • Unsettled Orders: Fees cannot be deducted, leaving orders unfulfilled.

  • Account Liquidation Failure: Liquidation of undercollateralized accounts may be blocked, risking the protocol's financial stability.

  • User Fund Lock: Users may be unable to withdraw or transfer their funds due to failed transactions.

Tools Used

Manual Review & Foundry

Recommendations

Only transfer the amount when the value is > 0.

if (marginCollateralBalanceX18.gte(requiredMarginInCollateralX18)) {
withdraw(self, collateralType, requiredMarginInCollateralX18);
amountToTransfer =
marginCollateralConfiguration.convertUd60x18ToTokenAmount(requiredMarginInCollateralX18);
IERC20(collateralType).safeTransfer(recipient, amountToTransfer);
withdrawnMarginUsdX18 = amountUsdX18;
isMissingMargin = false;
} else {
withdraw(self, collateralType, marginCollateralBalanceX18);
amountToTransfer = marginCollateralConfiguration.convertUd60x18ToTokenAmount(marginCollateralBalanceX18);
+ if (amountToTransfer > 0)
IERC20(collateralType).safeTransfer(recipient, amountToTransfer);
withdrawnMarginUsdX18 = marginCollateralPriceUsdX18.mul(marginCollateralBalanceX18);
isMissingMargin = true;
}

(Additional) Consider adding a minimum value for the deposit.

Updates

Lead Judging Commences

inallhonesty Lead Judge
over 1 year ago
inallhonesty Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Out of scope

Support

FAQs

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

Give us feedback!