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

Zaros - Low findings report

[L-01] Missing a dedicated function to deposit Ether

The fee to pay for verifying chainlink data stream reports are calculated and paid in native token.

SettlementConfig.verifyDataStreamReport gets the fee denominated in Ether then calls verifyReport. Next the fee amount is sent to chainlinkVerifier.

function verifyReport(
IVerifierProxy chainlinkVerifier,
FeeAsset memory fee,
bytes memory signedReport
)
internal
returns (bytes memory verifiedReportData)
{// @audit fee is paid in native token
verifiedReportData = chainlinkVerifier.verify{ value: fee.amount }(signedReport, abi.encode(fee.assetAddress));
}

The problem is that the only payable function is TradingAccountBranch.createTradingAccountAndMulticall. Each time when admin wants to sent Ether to contract for reports verification, a new tradingAccountId is created.

function createTradingAccountAndMulticall(
bytes[] calldata data,
bytes memory referralCode,
bool isCustomReferralCode
)
external
payable
virtual
returns (bytes[] memory results)
{
uint128 tradingAccountId = createTradingAccount(referralCode, isCustomReferralCode);
//...
}

Tools Used

Manual Review

Recommended Mitigation Steps

Implement an empty receive function

receive() external payable {}

[L-02] If a tradingAccountId is transferred back and forth old pending orders that should be canceled are active again

When a tradingAccountId NFT is transferred old pending orders can not be executed anymore because the new accountId owner is not the signed. FillOffchainOrder is reverted.

function fillOffchainOrders(
uint128 marketId,
OffchainOrder.Data[] calldata offchainOrders,
bytes calldata priceData
)
external
onlyOffchainOrdersKeeper(marketId)
{
//...
// ensure the signer is the owner of the trading account, otherwise revert.
// NOTE: If an account's owner transfers to another address, this will fail. Therefore, clients must
// cancel all users offchain orders in that scenario.
if (ctx.signer != tradingAccount.owner) {
revert Errors.InvalidOrderSigner(ctx.signer, tradingAccount.owner);
}
//...
}

The tradingAccount owner is updated in notifyAccounTransfer when the nftId is transferred

While this check makes old pending transactions non executable, if the same tradingAccountId is transferred back to first owner, the old pending orders are active again and can be executed.
Imagine a scenario where an user signs offchian an order.

  • Order does not get executed (eg. due to price not matching), he transfer tradingAccountId to another address he controls. User thinks the order is canceled.

  • After some time, while no new orders are created (thus nonce doesn't get increased) he transfer same tradingAccountId to first address. => the old unfilled order is active again. Order can be filled when price conditions are meet.

Impact

While traders think old pending positions are canceled, new positions can be created at unfavorable prices.

Recommended Mitigation Steps

Increase the trading account's nonce when the account is transferred.

function notifyAccountTransfer(address to, uint128 tradingAccountId) external {// v1
_onlyTradingAccountToken();
TradingAccount.Data storage tradingAccount = TradingAccount.loadExisting(tradingAccountId);
tradingAccount.owner = to;
++ tradingAccount.nonce++;
}

[L-03] Liquidation can take up many blocks, increasing the chance that protocol will accumulate bad debt

In the liquidation process, checkUpkeep is responsible for identifying liquidatable accountIDs within a specified range (checkLowerBound, checkUpperBound) and passing a subset of these to performUpkeep for execution.

checkUpkeep operates on two distinct account ID intervals:

  1. A checking interval (checkLowerBound, checkUpperBound) for liquidation condition evaluation

  2. An execution interval (performLowerBound, performUpperBound) for actual liquidation processing

Multiple upkeeps can be registered to process many accounts per block.

The workflow is as follows:

  1. checkUpkeep examines an interval of accountIds to identify those meeting the liquidation condition.

  2. A subset of liquidatable accounts, constrained by the execution interval, is passed to performUpkeep

  3. Each upkeep processes its allocated slice of liquidatable accounts in every block

A potential bottleneck may arise when there are significantly more accounts to be liquidated than the number of actual liquidations defined by the execution interval (performLowerBound, performUpperBound) within a single block.
Let's take the following example:

checkLowerBound = 100

checkUpperBound = 200

performLowerBound = 0

performUpperBound = 10

Checking interval length = 200 - 100 = 100

Execution interval length = 10 - 0 = 10.

If all accounts from checking interval are liquidatable, it needs 10 blocks to liquidate all of them. In this time market conditions can get worse, making user's margin to not cover for PnL and fees.Because worsening market conditions new users became liquidatable adding more pressure (time needed to liquidate all positions) .

Moreover if performLowerBound is different than 0, some accounts will not get liquidated because of the following check:

function checkUpkeep(bytes calldata checkData) {
//...
uint128[] memory liquidatableAccountsIds =
perpsEngine.checkLiquidatableAccounts(checkLowerBound, checkUpperBound);
uint128[] memory accountsToBeLiquidated;
if (liquidatableAccountsIds.length == 0 || liquidatableAccountsIds.length <= performLowerBound) {
// @audit if performLowerBound > 0 an empty list of accountIds will be passed to performUpkeep
// even there's at least 1 account liquidatable
performData = abi.encode(accountsToBeLiquidated);
return (upkeepNeeded, performData);
}
//...
}

Recommended Mitigation Steps

  1. Renounce to execution interval feature and liquidate all liquidatable accounts found in check interval.
    This means that a smaller check interval need to be configured and more upkeeps to be registred.

  2. Renounce to performLowerBound check to ensure no liquidatable accounts are skipped.


[L-04] Unused data is encoded in checkUpkeep

In checkUpkeep the address(this) is encoded in extraData and passed to performUpkeep but never decoded.
There's no issue with this because accountsToBeLiquidated is of type uint128. Instead if ids would have been of type uint256, address(this) would have been interpreted as a valid Id.

function checkUpkeep(bytes calldata checkData)
external
view
returns (bool upkeepNeeded, bytes memory performData)
{
//...
uint128[] memory accountsToBeLiquidated;
//...
accountsToBeLiquidated = new uint128[](performLength);
for (uint256 i; i < performLength; i++) {
uint256 accountIdIndexAtLiquidatableAccounts = performLowerBound + i;
if (accountIdIndexAtLiquidatableAccounts >= liquidatableAccountsIds.length) {
break;
}
accountsToBeLiquidated[i] = liquidatableAccountsIds[accountIdIndexAtLiquidatableAccounts];
if (!upkeepNeeded && liquidatableAccountsIds[accountIdIndexAtLiquidatableAccounts] != 0) {
upkeepNeeded = true;
}
}
// @audit address(this) is not decoded/ used
bytes memory extraData = abi.encode(accountsToBeLiquidated, address(this));
return (upkeepNeeded, extraData);
}

In liquidateAccounts() function TradingAccount.loadExisting would reverted because the Liquidation Keeper address (address(this) encoded) would not have an owner.

/// @notice Checks whether the given trading account exists.
/// @param tradingAccountId The trading account id.
/// @return tradingAccount if the trading account exists, its storage pointer is returned.
function loadExisting(uint128 tradingAccountId) internal view returns (Data storage tradingAccount) {
tradingAccount = load(tradingAccountId);
// @audit if an invalid tradingAccountId is passed liquidation would be DoSed
if (tradingAccount.owner == address(0)) {
revert Errors.AccountNotFound(tradingAccountId, msg.sender);
}
}

Recommended Mitigation Steps

Remove the address(this)

function checkUpkeep(bytes calldata checkData)
external
view
returns (bool upkeepNeeded, bytes memory performData)
{
//...
-- bytes memory extraData = abi.encode(accountsToBeLiquidated, address(this));
++ bytes memory extraData = abi.encode(accountsToBeLiquidated);
return (upkeepNeeded, extraData);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge
11 months ago
inallhonesty Lead Judge 11 months ago
Submission Judgement Published
Validated
Assigned finding tags:

No means for the PerpEngine to receive native to pay the Chainlink Verifier in case Chainlinks charges fees to the protocol

When a user creates an order and then transfers the account NFT, pending orders should clear

Support

FAQs

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