DYAD - dimulski's results

The first capital efficient overcollateralized stablecoin.

General Information

Platform: Code4rena

Start Date: 18/04/2024

Pot Size: $36,500 USDC

Total HM: 19

Participants: 183

Period: 7 days

Judge: Koolex

Id: 367

League: ETH

DYAD

Findings Distribution

Researcher Performance

Rank: 12/183

Findings: 8

Award: $683.26

๐ŸŒŸ Selected for report: 1

๐Ÿš€ Solo Findings: 0

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L269-L286

Vulnerability details

Impact

The DYAD protocol is requiring a collateral ratio of 150% for minting DYAD tokens. Each DYAD stablecoin should be backed by at least $1.50 of exogenous collateral. This surplus is intended to absorb the collateralโ€™s volatility, in order to keep DYAD fully backed in all conditions. However the collateral ratio restrictions are easily bypassed because of the way the collateral ratio is calculated. As per the deploy script the same wstETH and WETH backed vaults will be utilized in the getNonKeroseneValue() and getKeroseneValue() functions (the user have to first add them to the lists of his approved vaults). Also this is the way the protocol have decided to calculate the value of the Kerosene token. As per the docs: Kerosene is a token that lets you mint DYAD against this collateral surplus. Kerosene can be deposited in Notes just like other collateral to increase the Noteโ€™s DYAD minting capacity. However a user can just add the vaults to both the vaults and vaultsKerosene lists. And thus doubling his collateral ratio, bypassing the protocol collateral ratio restrictions, and minting dyad tokens at 1:1 ratio with the deposited collateral. If the dollar value of the collateral decreases slightly, the protocol will go underwater easily if enough such positions exists. Also when it comes to liquidation, the collateral ratio check will be reverting until the provided collateral is well below the dollar value of the minted dyad.

Proof of Concept

Gist

After following the steps in the above linked gist add the following test to AuditorTests.t.sol file:

    function test_DoubleCollateral() public {
        vm.startPrank(alice);
        WETH.mint(alice, 10e18);
        console2.log("Balance of weth vault: ", WETH.balanceOf(address(vault)));
        dNft.mintNft(alice);
        console2.log("Owner of 0 id NFT: ", dNft.ownerOf(0));
        vaultManagerV2.add(0, address(vault));
        vaultManagerV2.add(0, address(vaultWstEth));
        vaultManagerV2.add(0, address(unboundedKerosineVault));
        WETH.approve(address(vaultManagerV2), type(uint256).max);
        vaultManagerV2.deposit(0, address(vault), 1e18);
        oracleWETH.setPrice(int256(150e8));
        vaultManagerV2.mintDyad(0, 100e18, alice);
        console2.log("Alice's note collateral ratio: ", vaultManagerV2.collatRatio(0));
        vaultManagerV2.addKerosene(0, address(vault));
        vaultManagerV2.addKerosene(0, address(vaultWstEth));
        console2.log("Alice's note collateral ratio after she adds the vaults for calculating the kerosene value: ", vaultManagerV2.collatRatio(0));
        vm.stopPrank();
    }
Logs:
  Balance of weth vault:  0
  Owner of 0 id NFT:  0x328809Bc894f92807417D2dAD6b7C998c1aFdac6
  Alice's note collateral ratio:  1500000000000000000
  Alice's note collateral ratio after she adds the vaults for calculating the kerosene value:  3000000000000000000

To run the test use: forge test -vvv --mt test_DoubleCollateral

Tools Used

Manual review and Foundry

Consider implementing internal accounting to track the price of kerosine, don't add vaults, and definitely don't add the same vaults.

Assessed type

Context

#0 - c4-pre-sort

2024-04-29T07:30:34Z

JustDravee marked the issue as duplicate of #966

#1 - c4-pre-sort

2024-04-29T08:37:55Z

JustDravee marked the issue as sufficient quality report

#2 - c4-judge

2024-05-04T09:46:29Z

koolexcrypto marked the issue as unsatisfactory: Invalid

#3 - c4-judge

2024-05-28T15:28:31Z

koolexcrypto marked the issue as duplicate of #1133

#4 - c4-judge

2024-05-29T07:06:55Z

koolexcrypto marked the issue as satisfactory

Findings Information

Awards

200.8376 USDC - $200.84

Labels

bug
3 (High Risk)
satisfactory
sufficient quality report
upgraded by judge
edited-by-warden
:robot:_11_group
duplicate-1097

External Links

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L205-L228

Vulnerability details

Impact

There are no restrictions on the maximum collateral amount a user can deposit into a single Note, and the amount of DYAD tokens he can mint. The liquidate() function requires all of the DYAD tokens, a Note has minted to be burned in a single transaction as can be seen from this line:

dyad.burn(id, msg.sender, dyad.mintedDyad(address(this), id));

Typically liquidators are bots that monitor protocols and liquidate undercollateralized positions if there is profit to be made. They don't hold the specific funds that will be required to be transferred to the protocol, or burned in order to execute a potentially profitable liquidation. They rely on flash loans, and most protocols providing flash loan functionality, require users to pay back the loan in the same token they received when taking the flash loan + some fee, paid in the same token. There are no DYAD markets on AAVE or Compound, so a liquidator can't take a big enough loan of DYAD tokens in order to liquidate an undercollateralized position. Thus the liquidator will have to acquire the DYAD tokens required to be burned in a different way. At the time of writing this report there are no DYAD pools on Curve. There is a Uniswap V2 ETH/DYAD pool which contains 31714921551045792676107 โ‰ˆ 31_714e18 DYAD tokens, as well as Uniswap V3 USDC/DYAD pool which contains โ‰ˆ 336_153e18 DYAD tokens. There are around 370_000e18 DYAD tokens supplied as liquidity on DEXes. However the slippage on the Uniswap V2 pool will be extremely big. As for Uniswap v3 pools, tokens are converted to the other token, when the current price, is not in the price range the liquidity was provided for. So we can assume that a liquidator can make a swap of 150_000 - 200_000 DYAD tokens max. To create a position of $150_000 - $200_000 DYAD tokens with a collateralization rate of 150%, a user would have to provide $225_000 - $300_000 worth of collateral, to satisfy the collateral ratio requirement. The deposti() function allows for anyone to provide collateral for a note, and the mintDyad() function allows the owner of the Note to mint DYAD tokens to an arbitrary address. Many users not wanting to buy a Note NFT, which price starts at 0.1e18 ETH and increase linearly on each new minted NFT with 0.001e18ETH, may opt in for a single Note, and provide collateral in it, thus creating one big position, split between different users. Due to the implementation of the liquidate() function all of the DYAD of the undercollateralized position have to be burned in one transaction. It will take 1 or 2 big positions, in the range of 150_000 - 200_000 DYAD tokens, to go below the collateralization ratio, and at the time of writing this report there isn't a way to get this positions liquidated. The whole protocol can go underwater very quickly.

Tools Used

Manual review

Either provide a maximum amount of DYAD tokens that a single Note can mint, or allow undercollateralized positions to be liquidated on parts.

Assessed type

Context

#0 - c4-pre-sort

2024-04-28T10:12:22Z

JustDravee marked the issue as duplicate of #1097

#1 - c4-pre-sort

2024-04-29T08:34:28Z

JustDravee marked the issue as sufficient quality report

#2 - c4-judge

2024-05-11T12:22:01Z

koolexcrypto marked the issue as satisfactory

#3 - c4-judge

2024-05-13T18:38:27Z

koolexcrypto changed the severity to 3 (High Risk)

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L134-L153 https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L119-L131

Vulnerability details

Impact

The withdraw() function in implements the following check:

if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock()

not allowing withdraws in the same block, in which someone has already deposited into a so called Note. As can be seen from the deposit() function:

  function deposit(uint id, address vault, uint amount) external isValidDNft(id) {
    idToBlockOfLastDeposit[id] = block.number;
    Vault _vault = Vault(vault);
    _vault.asset().safeTransferFrom(msg.sender, address(vault), amount);
    _vault.deposit(id, amount);
  }

There are no minimum deposits required, so a malicious actor can frontrun the Note owner call to withdraw(), deposit as little as 1 WEI and the withdraw function will revert, thus griefing the Note owner. A malicious actor can do this as many times as he wants, and the Note owner won't be able to withdraw his deposited tokens. In a turbulent market conditions (which are a typical occurrence in crypto), a Note owner may have decided that he would like to convert his crypto assets to fiat currency, already burned all of his minted DYAD tokens in order to free all of his collateral. However as he is trying to withdraw all of his deposited collateral, a malicious actor can grief him as described above, while the deposited collateral falls in value. Thus resulting in immense losses for the owner of the Note. The same griefing attack can be performed when a user is trying to remove a vault from his vaults, or vaultsKerosene lists. For example he has liquidated an undercollateralized position of somebody, he already has 5 vaults in his vaults list but a big portion of the collateral he received as a reward are in a vault he hasn't added to his vaults list. In order to withdrawn them he will have to remove one vault and add another, however a malicious user can keep depositing 1 WEI, thus restricting this functionality. Considering when a positions gets liquidated, it is typically because the collateral asset fell in dollar value, and is probably going to continue falling in price due to some turbulent market conditions, this may result in a big loss for users.

Tools Used

Manual review

Consider allowing only the DNFT Owner to be able to deposit assets via the deposit() function, set the modifier from isValidDNft(id) to isDNftOwner(id)

Assessed type

DoS

#0 - c4-pre-sort

2024-04-27T12:01:38Z

JustDravee marked the issue as duplicate of #489

#1 - c4-pre-sort

2024-04-29T09:28:34Z

JustDravee marked the issue as sufficient quality report

#2 - c4-judge

2024-05-05T20:39:26Z

koolexcrypto marked the issue as unsatisfactory: Invalid

#3 - c4-judge

2024-05-05T20:45:35Z

koolexcrypto marked the issue as unsatisfactory: Invalid

#4 - c4-judge

2024-05-05T21:49:18Z

koolexcrypto marked the issue as nullified

#5 - c4-judge

2024-05-05T21:49:20Z

koolexcrypto marked the issue as not nullified

#6 - c4-judge

2024-05-08T15:26:43Z

koolexcrypto marked the issue as duplicate of #1001

#7 - c4-judge

2024-05-11T19:49:15Z

koolexcrypto marked the issue as satisfactory

#8 - c4-judge

2024-05-13T18:34:30Z

koolexcrypto changed the severity to 3 (High Risk)

Awards

3.8221 USDC - $3.82

Labels

bug
3 (High Risk)
satisfactory
sufficient quality report
edited-by-warden
:robot:_28_group
duplicate-830

External Links

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L134-L153

Vulnerability details

Impact

The DYAD protocol supports two different Kerosine vaults - bounded and unbounded. Users who deposit into the unbounded vault should be able to withdraw their kerosene tokens if the collateral ratio criteria are met. Withdrawing of deposited tokens happens via the withdraw() function.

  function withdraw(uint id, address vault, uint amount, address to) public isDNftOwner(id) {
    if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock();
    uint dyadMinted = dyad.mintedDyad(address(this), id);
    Vault _vault = Vault(vault);
    uint value = amount * _vault.assetPrice() * 1e18 / 10**_vault.oracle().decimals() / 10**_vault.asset().decimals();
    if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat();
    _vault.withdraw(id, to, amount);
    if (collatRatio(id) < MIN_COLLATERIZATION_RATIO)  revert CrTooLow(); 
  }

As can be seen from the code snippet above when the value variable is calculated the contract tries to call oracle().decimals() on the corresponding vault, however there is no oracle variable in the Vault.kerosine.unbounded.sol contract and the transaction will revert, locking the deposited kerosine tokens forever.

Proof of Concept

Gist After following the steps in the above linked gist add the following test to AuditorTests.t.sol:

    function test_WithdrawKerosineNotWorking() public {
        vm.startPrank(owner);
        console2.log("Kerosene balance of owner normalized: ", kerosine.balanceOf(owner) / 1e18);
        kerosine.transfer(alice, 10_000e18);
        console2.log("Kerosene balance of alice normalized: ", kerosine.balanceOf(alice) / 1e18);
        vm.stopPrank();

        vm.startPrank(alice);
        WETH.mint(alice, 10e18);
        deal(alice, 1e18);
        console2.log("Balance of weth vault: ", WETH.balanceOf(address(vault)));
        dNft.mintNft(alice);
        console2.log("Owner of 0 id NFT: ", dNft.ownerOf(0));
        vaultManagerV2.add(0, address(vault));
        vaultManagerV2.add(0, address(vaultWstEth));
        vaultManagerV2.add(0, address(unboundedKerosineVault));
        vaultManagerV2.addKerosene(0, address(vault));
        vaultManagerV2.addKerosene(0, address(vaultWstEth));
        console2.log("Alice deposited kerosine in the unbounded vault: ", unboundedKerosineVault.id2asset(0));
        kerosine.approve(address(vaultManagerV2), type(uint256).max);
        vaultManagerV2.deposit(0, address(unboundedKerosineVault), 10_000e18);
        console2.log("Alice deposited kerosine in the unbounded vault normalized : ", unboundedKerosineVault.id2asset(0) / 1e18);
        console2.log("Alice note minted dyad: ", dyad.mintedDyad(address(vaultManagerV2), 0));
        vm.roll(100);
        vm.expectRevert();
        vaultManagerV2.withdraw(0, address(unboundedKerosineVault), 10_000e18, alice);
        vm.stopPrank();
    }
Logs:
  Kerosene balance of owner normalized:  1000000000
  Kerosene balance of alice normalized:  10000
  Balance of weth vault:  0
  Owner of 0 id NFT:  0x328809Bc894f92807417D2dAD6b7C998c1aFdac6
  Alice deposited kerosine in the unbounded vault:  0
  Alice deposited kerosine in the unbounded vault normalized :  10000
  Alice note minted dyad:  0

To run the test use: forge test -vvv --mt test_WithdrawKerosineNotWorking

Tools Used

Manual review & Foundry

Consider adding some identifier to the unbounded kerosine vault, and implement a separate withdraw function, or in the current withdraw function check if the vault the user is trying to withdraw from is a kerosine vault, and if so calculate the price accordingly. For example:

if(vaultKerosineUnbounded) {
   value = amount * _vault.assetPrice() / 1e8;
}

Assessed type

Context

#0 - c4-pre-sort

2024-04-26T21:06:38Z

JustDravee marked the issue as duplicate of #1048

#1 - c4-pre-sort

2024-04-28T18:38:23Z

JustDravee marked the issue as duplicate of #830

#2 - c4-pre-sort

2024-04-29T08:44:26Z

JustDravee marked the issue as sufficient quality report

#3 - c4-judge

2024-05-11T20:05:51Z

koolexcrypto marked the issue as satisfactory

Awards

3.8221 USDC - $3.82

Labels

bug
3 (High Risk)
satisfactory
sufficient quality report
upgraded by judge
edited-by-warden
:robot:_52_group
duplicate-308

External Links

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/main/script/deploy/Deploy.V2.s.sol#L71-L76

Vulnerability details

Impact

As per the readme of the contest, the Deploy.V2.s.sol contract is in scope, and in this file the steps to migrate to the new VaultManagerV2.sol are defined. However the protocol team has neglected the fact that there is already a circulating supply of DYAD tokens. As of writing the report the total supply is: 632_967_400_000_000_000_000_000 โ‰ˆ 632_967e18. And when the Unbounded Kerosine Vault is deployed the address of the already existing DYAD token is set.

    UnboundedKerosineVault unboundedKerosineVault = new UnboundedKerosineVault(
      vaultManager,
      Kerosine(MAINNET_KEROSENE), 
      Dyad(MAINNET_DYAD),
      kerosineManager
    );

In Vault.kerosine.unbounded.sol the asset price is calculated in the following way:

  function assetPrice() public view override returns (uint) {
      uint tvl;
      address[] memory vaults = kerosineManager.getVaults();
      uint numberOfVaults = vaults.length;
      for (uint i = 0; i < numberOfVaults; i++) {
        Vault vault = Vault(vaults[i]);
        tvl += vault.asset().balanceOf(address(vault)) 
                * vault.assetPrice() * 1e18
                / (10**vault.asset().decimals()) 
                / (10**vault.oracle().decimals());
      }
      uint numerator = tvl - dyad.totalSupply();
      uint denominator = kerosineDenominator.denominator();
      return numerator * 1e8 / denominator;
  }

As we can see the DYAD token total supply is subtracted from the tvl, and later on some addition calculations are performed. However in the deployment script new wethVault and wstETHVault are created with 0 funds, and added to the kerosineManager. When a function calls the assetPrice() function it will revert as the TVL will initially be 0, and the DYAD token supply is approximately 632_967e18. Functions like withdraw(), liquidate(), redeemDyad(), mintDyad() will always revert if the Note owner, has added the unbounded kerosine vault to his list of vaults, no matter if he has deposited funds in it or not. It will require at least $632_967 worth of collateral to be deposited to the vaults, before any new DYAD can be minted.

Tools Used

Manual review

Use the already deployed vaults, where the collateral is provided

Assessed type

Context

#0 - c4-pre-sort

2024-04-27T18:34:09Z

JustDravee marked the issue as duplicate of #958

#1 - c4-pre-sort

2024-04-29T08:39:28Z

JustDravee marked the issue as sufficient quality report

#2 - c4-judge

2024-05-05T13:48:34Z

koolexcrypto marked the issue as duplicate of #308

#3 - c4-judge

2024-05-11T20:08:43Z

koolexcrypto marked the issue as satisfactory

#4 - c4-judge

2024-05-13T18:34:04Z

koolexcrypto changed the severity to 3 (High Risk)

Findings Information

๐ŸŒŸ Selected for report: alix40

Also found by: Giorgio, dimulski, eta, ljj, zhaojohnson

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
sufficient quality report
:robot:_11_group
duplicate-343

Awards

460.7972 USDC - $460.80

External Links

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L205-L228

Vulnerability details

Impact

The liquidation mechanism is not optimal leading to a couple of detrimental issues for the protocol. In order for liquidation to be executed on time, so the protocol is always well collateralized(above 150%) and the chances of going underwater are slim, usually bots are the ones who look for potential profit when executing liquidations on different protocols. However the design of the liquidate() function, will deter bots of liquidating, medium positions. Possible only very big positions will be profitable enough for bots >$20_000

  function liquidate(uint id, uint to) external isValidDNft(id) isValidDNft(to) {
      uint cr = collatRatio(id);
      if (cr >= MIN_COLLATERIZATION_RATIO) revert CrTooHigh();
      dyad.burn(id, msg.sender, dyad.mintedDyad(address(this), id));

      uint cappedCr = cr < 1e18 ? 1e18 : cr;
      uint liquidationEquityShare = (cappedCr - 1e18).mulWadDown(LIQUIDATION_REWARD);
      uint liquidationAssetShare = (liquidationEquityShare + 1e18).divWadDown(cappedCr);

      uint numberOfVaults = vaults[id].length();
      for (uint i = 0; i < numberOfVaults; i++) {
          Vault vault = Vault(vaults[id].at(i));
          uint collateral = vault.id2asset(id).mulWadUp(liquidationAssetShare);
          vault.move(id, to, collateral);
      }
      emit Liquidate(id, msg.sender, to);
  }

As can be seen from the above code snippet, in order for an undercollateralized position to be liquidated, the liquidator have to have a Note. The price to mint a Note or a DNft is calculated in the following way:

uint price = START_PRICE + (PRICE_INCREASE * publicMints++);

The START_PRICE is 100000000000000000 โ‰ˆ 0.1ETH and the PRICE_INCREASE is 1000000000000000 โ‰ˆ 0.001ETH, this parameters are immutable. At the time of writing this report there are 531 publicMints. Meaning that in order for a bot to be able to liquidate a transaction he will first have to mint a Note costing at least 0.1 + (0.001 * 531) = 0.631ETH transaction costs excluded. This is worth around $2000. So the 20% liquidation bonus that the liquidator received should amount to at least $2000 (not including gas, swapping fees, and slippage). Most liquidation bots, hold their assets in stable coins such as USDC and USDC, and swap to the required collateral, liquidate the position, and then swap back to a stable asset. However there is another problem with the liquidate() function. Bounded Kerosine can't be withdrawn from a vault, so liquidators will be stuck with an asset that they can't withdraw and convert to a stable asset. Assuming more that 10% - 15% of the undercollateralized position is in bounded kerosine, this will further deter liquidation bots. This is extremely bad for the protocol, and the possibility of the whole protocol going underwater in a slightly turbulent market is huge. It is true that undercollateralized positions can be liquidated by other normal participants in the DYAD ecosystem, however this won't happen fast enough, and still people who don't want to hold unbounded kerosine, will receive it as part of the liquidation reward. According to Dune, the 8th biggest position is worth 25_776e18 DYAD tokens, the 9th is 13_000e18 DYAD tokens, this means more than 90% of all positions most probably won't be liquidated on time, which can drive the protocol underwater extremely fast.

Tools Used

Manual review

Consider a way to directly send the collateral to the address of the liquidator, don't require a Note. Dealing with not sending bounded kerosine to the liquidator is trickier. Consider the implementation of a mechanism that will allow people to swap it directly for some asset such as DYAD for example.

Assessed type

Context

#0 - c4-pre-sort

2024-04-28T17:24:03Z

JustDravee marked the issue as duplicate of #456

#1 - c4-pre-sort

2024-04-29T09:31:20Z

JustDravee marked the issue as sufficient quality report

#2 - c4-judge

2024-05-12T08:54:50Z

koolexcrypto marked the issue as satisfactory

#3 - c4-judge

2024-05-12T09:11:59Z

koolexcrypto marked the issue as unsatisfactory: Insufficient quality

#4 - AtanasDimulski

2024-05-16T06:40:37Z

Hi @koolexcrypto, Please re-check this finding as well as all other dups of 456, most of the duplicates raise valid concerns, and simply marking them unsatisfactory due to lack of proof, or stating they are of insufficient quality is incorrect. In my issue, I have raised a valid concern on why positions won't be liquidated on time, as the whole liquidation process is extremely inefficient. The reason I have added the statement that bounded kerosine won't be of any value to the liquidator is because of the DeployV2.s.sol file, which is in scope.

#5 - koolexcrypto

2024-05-21T11:22:32Z

Hi @AtanasDimulski

I appreciate your input on this.

The issue to me still invalid since It's a design issue, The change suggested in the recommendation is major and the implication is, changing the protocol at a design level.

Here are some examples as well making the issue invalid.

At the time of writing this report there are 531 publicMints. Meaning that in order for a bot to be able to liquidate a transaction he will first have to mint a Note costing at least 0.1 + (0.001 * 531) = 0.631ETH transaction costs excluded

NFTs are out of scope.

However there is another problem with the liquidate() function. Bounded Kerosine can't be withdrawn from a vault, so liquidators will be stuck with an asset that they can't withdraw and convert to a stable asset

No sufficient proof provided on this.

#6 - AtanasDimulski

2024-05-21T12:19:30Z

Hello, @koolexcrypto Thanks for your feedback, for your first point it is true that the NFT contract is out of scope, but the issue arises from the VaultManagerV2 contract which is in scope. If we consider that the NFT contract is fully out of scope, then all of the issues in this contest are invalid, as you can't interact with the VaultManagerV2 in any other way. I believe the first point is enough to make the whole liquidation process extremely inefficient, as no bot will spend so much money to liquidate a position, as they will lose money in the process. Thus the collateralization ratio of 150% won't be upheld, and the protocol can easily go underwater. No intentions of the protocol to manually liquidate all positions were disclosed prior to the audit (however this would be inefficient as well). As of now the price of ETH is 3_771$ using the above number of NFT mints, it will cost 2405,78$ (excluding all other costs, buying the NFT, selling it, acquiring the dyad, executing the liquidation, and then converting the collateral back to a stable asset). So the 20% liquidation bonus has to cover 2405,78$. Based on the current collateral ratio, and percent calculations only this statement proves that positions that are worth less than 12_000$ won't be liquidated on time, as bots won't be incentivized ( which is more than 90% of the current positions). Keep in mind the cost of acquiring an NFT will only grow in time, as more people start to use the protocol, and buy NFTs. As for the second point it is stated in the documentation that bounded kerosine can't be withdrawn from a vault + my report is based on the deploy script. If we suppose that the correct working of the liquidation is described in 128, my issue only amplifies the impact. I find it hard to believe that 128 is considered in scope, and this one isn't.

#7 - c4-judge

2024-05-28T16:04:12Z

koolexcrypto marked the issue as duplicate of #977

#8 - c4-judge

2024-05-28T16:20:19Z

koolexcrypto changed the severity to 2 (Med Risk)

#9 - AtanasDimulski

2024-05-28T18:07:24Z

Hey @koolexcrypto, I am sorry for sounding like a broken record, but my issue is not a dup of #977, based on the primary issues you have selected as of now, my issue is closest to #343. However again based on the primary issues selected as of now I believe mine is a solo high, as there is no primary issue that talks about my first point of the issue. Can you please check again? Thanks for your time.

#10 - InfectedIsm

2024-05-28T18:52:07Z

So the 20% liquidation bonus has to cover 2405,78$. Based on the current collateral ratio, and percent calculations only this statement proves that positions that are worth less than 12_000$ won't be liquidated on time. Based on the current collateral ratio, and percent calculations only this statement proves that positions that are worth less than 12_000$ won't be liquidated on time, as bots won't be incentivized ( which is more than 90% of the current positions)

This is simply not true, a Note do not need to be "reimbursed" from only one liquidation, it can be done over time from multiple liquidations. Sure, this is not ideal, but it does not make liquidations unprofitable, only longer to be profitable.

But I agree with the fact that this is not a dup of #977

#11 - AtanasDimulski

2024-05-28T19:03:08Z

So the 20% liquidation bonus has to cover 2405,78$. Based on the current collateral ratio, and percent calculations only this statement proves that positions that are worth less than 12_000$ won't be liquidated on time. Based on the current collateral ratio, and percent calculations only this statement proves that positions that are worth less than 12_000$ won't be liquidated on time, as bots won't be incentivized ( which is more than 90% of the current positions)

This is simply not true, a Note do not need to be "reimbursed" from only one liquidation, it can be done over time from multiple liquidations. Sure, this is not ideal, but it does not make liquidations unprofitable, only longer to be profitable.

But I agree with the fact that this is not a dup of #977

So in your words then liquidation bots should invest in notes and hold them in order to liquidate positions? Can you please provide me with a current bot implementation that does that? Please check the whole report, don't just quote certain parts of a comment.

#12 - InfectedIsm

2024-05-28T19:13:52Z

So the 20% liquidation bonus has to cover 2405,78$. Based on the current collateral ratio, and percent calculations only this statement proves that positions that are worth less than 12_000$ won't be liquidated on time. Based on the current collateral ratio, and percent calculations only this statement proves that positions that are worth less than 12_000$ won't be liquidated on time, as bots won't be incentivized ( which is more than 90% of the current positions)

This is simply not true, a Note do not need to be "reimbursed" from only one liquidation, it can be done over time from multiple liquidations. Sure, this is not ideal, but it does not make liquidations unprofitable, only longer to be profitable. But I agree with the fact that this is not a dup of #977

So in your words then liquidation bots should invest in notes and hold them in order to liquidate positions? Can you please provide me with a current bot implementation that does that? Please check the whole report, don't just quote certain parts of a comment.

I'm not aware of every existing implementation of liquidations. Just saying that your statement is wrong: liquidations become profitable once the initial cost of acquiring a Note has been reimbursed through the first liquidations. Once its done, this become only profit.

One could also argue that because Note price only go up, the liquidation bot can sell its Note whenever he wants, automatically reimbursing its acquisition cost, and keep the profit from liquidations.

I completely agree with the fact that this implementation is questionable in term of incentive, and kudos for bringing that up. But again, my point was to correct your statement about the profitability of a liquidator.

#13 - AtanasDimulski

2024-05-28T19:30:55Z

@InfectedIsm In my report by liquidator I mean a liquidator bot, not just a person who already owns a note, and I have stated that clearly. NFTs are sold on market places, and as I have mentioned all the bots calculate the cost of the whole operation before they try to liquidate a position, they don't gamble whether in 100 blocks someone will buy their NFT for a 100 more dollars. If a bot can't sell the NFT in the same transaction he liquidated a position, he won't liquidate. Thus my argument about the note having to be reimbursed from one liquidation is valid and this severely increases the chances of the protocol going underwater. I will refrain from further discussions, unless the judge requires additional input.

#14 - c4-judge

2024-05-29T07:03:07Z

koolexcrypto marked the issue as satisfactory

#15 - c4-judge

2024-05-29T07:37:53Z

koolexcrypto marked the issue as not a duplicate

#16 - koolexcrypto

2024-05-29T07:46:14Z

Thanks everyone for your feedback.

This is still invalid except that fact that it mentions a problem with not withdrawing bounded kerosene for liquidators.

However there is another problem with the liquidate() function. Bounded Kerosine can't be withdrawn from a vault, so liquidators will be stuck with an asset that they can't withdraw and convert to a stable asset

Therefore, it is a dup of #343 with a partial credit since the main issue is entirely focused on the point and it is way more comprehensive.

#17 - c4-judge

2024-05-29T07:46:49Z

koolexcrypto removed the grade

#18 - c4-judge

2024-05-29T07:47:02Z

koolexcrypto marked the issue as duplicate of #343

#19 - c4-judge

2024-05-29T11:23:30Z

koolexcrypto marked the issue as satisfactory

Awards

6.3334 USDC - $6.33

Labels

bug
2 (Med Risk)
primary issue
satisfactory
selected for report
sufficient quality report
edited-by-warden
:robot:_11_group
M-05

External Links

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L205-L228

Vulnerability details

Impact

The DYAD protocol allows users to deposit as little as 1 WEI via the deposit() function, however in order to mint the DYAD token the protocol requires user to have a collateral ratio of 150% or above. Liquidators liquidate users for the profit they can make. Currently the DYAD protocol awards the value of the DYAD token burned(1 DYAD token is always equal to 1$ when calculated in the liquidate function) + 20% of the collateral left to the liquidator.

  function liquidate(uint id, uint to) external isValidDNft(id) isValidDNft(to) {
      uint cr = collatRatio(id);
      if (cr >= MIN_COLLATERIZATION_RATIO) revert CrTooHigh();
      dyad.burn(id, msg.sender, dyad.mintedDyad(address(this), id));

      uint cappedCr = cr < 1e18 ? 1e18 : cr;
      uint liquidationEquityShare = (cappedCr - 1e18).mulWadDown(LIQUIDATION_REWARD);
      uint liquidationAssetShare = (liquidationEquityShare + 1e18).divWadDown(cappedCr);

      uint numberOfVaults = vaults[id].length();
      for (uint i = 0; i < numberOfVaults; i++) {
          Vault vault = Vault(vaults[id].at(i));
          uint collateral = vault.id2asset(id).mulWadUp(liquidationAssetShare);
          vault.move(id, to, collateral);
      }
      emit Liquidate(id, msg.sender, to);
  }

If there is no profit to be made than there will be no one to call the liquidate function. Consider the following example:

  • User A deposits collateral worth 75$, and mint 50 DYAD tokens equal to 50$. The collateral ratio is 75/50 = 150%.
  • The price of the provided collateral drops, and now user A collateral is worth 70$, 70/50 = 140% collateral ratio. The position should be liquidated now, so the protocol doesn't become insolvent.
  • We get the following calculation:
    • cr & cappedCr = 1.4e18
    • liquidationEquityShare = (1.4e18 - 1e18) * 0.2e18 = 80000000000000000 = 0.08e18
    • liquidationAssetShare = (0.08e18 + 1e18) / 1.4e18 = 771428571428571428 โ‰ˆ 0.77e18
  • The total amount of collateral the liquidator will receive is 70 * 0.77 = 53.9$
  • The dollar amount he spent for the DYAD token is 50$ (assuming no depegs) so the profit for the liquidator will be 53.9$ - 50$ = 3.9$

The protocol will be deployed on Ethereum where gas is expensive. Because the reward the liquidator will receive is low, after gas costs taking into account that most liquidators are bots, and they will have to acquire the DYAD token on an exchange, experience some slippage, swapping fees, and additional gas cost, the total cost to liquidate small positions outweighs the potential profit of liquidators. In the end these low value accounts will never get liquidated, leaving the protocol with bad debt and can even cause the protocol to go underwater. Depending on the gas prices at the time of liquidation (liquidity at DEXes can also be taken into account, as less liquidity leads to bigger slippage) positions in the range of 150$ - 200$ can be unprofitable for liquidators. This attack can be beneficial to a whale, large competitor, or a group of people actively working together to bring down the stable coin. The incentive for them is there if they have shorted the DYAD token with substantial amount of money. The short gains will outweigh the losses they incur by opening said positions to grief the protocol. Also this is crypto, there have been numerous instances of prices drooping rapidly, and usually at that time gas prices are much higher compared to normal market conditions. NOTE: The liquidation mechanism is extremely inefficient, as it requires bots to have a Note in order to be able to liquidate a position, however this is a separate issue, as even the inefficiency of the liquidation mechanism is fixed, small positions still won't be profitable enough for liquidators.

Tools Used

Manual review

Consider setting a minimum amount that users have to deposit before they can mint DYAD stable coin. Minimum amount of 500-600$ should be good enough.

Assessed type

Context

#0 - c4-pre-sort

2024-04-27T17:35:29Z

JustDravee marked the issue as duplicate of #1258

#1 - c4-pre-sort

2024-04-29T09:21:16Z

JustDravee marked the issue as sufficient quality report

#2 - c4-judge

2024-05-03T14:07:47Z

koolexcrypto changed the severity to QA (Quality Assurance)

#3 - c4-judge

2024-05-12T09:47:11Z

koolexcrypto marked the issue as grade-c

#4 - c4-judge

2024-05-22T14:26:07Z

This previously downgraded issue has been upgraded by koolexcrypto

#5 - c4-judge

2024-05-28T16:53:22Z

koolexcrypto marked the issue as satisfactory

#6 - c4-judge

2024-05-28T20:05:11Z

koolexcrypto marked the issue as not a duplicate

#7 - c4-judge

2024-05-28T20:05:16Z

koolexcrypto marked the issue as primary issue

#8 - c4-judge

2024-05-28T20:06:53Z

koolexcrypto marked the issue as selected for report

Awards

7.3512 USDC - $7.35

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
duplicate-118

External Links

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L134-L153 https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L119-L131

Vulnerability details

Impact

The withdraw() function in implements the following check:

if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock()

not allowing withdraws in the same block, in which someone has already deposited into a so called Note. As can be seen from the deposit() function:

  function deposit(uint id, address vault, uint amount) external isValidDNft(id) {
    idToBlockOfLastDeposit[id] = block.number;
    Vault _vault = Vault(vault);
    _vault.asset().safeTransferFrom(msg.sender, address(vault), amount);
    _vault.deposit(id, amount);
  }

There are no minimum deposits required, so a malicious actor can frontrun the Note owner call to withdraw(), deposit as little as 1 WEI and the withdraw function will revert, thus griefing the Note owner. A malicious actor can do this as many times as he wants, and the Note owner won't be able to withdraw his deposited tokens. In a turbulent market conditions (which are a typical occurrence in crypto), a Note owner may have decided that he would like to convert his crypto assets to fiat currency, already burned all of his minted DYAD tokens in order to free all of his collateral. However as he is trying to withdraw all of his deposited collateral, a malicious actor can grief him as described above, while the deposited collateral falls in value. Thus resulting in immense losses for the owner of the Note. The same griefing attack can be performed when a user is trying to remove a vault from his vaults, or vaultsKerosene lists. For example he has liquidated an undercollateralized position of somebody, he already has 5 vaults in his vaults list but a big portion of the collateral he received as a reward are in a vault he hasn't added to his vaults list. In order to withdrawn them he will have to remove one vault and add another, however a malicious user can keep depositing 1 WEI, thus restricting this functionality. Considering when a positions gets liquidated, it is typically because the collateral asset fell in dollar value, and is probably going to continue falling in price due to some turbulent market conditions, this may result in a big loss for users.

Tools Used

Manual review

Consider allowing only the DNFT Owner to be able to deposit assets via the deposit() function, set the modifier from isValidDNft(id) to isDNftOwner(id)

Assessed type

DoS

#0 - thebrittfactor

2024-05-29T13:34:26Z

For transparency, the judge has requested that issue #177ย be duplicated, as it contains two issues they deemed should be judged separately.

#1 - c4-judge

2024-05-29T13:49:29Z

koolexcrypto marked the issue as duplicate of #118

#2 - c4-judge

2024-05-29T13:49:33Z

koolexcrypto marked the issue as satisfactory

#3 - koolexcrypto

2024-05-29T13:49:47Z

split from #177

#4 - c4-judge

2024-05-29T13:55:32Z

koolexcrypto changed the severity to 2 (Med Risk)

AuditHub

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

Built bymalatrax ยฉ 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter