DittoETH - serial-coder's results

A decentralized stablecoin protocol with an order book design for supercharged staking yield.

General Information

Platform: Code4rena

Start Date: 15/03/2024

Pot Size: $60,500 USDC

Total HM: 16

Participants: 43

Period: 21 days

Judge: hansfriese

Total Solo HM: 5

Id: 348

League: ETH

DittoETH

Findings Distribution

Researcher Performance

Rank: 6/43

Findings: 5

Award: $4,573.16

🌟 Selected for report: 1

πŸš€ Solo Findings: 0

Findings Information

🌟 Selected for report: nonseodion

Also found by: serial-coder

Labels

3 (High Risk)
satisfactory
duplicate-134

Awards

2108.9448 USDC - $2,108.94

External Links

Judge has assessed an item in Issue #266 as 3 risk. The relevant finding follows:

[L-02] Incorrectly executing the updateErcDebt() leads to virtually minting the ercDebt more than necessary

#0 - c4-judge

2024-04-17T06:46:39Z

hansfriese marked the issue as satisfactory

#1 - c4-judge

2024-04-17T06:47:12Z

hansfriese marked the issue as duplicate of #134

Findings Information

🌟 Selected for report: samuraii77

Also found by: samuraii77, serial-coder

Labels

3 (High Risk)
partial-75
duplicate-104

Awards

1581.7086 USDC - $1,581.71

External Links

Judge has assessed an item in Issue #266 as 3 risk. The relevant finding follows:

Incorrect verification of shorter and shortRecordId in RedemptionFacet::claimRemainingCollateral()

#0 - c4-judge

2024-04-11T16:08:36Z

hansfriese marked the issue as duplicate of #104

#1 - c4-judge

2024-04-11T16:08:43Z

hansfriese marked the issue as satisfactory

#2 - hansfriese

2024-04-17T06:36:13Z

I will apply 75% partial credits due to the lack of a detailed impact explanation.

#3 - c4-judge

2024-04-17T06:36:22Z

hansfriese marked the issue as partial-75

Findings Information

🌟 Selected for report: serial-coder

Also found by: unix515

Labels

bug
2 (Med Risk)
insufficient quality report
primary issue
satisfactory
selected for report
sponsor confirmed
:robot:_156_group
M-01

Awards

822.4885 USDC - $822.49

External Links

Lines of code

https://github.com/code-423n4/2024-03-dittoeth/blob/91faf46078bb6fe8ce9f55bcb717e5d2d302d22e/contracts/facets/RedemptionFacet.sol#L81 https://github.com/code-423n4/2024-03-dittoeth/blob/91faf46078bb6fe8ce9f55bcb717e5d2d302d22e/contracts/facets/RedemptionFacet.sol#L110-L114 https://github.com/code-423n4/2024-03-dittoeth/blob/91faf46078bb6fe8ce9f55bcb717e5d2d302d22e/contracts/facets/RedemptionFacet.sol#L38

Vulnerability details

Impact

The BidOrdersFacet::bidMatchAlgo() allows a shortOrder to be partially matched and leave its ercAmount * price < minAskEth due to the DUST_FACTOR constant (as long as its corresponding shortRecord is maintaining enough ercDebt + shortOrder's ercAmount to keep the position >= the minShortErc threshold).

The redemption process enables redeemers to redeem their ercEscrowed for ethCollateral on target shortRecords. If a shortRecord was partially filled, the RedemptionFacet::proposeRedemption() must guarantee that the corresponding shortOrder maintains the ercAmount >= minShortErc. In other words, if the shortOrder's ercAmount is less than the minShortErc threshold, the shortOrder must be canceled from the order book. Otherwise, the shortRecord position will be less than the minShortErc threshold when the order is matched again. Subsequently, the small shortRecord (short position) will not incentivize liquidators to liquidate it even if it is liquidable, leaving bad debt to the protocol.

I discovered that the proposeRedemption() improperly verifies the proposer (redeemer)'s inputted shortOrderId param, allowing an attacker to specify the shortOrderId param to another shortOrder's id that does not correspond to the target redeeming shortRecord.

The vulnerability can bypass the minShortErc threshold verification process on the shortOrder corresponding to the processing shortRecord, eventually allowing an attacker to leave a small shortRecord position that disincentivizes liquidators from liquidating the position. Furthermore, the small shortRecord also disables the redemption mechanism from redeeming it for ethCollateral.

Proof of Concept

While processing the proposalInput param, the proposeRedemption() will load the shortOrder from storage according to the attacker-inputted shortOrderId param (i.e., p.shortOrderId).

Suppose that the attacker wants to leave their small shortRecord position to disincentivize liquidators from liquidating it (as well as disabling the redemption mechanism from redeeming it for ethCollateral). They can place their partially-filled shortRecord that has the corresponding shortOrder.ercAmount < minShortErc to be redeemed via a Sybil account.

To bypass the minShortErc threshold verification process from canceling their small shortOrder, the attacker must specify the p.shortOrderId param to another shortOrder's id with ercAmount >= minShortErc. The manipulated p.shortOrderId param can bypass the verification process because if the loaded shortOrder.ercAmount >= minShortErc, the proposeRedemption() will not proceed to verify the validity of the shortOrder.

Hence, the attacker can target their shortRecord to be redeemed and leave its corresponding shortOrder with ercAmount < minShortErc to keep alive on the order book. Once their small shortOrder is matched again, it will leave the shortRecord position less than the minShortErc threshold, disincentivizing liquidators from liquidating it.

Furthermore, the small shortRecord (i.e., shortRecord.ercDebt < minShortErc) also disables the redemption mechanism from redeeming it for ethCollateral.

    function proposeRedemption(
        address asset,
        MTypes.ProposalInput[] calldata proposalInput,
        uint88 redemptionAmount,
        uint88 maxRedemptionFee
    ) external isNotFrozen(asset) nonReentrant {
        ...

        for (uint8 i = 0; i < proposalInput.length; i++) {
            p.shorter = proposalInput[i].shorter;
            p.shortId = proposalInput[i].shortId;
            p.shortOrderId = proposalInput[i].shortOrderId;
            STypes.ShortRecord storage currentSR = s.shortRecords[p.asset][p.shorter][p.shortId];

            ...

@1          STypes.Order storage shortOrder = s.shorts[asset][p.shortOrderId]; //@audit -- The shortOrder is loaded from storage according to the attacker's p.shortOrderId param
@2          if (currentSR.status == SR.PartialFill && shortOrder.ercAmount < minShortErc) { //@audit -- The attacker can leave their target partially-filled shortOrder (corresponding to the redeeming shortRecord) with ercAmount < minShortErc alive by specifying the p.shortOrderId param to another shortOrder's id with ercAmount >= minShortErc
@3              if (shortOrder.shortRecordId != p.shortId || shortOrder.addr != p.shorter) revert Errors.InvalidShortOrder(); //@audit -- The root cause is that the proposeRedemption() verifies the loaded shortOrder against the legitimate shortRecordId and shorter params only after the condition in @2 was met
                LibOrders.cancelShort(asset, p.shortOrderId);
            }

            ...
        }

        ...
    }

Tools Used

Manual Review

To fix the vulnerability, move out the shortOrder verification check and execute it immediately after loading the shortOrder from storage.

    function proposeRedemption(
        address asset,
        MTypes.ProposalInput[] calldata proposalInput,
        uint88 redemptionAmount,
        uint88 maxRedemptionFee
    ) external isNotFrozen(asset) nonReentrant {
        ...

        for (uint8 i = 0; i < proposalInput.length; i++) {
            p.shorter = proposalInput[i].shorter;
            p.shortId = proposalInput[i].shortId;
            p.shortOrderId = proposalInput[i].shortOrderId;
            STypes.ShortRecord storage currentSR = s.shortRecords[p.asset][p.shorter][p.shortId];

            ...

            STypes.Order storage shortOrder = s.shorts[asset][p.shortOrderId];
+           if (shortOrder.shortRecordId != p.shortId || shortOrder.addr != p.shorter) revert Errors.InvalidShortOrder();
            if (currentSR.status == SR.PartialFill && shortOrder.ercAmount < minShortErc) {
-               if (shortOrder.shortRecordId != p.shortId || shortOrder.addr != p.shorter) revert Errors.InvalidShortOrder();
                LibOrders.cancelShort(asset, p.shortOrderId);
            }

            ...
        }

        ...
    }

Assessed type

Invalid Validation

#0 - c4-pre-sort

2024-04-07T02:32:34Z

raymondfam marked the issue as insufficient quality report

#1 - c4-pre-sort

2024-04-07T02:32:38Z

raymondfam marked the issue as primary issue

#2 - raymondfam

2024-04-07T02:34:57Z

Inadequate/unstructured proof to support the intended code refactoring.

#3 - c4-judge

2024-04-17T05:38:17Z

hansfriese marked the issue as unsatisfactory: Insufficient proof

#4 - serial-coder

2024-04-17T11:42:17Z

Hi @hansfriese,

Appeal

Vulnerability Details

Please have a second look at the following from the Proof of Concept section:

While processing the proposalInput param, the proposeRedemption() will load the shortOrder from storage according to the attacker-inputted shortOrderId param (i.e., p.shortOrderId).

Suppose that the attacker wants to leave their small shortRecord position to disincentivize liquidators from liquidating it (as well as disabling the redemption mechanism from redeeming it for ethCollateral). They can place their partially-filled shortRecord that has the corresponding shortOrder.ercAmount < minShortErc to be redeemed via a Sybil account.

To bypass the minShortErc threshold verification process from canceling their small shortOrder, the attacker must specify the p.shortOrderId param to another shortOrder's id with ercAmount >= minShortErc. The manipulated p.shortOrderId param can bypass the verification process because if the loaded shortOrder.ercAmount >= minShortErc, the proposeRedemption() will not proceed to verify the validity of the shortOrder.

Hence, the attacker can target their shortRecord to be redeemed and leave its corresponding shortOrder with ercAmount < minShortErc to keep alive on the order book. Once their small shortOrder is matched again, it will leave the shortRecord position less than the minShortErc threshold, disincentivizing liquidators from liquidating it.

Furthermore, the small shortRecord (i.e., shortRecord.ercDebt < minShortErc) also disables the redemption mechanism from redeeming it for ethCollateral.

To demystify the vulnerability again, please follow the links along:

  1. The shortOrder is loaded from storage according to the attacker's p.shortOrderId param.

  2. The attacker can leave their target partially-filled shortOrder (corresponding to the redeeming shortRecord) with ercAmount < minShortErc alive on the order book by specifying the p.shortOrderId param to another shortOrder's id with ercAmount >= minShortErc (to bypass the if statement).

  3. The root cause is that the proposeRedemption() will verify the loaded shortOrder against the shortRecordId and shorter params only after the condition in Step 2 was met.

  4. Since the attacker already bypassed the condition check in Step 2, Step 3 would not be executed.

Hence, if the attacker points the p.shortOrderId param to another (fake) shortOrder's id with ercAmount >= minShortErc, they can bypass the validity check of the loaded (fake) shortOrder, preventing their targeted small shortOrder (the real shortOrder corresponding to the redeeming shortRecord) from being canceled.

Subsequently, the attacker can leave their target small shortOrder on the order book and when it is matched, its short position (shortRecord) will be less than theΒ minShortErcΒ threshold that will disincentivize liquidators from liquidating the position even if it is liquidable, leading to the protocol's bad debt. Moreover, the smallΒ shortRecordΒ (i.e.,Β shortRecord.ercDebtΒ <Β minShortErc) will also disable the redemption mechanism from redeeming it forΒ ethCollateral.

Summary Of Impacts

  1. An attacker can leave small shortOrders on the order book. As a result, large Bid orders can run out of gas if the attacker places many small shortOrders on the order book.

  2. Liquidators are disincentivized from liquidating small shortRecords because of their small amounts.

  3. Small shortRecords disable the redemption mechanism from redeeming them forΒ ethCollateral even if they have poor collateralization.

Solution

The snippet below presents how to fix the vulnerability by verifying the validity of the loaded shortOrder (Step 1) against the shortRecordId and shorter params (Step 3) before processing the loaded shortOrder (Step 2).

This way, an attacker cannot manipulate the p.shortOrderId param to another (fake) shortOrder's id.

    function proposeRedemption(
        address asset,
        MTypes.ProposalInput[] calldata proposalInput,
        uint88 redemptionAmount,
        uint88 maxRedemptionFee
    ) external isNotFrozen(asset) nonReentrant {
        ...

        for (uint8 i = 0; i < proposalInput.length; i++) {
            p.shorter = proposalInput[i].shorter;
            p.shortId = proposalInput[i].shortId;
            p.shortOrderId = proposalInput[i].shortOrderId;
            STypes.ShortRecord storage currentSR = s.shortRecords[p.asset][p.shorter][p.shortId];

            ...

            STypes.Order storage shortOrder = s.shorts[asset][p.shortOrderId]; //@audit -- Step 1
+           if (shortOrder.shortRecordId != p.shortId || shortOrder.addr != p.shorter) revert Errors.InvalidShortOrder(); //@audit -- Process Step 3 before Step 2 to fix the vulnerability
            if (currentSR.status == SR.PartialFill && shortOrder.ercAmount < minShortErc) { //@audit -- Step 2
-               if (shortOrder.shortRecordId != p.shortId || shortOrder.addr != p.shorter) revert Errors.InvalidShortOrder(); //@audit -- Step 3
                LibOrders.cancelShort(asset, p.shortOrderId);
            }

            ...
        }

        ...
    }

#5 - ditto-eth

2024-04-19T15:46:37Z

This is valid, good find

#6 - c4-sponsor

2024-04-19T15:47:18Z

ditto-eth (sponsor) confirmed

#7 - hansfriese

2024-04-21T10:46:24Z

Nice finding. Medium is appropriate as an attacker can bypass the minShortErc validation.

#8 - c4-judge

2024-04-21T10:46:29Z

hansfriese removed the grade

#9 - c4-judge

2024-04-21T10:46:37Z

hansfriese marked the issue as satisfactory

#10 - c4-judge

2024-04-21T10:46:50Z

hansfriese marked the issue as selected for report

Findings Information

🌟 Selected for report: klau5

Also found by: 0xSecuri, Bauchibred, Bigsam, Infect3d, falconhoof, nonseodion, serial-coder

Labels

bug
2 (Med Risk)
insufficient quality report
partial-50
sponsor confirmed
:robot:_08_group
duplicate-164

Awards

42.0292 USDC - $42.03

External Links

Lines of code

https://github.com/code-423n4/2024-03-dittoeth/blob/91faf46078bb6fe8ce9f55bcb717e5d2d302d22e/contracts/libraries/LibOracle.sol#L125-L126 https://github.com/code-423n4/2024-03-dittoeth/blob/91faf46078bb6fe8ce9f55bcb717e5d2d302d22e/contracts/libraries/LibOracle.sol#L77

Vulnerability details

Impact

This report raises an issue regarding the lack of a stale price check for the base oracle (ETH/USD price) in the oracleCircuitBreaker() only, as the lack of a stale price check for the non-ETH/USD asset oracle (multi-asset oracle) was flagged as a known issue.

The LibOracle::oracleCircuitBreaker() does not check the stale price for the base oracle (ETH/USD price). Specifically, I'm talking about the condition: "block.timestamp > 2 hours + baseTimeStamp" (i.e., the 2-hour stale heartbeat as per the docs). Hence, the function cannot verify whether or not the baseChainlinkPrice is stale.

Consequently, the oracleCircuitBreaker() will not revert the transaction as expected if the baseChainlinkPrice is stale. As a result, the protocol's core functions will consume the stale price, harming the protocol's and users' funds.

Proof of Concept

The oracleCircuitBreaker() does not check the stale price for the base oracle (ETH/USD price) (i.e., the condition: "block.timestamp > 2 hours + baseTimeStamp") compared to the baseOracleCircuitBreaker().

Without the stale price check mentioned above, the oracleCircuitBreaker() cannot verify whether the baseChainlinkPrice is stale. For this reason, the oracleCircuitBreaker() will not revert the transaction as expected if the baseChainlinkPrice is stale.

    //@audit -- This report raises an issue regarding the lack of a stale price check for the base oracle (ETH/USD price) in the oracleCircuitBreaker() only, as the lack of a stale price check for the non-ETH/USD asset oracle (multi-asset oracle) was flagged as a known issue
    function oracleCircuitBreaker(
        uint80 roundId,
        uint80 baseRoundId,
        int256 chainlinkPrice,
        int256 baseChainlinkPrice,
        uint256 timeStamp,
        uint256 baseTimeStamp
    ) private view {
@1      bool invalidFetchData = roundId == 0 || timeStamp == 0 || timeStamp > block.timestamp || chainlinkPrice <= 0
@1          || baseRoundId == 0 || baseTimeStamp == 0 || baseTimeStamp > block.timestamp || baseChainlinkPrice <= 0; //@audit -- The oracleCircuitBreaker() lacks checking the stale price for the base oracle (ETH/USD price) (condition: "block.timestamp > 2 hours + baseTimeStamp")

        if (invalidFetchData) revert Errors.InvalidPrice();
    }

    ...

    function baseOracleCircuitBreaker(
        uint256 protocolPrice,
        uint80 roundId,
        int256 chainlinkPrice,
        uint256 timeStamp,
        uint256 chainlinkPriceInEth
    ) private view returns (uint256 _protocolPrice) {
        bool invalidFetchData = roundId == 0 || timeStamp == 0 || timeStamp > block.timestamp || chainlinkPrice <= 0
@2          || block.timestamp > 2 hours + timeStamp; //@audit -- Whereas the baseOracleCircuitBreaker() already checks that condition as per the docs
        uint256 chainlinkDiff =
            chainlinkPriceInEth > protocolPrice ? chainlinkPriceInEth - protocolPrice : protocolPrice - chainlinkPriceInEth;
        bool priceDeviation = protocolPrice > 0 && chainlinkDiff.div(protocolPrice) > 0.5 ether;

        ...
    }

Tools Used

Manual Review

Add the stale price check for the base oracle (the condition: block.timestamp > 2 hours + baseTimeStamp) in the oracleCircuitBreaker().

    function oracleCircuitBreaker(
        uint80 roundId,
        uint80 baseRoundId,
        int256 chainlinkPrice,
        int256 baseChainlinkPrice,
        uint256 timeStamp,
        uint256 baseTimeStamp
    ) private view {
        bool invalidFetchData = roundId == 0 || timeStamp == 0 || timeStamp > block.timestamp || chainlinkPrice <= 0
-           || baseRoundId == 0 || baseTimeStamp == 0 || baseTimeStamp > block.timestamp || baseChainlinkPrice <= 0;
+           || baseRoundId == 0 || baseTimeStamp == 0 || baseTimeStamp > block.timestamp || baseChainlinkPrice <= 0
+           || block.timestamp > 2 hours + baseTimeStamp; //@audit -- Add the stale price check for the base oracle (ETH/USD price)

        if (invalidFetchData) revert Errors.InvalidPrice();
    }

Assessed type

Oracle

#0 - c4-pre-sort

2024-04-05T21:16:34Z

raymondfam marked the issue as insufficient quality report

#1 - c4-pre-sort

2024-04-05T21:16:40Z

raymondfam marked the issue as duplicate of #4

#2 - raymondfam

2024-04-05T21:17:07Z

See #4.

#3 - c4-judge

2024-04-12T16:57:54Z

hansfriese marked the issue as unsatisfactory: Out of scope

#4 - serial-coder

2024-04-17T11:36:54Z

Hi @hansfriese,

Appeal

This issue is not a duplicate of #4.

As #4 refers to the missing checks for roundId, price, updateTime, and answeredInRound, which was flagged as a known issue. This issue refers to an incorrect implementation of the base oracle (ETH/USD price) in theΒ oracleCircuitBreaker().

To elaborate, the docs (bullet 5) mentioned that the check: "timeStamp + 2 hours < block.timestamp" (i.e., the 2-hour heartbeat for the base price) must be performed to verify the Chainlink data.

Apparently, the baseOracleCircuitBreaker() correctly checks the base oracle in question (see @2 in the snippet below). However, the sponsor didn't implement this check for the same base oracle in the oracleCircuitBreaker() (see @1).

Hence, this issue is not a duplicate of #4, and it raises another point that is a valid issue because the sponsor implemented the use of base oracle (ETH/USD price) incorrectly (please refer to the docs (bullet 5)).

    //@audit -- This report raises an issue regarding the lack of a stale price check for the base oracle (ETH/USD price) in the oracleCircuitBreaker() only, as the lack of a stale price check for the non-ETH/USD asset oracle (multi-asset oracle) was flagged as a known issue
    function oracleCircuitBreaker(
        uint80 roundId,
        uint80 baseRoundId,
        int256 chainlinkPrice,
        int256 baseChainlinkPrice,
        uint256 timeStamp,
        uint256 baseTimeStamp
    ) private view {
@1      bool invalidFetchData = roundId == 0 || timeStamp == 0 || timeStamp > block.timestamp || chainlinkPrice <= 0
@1          || baseRoundId == 0 || baseTimeStamp == 0 || baseTimeStamp > block.timestamp || baseChainlinkPrice <= 0; //@audit -- The oracleCircuitBreaker() lacks checking the stale price for the base oracle (ETH/USD price) (condition: "block.timestamp > 2 hours + baseTimeStamp")

        if (invalidFetchData) revert Errors.InvalidPrice();
    }

    ...

    function baseOracleCircuitBreaker(
        uint256 protocolPrice,
        uint80 roundId,
        int256 chainlinkPrice,
        uint256 timeStamp,
        uint256 chainlinkPriceInEth
    ) private view returns (uint256 _protocolPrice) {
        bool invalidFetchData = roundId == 0 || timeStamp == 0 || timeStamp > block.timestamp || chainlinkPrice <= 0
@2          || block.timestamp > 2 hours + timeStamp; //@audit -- Whereas the baseOracleCircuitBreaker() already checks that condition as per the docs
        uint256 chainlinkDiff =
            chainlinkPriceInEth > protocolPrice ? chainlinkPriceInEth - protocolPrice : protocolPrice - chainlinkPriceInEth;
        bool priceDeviation = protocolPrice > 0 && chainlinkDiff.div(protocolPrice) > 0.5 ether;

        ...
    }

#5 - ditto-eth

2024-04-19T15:43:45Z

Agree, this seems like a valid issue. Returned base oracle price could be stale.

#6 - c4-sponsor

2024-04-19T15:47:32Z

ditto-eth (sponsor) confirmed

#7 - c4-judge

2024-04-21T08:13:28Z

hansfriese marked the issue as satisfactory

#8 - c4-judge

2024-04-21T08:13:36Z

hansfriese marked the issue as not a duplicate

#9 - c4-judge

2024-04-21T08:14:00Z

hansfriese marked the issue as selected for report

#10 - c4-judge

2024-04-21T08:15:52Z

hansfriese marked the issue as primary issue

#11 - c4-judge

2024-04-21T08:41:44Z

hansfriese marked the issue as not selected for report

#12 - c4-judge

2024-04-21T08:43:58Z

hansfriese marked the issue as duplicate of #164

#13 - c4-judge

2024-04-21T08:44:18Z

hansfriese marked the issue as partial-50

Findings Information

Awards

17.9877 USDC - $17.99

Labels

bug
disagree with severity
grade-b
QA (Quality Assurance)
sponsor confirmed
sufficient quality report
Q-03

External Links

[L-01] Incorrect verification of shorter and shortRecordId in RedemptionFacet::claimRemainingCollateral()

Impact

The RedemptionFacet::claimRemainingCollateral() incorrectly verifies the shorter (msg.sender) and shortRecordId (the inputted id) parameters, which can lead to further exploits or unexpected circumstances.

Proof of Concept

As you can see below, the claimRemainingCollateral() verifies the shorter (msg.sender) and shortRecordId (the inputted id) parameters using the && operator.

Therefore, the function caller can easily bypass the verification by inputting one of the two logical expressions to be false. For example, inputting the id parameter == claimProposal.shortId.

    function claimRemainingCollateral(address asset, address redeemer, uint8 claimIndex, uint8 id)
        external
        isNotFrozen(asset)
        nonReentrant
    {
        STypes.AssetUser storage redeemerAssetUser = s.assetUser[asset][redeemer];
        if (redeemerAssetUser.SSTORE2Pointer == address(0)) revert Errors.InvalidRedemption();
        if (redeemerAssetUser.timeToDispute > LibOrders.getOffsetTime()) revert Errors.TimeToDisputeHasNotElapsed();

        // @dev Only need to read up to the position of the SR to be claimed
        MTypes.ProposalData[] memory decodedProposalData =
            LibBytes.readProposalData(redeemerAssetUser.SSTORE2Pointer, claimIndex + 1);
        MTypes.ProposalData memory claimProposal = decodedProposalData[claimIndex];

        //@audit -- Incorrect conditional logic (must use the "||" operator instead of the "&&" operator)
@>      if (claimProposal.shorter != msg.sender && claimProposal.shortId != id) revert Errors.CanOnlyClaimYourShort();

        STypes.Asset storage Asset = s.asset[asset];
        _claimRemainingCollateral({asset: asset, vault: Asset.vault, shorter: msg.sender, shortId: id});
    }

Tools Used

Manual Review

Use the operator || instead of the &&.

    function claimRemainingCollateral(address asset, address redeemer, uint8 claimIndex, uint8 id)
        external
        isNotFrozen(asset)
        nonReentrant
    {
        STypes.AssetUser storage redeemerAssetUser = s.assetUser[asset][redeemer];
        if (redeemerAssetUser.SSTORE2Pointer == address(0)) revert Errors.InvalidRedemption();
        if (redeemerAssetUser.timeToDispute > LibOrders.getOffsetTime()) revert Errors.TimeToDisputeHasNotElapsed();

        // @dev Only need to read up to the position of the SR to be claimed
        MTypes.ProposalData[] memory decodedProposalData =
            LibBytes.readProposalData(redeemerAssetUser.SSTORE2Pointer, claimIndex + 1);
        MTypes.ProposalData memory claimProposal = decodedProposalData[claimIndex];

-       if (claimProposal.shorter != msg.sender && claimProposal.shortId != id) revert Errors.CanOnlyClaimYourShort();
+       if (claimProposal.shorter != msg.sender || claimProposal.shortId != id) revert Errors.CanOnlyClaimYourShort();

        STypes.Asset storage Asset = s.asset[asset];
        _claimRemainingCollateral({asset: asset, vault: Asset.vault, shorter: msg.sender, shortId: id});
    }

[L-02] Incorrectly executing the updateErcDebt() leads to virtually minting the ercDebt more than necessary

Impact

The ExitShortFacet::exitShort() executes the checkCancelShortOrder() before the updateErcDebt().

Consequently, if the shortRecord is partially filled and has the ercDebt less than the minShortErc threshold, the shortOrder will be canceled and the ercDebt will be virtually minted more than necessary to the shortRecord (to maintain the minShortErc threshold).

Proof of Concept

The exitShort() executes the checkCancelShortOrder() to check whether the shortRecord is partially filled. The checkCancelShortOrder() will cancel the shortOrder if the shortRecord is partially filled.

Then, the exitShort() invokes the updateErcDebt() to update the ercDebt of the shortRecord.

    function exitShort(
        address asset,
        uint8 id,
        uint88 buybackAmount,
        uint80 price,
        uint16[] memory shortHintArray,
        uint16 shortOrderId
    ) external isNotFrozen(asset) nonReentrant onlyValidShortRecord(asset, msg.sender, id) {
        ...

        //@audit -- The checkCancelShortOrder() is executed before the updateErcDebt()
@1      e.shortOrderIsCancelled = LibSRUtil.checkCancelShortOrder({
@1          asset: asset,
@1          initialStatus: short.status,
@1          shortOrderId: shortOrderId,
@1          shortRecordId: id,
@1          shorter: msg.sender
@1      });
@1
@1      short.updateErcDebt(e.asset);

        ...
    }

To cancel the shortOrder, the cancelShort() is triggered. The function will check if the position has the ercDebt less than the minShortErc threshold. If that is the case, the function will spend some of the collateral to virtually mint the ercDebt (to be == minShortErc).

However, since the updateErcDebt() is executed after the shortOrder has already been canceled, the cancelShort() will virtually mint the ercDebt more than necessary.

Note: the updateErcDebt() will increase the ercDebt of the shortRecord. Therefore, if we execute the updateErcDebt() before, the cancelShort() will mint the ercDebt lesser to maintain the minShortErc threshold.

    function cancelShort(address asset, uint16 id) internal {
        ...

        if (shortRecord.status == SR.Closed) {
            ...

        } else {
            uint88 minShortErc = uint88(LibAsset.minShortErc(asset));
            if (shortRecord.ercDebt < minShortErc) {
                // @dev prevents leaving behind a partially filled SR is under minShortErc
                // @dev if the corresponding short is cancelled, then the partially filled SR's debt will == minShortErc
@2              uint88 debtDiff = minShortErc - shortRecord.ercDebt;
                {
                    STypes.Vault storage Vault = s.vault[vault];

@2                  uint88 collateralDiff = shortOrder.price.mulU88(debtDiff).mulU88(LibOrders.convertCR(shortOrder.shortOrderCR));

                    //@audit -- If the shortRecord is partially filled, the shortOrder will be canceled,
                    //          and if shortRecord.ercDebt < minShortErc, the collateral will be used to 
                    //          virtually mint ercDebt (to be == minShortErc) more than expected
                    LibShortRecord.fillShortRecord(
                        asset,
                        shorter,
                        shortRecordId,
                        SR.FullyFilled,
@2                      collateralDiff,
@2                      debtDiff,
                        Asset.ercDebtRate,
                        Vault.dethYieldRate
                    );

@2                  Vault.dethCollateral += collateralDiff;
@2                  Asset.dethCollateral += collateralDiff;
@2                  Asset.ercDebt += debtDiff;

                    // @dev update the eth refund amount
@2                  eth -= collateralDiff;
                }
                // @dev virtually mint the increased debt
@2              s.assetUser[asset][shorter].ercEscrowed += debtDiff;
            } else {
                shortRecord.status = SR.FullyFilled;
            }
        }

        ...
    }

Tools Used

Manual Review

Execute the updateErcDebt() before the checkCancelShortOrder().

    function exitShort(
        address asset,
        uint8 id,
        uint88 buybackAmount,
        uint80 price,
        uint16[] memory shortHintArray,
        uint16 shortOrderId
    ) external isNotFrozen(asset) nonReentrant onlyValidShortRecord(asset, msg.sender, id) {
        ...

+       short.updateErcDebt(e.asset);

        e.shortOrderIsCancelled = LibSRUtil.checkCancelShortOrder({
            asset: asset,
            initialStatus: short.status,
            shortOrderId: shortOrderId,
            shortRecordId: id,
            shorter: msg.sender
        });

-       short.updateErcDebt(e.asset);

        ...
    }

[L-03] The price deviation in LibOracle::baseOracleCircuitBreaker() should not be hardcoded

Impact

The hardcoded value of 50% price deviation (0.5 ether) may be too large when using ETH as a base price reference. Moreover, the fixed % deviation is considered too risky because the protocol's DAO or admin will not be able to update it in production.

Consequently, the check for price deviation in the LibOracle::baseOracleCircuitBreaker() may not be effective enough to filter out the stale price in production, directly affecting the quality of the oracle price that will be consumed by the core functions of the Ditto protocol.

Proof of Concept

The baseOracleCircuitBreaker() verifies the price reported by Chainlink. If the reported price is invalid or its price deviation when compared to the protocol's cached oracle price is more than 50%, the function will fall back to get Uniswap's TWAP price instead.

However, the baseOracleCircuitBreaker() uses a hardcoded value of 50% price deviation (0.5 ether), which may be too large when using ETH as a base price reference. Moreover, the fixed % deviation is considered too risky because the protocol's DAO or admin will not be able to update it in production.

    function baseOracleCircuitBreaker(
        uint256 protocolPrice,
        uint80 roundId,
        int256 chainlinkPrice,
        uint256 timeStamp,
        uint256 chainlinkPriceInEth
    ) private view returns (uint256 _protocolPrice) {
        bool invalidFetchData = roundId == 0 || timeStamp == 0 || timeStamp > block.timestamp || chainlinkPrice <= 0
            || block.timestamp > 2 hours + timeStamp;
        uint256 chainlinkDiff =
            chainlinkPriceInEth > protocolPrice ? chainlinkPriceInEth - protocolPrice : protocolPrice - chainlinkPriceInEth;
@>      bool priceDeviation = protocolPrice > 0 && chainlinkDiff.div(protocolPrice) > 0.5 ether;

        // @dev if there is issue with chainlink, get twap price. Verify twap and compare with chainlink
        if (invalidFetchData) {
            ...

@>      } else if (priceDeviation) {
            ...

        } else {
            ...
        }
    }

Tools Used

Manual Review

The % price deviation should be a variable updatable by the protocol's DAO or admin in production.


[L-04] Inconsistency in calculating the minShortErc in the ShortOrdersFacet::createLimitShort()

Impact

I noticed the inconsistency in calculating the p.minShortErc in the ShortOrdersFacet::createLimitShort() when the cr param < 1 ether and when the param >= 1 ether.

If the cr param < 1 ether, the p.minShortErc has to be modified to be bigger. But, the resulting p.minShortErc will be bigger than expected. Consequently, a shorter must supply the ercAmount more than expected to create a limit shortOrder with the cr < 1 ether.

Proof of Concept

If the cr param < 1 ether, p.minShortErc = LibAsset.minShortErc(asset).mul(1 ether + cr.inv()).

Suppose the cr == 0.5 ether.

p.minShortErc = LibAsset.minShortErc(asset).mul(1 ether + (1/0.5 ether)) = LibAsset.minShortErc(asset).mul(1 ether + 2 ether) = LibAsset.minShortErc(asset).mul(3 ether)

As you can see, the resulting multiplier will be 3 ether instead of 2 ether (compared to the case cr param >= 1 ether).

    function createLimitShort(
        address asset,
        uint80 price,
        uint88 ercAmount,
        MTypes.OrderHint[] memory orderHintArray,
        uint16[] memory shortHintArray,
        uint16 shortOrderCR
    ) external isNotFrozen(asset) onlyValidAsset(asset) nonReentrant {
        ...

        // @dev minShortErc needs to be modified (bigger) when cr < 1
@>      p.minShortErc = cr < 1 ether ? LibAsset.minShortErc(asset).mul(1 ether + cr.inv()) : LibAsset.minShortErc(asset);
        p.eth = price.mul(ercAmount);
        p.minAskEth = LibAsset.minAskEth(asset);
        if (ercAmount < p.minShortErc || p.eth < p.minAskEth) revert Errors.OrderUnderMinimumSize();

        ...
    }

Tools Used

Manual Review

The formula for calculating the p.minShortErc when the cr param < 1 ether should be LibAsset.minShortErc(asset).mul(cr.inv()).

    function createLimitShort(
        address asset,
        uint80 price,
        uint88 ercAmount,
        MTypes.OrderHint[] memory orderHintArray,
        uint16[] memory shortHintArray,
        uint16 shortOrderCR
    ) external isNotFrozen(asset) onlyValidAsset(asset) nonReentrant {
        ...

        // @dev minShortErc needs to be modified (bigger) when cr < 1
-       p.minShortErc = cr < 1 ether ? LibAsset.minShortErc(asset).mul(1 ether + cr.inv()) : LibAsset.minShortErc(asset);
+       p.minShortErc = cr < 1 ether ? LibAsset.minShortErc(asset).mul(cr.inv()) : LibAsset.minShortErc(asset);
        p.eth = price.mul(ercAmount);
        p.minAskEth = LibAsset.minAskEth(asset);
        if (ercAmount < p.minShortErc || p.eth < p.minAskEth) revert Errors.OrderUnderMinimumSize();

        ...
    }

#0 - raymondfam

2024-04-08T01:05:14Z

#1 - c4-pre-sort

2024-04-08T01:05:18Z

raymondfam marked the issue as sufficient quality report

#2 - c4-sponsor

2024-04-09T22:35:09Z

ditto-eth (sponsor) confirmed

#3 - c4-sponsor

2024-04-09T22:35:14Z

ditto-eth marked the issue as disagree with severity

#4 - ditto-eth

2024-04-09T22:35:58Z

this is a pretty big catch. i think L1 can be moved to a medium/high

might be a duplicate of #104

#5 - ditto-eth

2024-04-09T22:41:31Z

l2 can be classified as high. this submission has been the best qa.

might be a duplicate of #134

#6 - c4-judge

2024-04-17T07:25:59Z

hansfriese marked the issue as grade-c

#7 - c4-judge

2024-04-22T13:53:21Z

hansfriese marked the issue as grade-b

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter