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
Rank: 12/183
Findings: 8
Award: $683.26
๐ Selected for report: 1
๐ Solo Findings: 0
๐ Selected for report: Maroutis
Also found by: 0x486776, 0xShitgem, 0xabhay, 0xleadwizard, 0xlemon, 0xnilay, 0xtankr, 3docSec, AM, Aamir, Abdessamed, Al-Qa-qa, AlexCzm, Circolors, CodeWasp, Daniel526, Egis_Security, Emmanuel, Giorgio, Honour, Hueber, Infect3d, Krace, KupiaSec, LeoGold, Limbooo, PoeAudits, SBSecurity, SpicyMeatball, T1MOH, The-Seraphs, TheSavageTeddy, TheSchnilch, Topmark, VAD37, ZanyBonzy, adam-idarrha, bhilare_, btk, carlitox477, cinderblock, dimulski, falconhoof, grearlake, gumgumzum, iamandreiski, itsabinashb, josephdara, ke1caM, kennedy1030, ljj, n0kto, n4nika, neocrao, oakcobalt, petro_1912, pontifex, poslednaya, shaflow2, shikhar229169, web3km, ych18, zhaojohnson, zigtur
0.2831 USDC - $0.28
https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L269-L286
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.
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
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.
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
๐ Selected for report: MrPotatoMagic
Also found by: 0xtankr, ArmedGoose, Egis_Security, Giorgio, KYP, Maroutis, NentoR, OMEN, Sabit, Shubham, SpicyMeatball, T1MOH, d3e4, dimulski, peanuts
200.8376 USDC - $200.84
https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L205-L228
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.
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.
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)
๐ Selected for report: MrPotatoMagic
Also found by: 0x175, 0x486776, 0x77, 0xAkira, 0xAsen, 0xDemon, 0xabhay, 0xblack_bird, 0xlemon, 0xloscar01, 0xtankr, 3docSec, 4rdiii, Abdessamed, AlexCzm, Angry_Mustache_Man, BiasedMerc, Circolors, Cryptor, DMoore, DPS, DedOhWale, Dinesh11G, Dots, GalloDaSballo, Giorgio, Honour, Imp, Jorgect, Krace, KupiaSec, Mrxstrange, NentoR, Pechenite, PoeAudits, Ryonen, SBSecurity, Sabit, T1MOH, TheFabled, TheSavageTeddy, Tychai0s, VAD37, Vasquez, WildSniper, ZanyBonzy, adam-idarrha, alix40, asui, blutorque, btk, c0pp3rscr3w3r, caglankaan, carrotsmuggler, d_tony7470, dimulski, dinkras, djxploit, falconhoof, forgebyola, grearlake, imare, itsabinashb, josephdara, kartik_giri_47538, ke1caM, kennedy1030, koo, lionking927, ljj, niser93, pep7siup, poslednaya, ptsanev, sashik_eth, shaflow2, steadyman, turvy_fuzz, ubl4nk, valentin_s2304, web3km, xyz, y4y, zhaojohnson, zigtur
0.0234 USDC - $0.02
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
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.
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)
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)
๐ Selected for report: Circolors
Also found by: 0x175, 0x486776, 0xAlix2, 0xSecuri, 0xShitgem, 0xfox, 0xlemon, 0xnilay, 3th, 4rdiii, Aamir, Al-Qa-qa, AlexCzm, Egis_Security, Evo, Honour, Infect3d, Josh4324, Limbooo, Mahmud, SBSecurity, TheSchnilch, ahmedaghadi, alix40, amaron, bbl4de, bhilare_, btk, carrotsmuggler, cinderblock, d3e4, dimulski, dinkras, ducanh2706, iamandreiski, itsabinashb, ke1caM, ljj, sashik_eth, shaflow2, steadyman, web3km, y4y
3.8221 USDC - $3.82
https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L134-L153
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.
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
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; }
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
๐ Selected for report: 0xAlix2
Also found by: 0x486776, 0xabhay, 0xlucky, 0xtankr, Abdessamed, Circolors, CodeWasp, DarkTower, Egis_Security, Giorgio, Infect3d, Krace, KupiaSec, Limbooo, Maroutis, NentoR, Ryonen, SpicyMeatball, T1MOH, TheFabled, TheSavageTeddy, TheSchnilch, VAD37, XDZIBECX, btk, carrotsmuggler, cu5t0mpeo, dimulski, gumgumzum, iamandreiski, imare, itsabinashb, ke1caM, kennedy1030, lian886, n4nika, oakcobalt, sashik_eth, shaflow2, steadyman, web3km, windhustler, zhaojohnson
3.8221 USDC - $3.82
https://github.com/code-423n4/2024-04-dyad/blob/main/script/deploy/Deploy.V2.s.sol#L71-L76
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.
Manual review
Use the already deployed vaults, where the collateral is provided
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)
460.7972 USDC - $460.80
https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L205-L228
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.
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.
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
๐ Selected for report: dimulski
Also found by: 0xleadwizard, 0xlemon, Aamir, Al-Qa-qa, AvantGard, Bauchibred, Cryptor, DarkTower, Egis_Security, Giorgio, Maroutis, MrPotatoMagic, OMEN, Ocean_Sky, Ryonen, SBSecurity, Sabit, SpicyMeatball, Stefanov, T1MOH, Tigerfrake, WildSniper, atoko, bhilare_, darksnow, fandonov, grearlake, iamandreiski, igdbase, pontifex, web3km, xiao
6.3334 USDC - $6.33
https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L205-L228
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:
cr
& cappedCr
= 1.4e18liquidationEquityShare
= (1.4e18 - 1e18) * 0.2e18 = 80000000000000000 = 0.08e18liquidationAssetShare
= (0.08e18 + 1e18) / 1.4e18 = 771428571428571428 โ 0.77e18The 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.
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.
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
๐ Selected for report: TheSavageTeddy
Also found by: 0x175, 0x486776, 0xnev, AamirMK, AlexCzm, ArmedGoose, BiasedMerc, CaeraDenoir, Egis_Security, Jorgect, KYP, MrPotatoMagic, PoeAudits, SBSecurity, SovaSlava, VAD37, adam-idarrha, alix40, carrotsmuggler, d_tony7470, dimulski, grearlake, josephdara, ljj, n0kto, okolicodes, sashik_eth, sil3th, turvy_fuzz
7.3512 USDC - $7.35
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
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.
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)
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)