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: 5/183
Findings: 5
Award: $814.80
🌟 Selected for report: 0
🚀 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/4a987e536576139793a1c04690336d06c93fca90/script/deploy/Deploy.V2.s.sol#L64-L65 https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/script/deploy/Deploy.V2.s.sol#L93-L94
The deployment script sets wsteth
and eth
vaults to both be licensed kerosene vaults and non-kerosene vaults. This allows for the same vault to be added to an nft's list of vaults twice which can lead to a host of accounting issue in the protocol. Users can take advantage of this by to maintain a healthy collateral ratio for half the needed amount of tokens to be deposited by adding the same vault into their list of vaults twice, one kersosene, one non-kerosene. This is because, the vaultmanager tracks an nft kerosene and non-kerosene vaults seperately. This way, a user can maintain a facade of being well collateralized, when they're not.
First, from the deployment script, we see that wsteth
and eth
vaults are licencsed by both the vault licenser, which presumably tracks non-kerosene vaults and the kerosene manager, which presumable tracks kerosene vaults.
kerosineManager.add(address(ethVault)); kerosineManager.add(address(wstEth));
vaultLicenser.add(address(ethVault)); vaultLicenser.add(address(wstEth));
Now, from the vault manager, vault types are tracked by two different mappings of enumerable sets, one for kerosenevaults and one for just vaults.
mapping (uint => EnumerableSet.AddressSet) internal vaults; mapping (uint => EnumerableSet.AddressSet) internal vaultsKerosene;
So upon vault addition, if a vault already exists, it reverts
function add( uint id, address vault ) external isDNftOwner(id) { if (vaults[id].length() >= MAX_VAULTS) revert TooManyVaults(); if (!vaultLicenser.isLicensed(vault)) revert VaultNotLicensed(); if (!vaults[id].add(vault)) revert VaultAlreadyAdded(); emit Added(id, vault); }
But such vault can still be added into the kerosene vault set.
function addKerosene( uint id, address vault ) external isDNftOwner(id) { if (vaultsKerosene[id].length() >= MAX_VAULTS_KEROSENE) revert TooManyVaults(); if (!keroseneManager.isLicensed(vault)) revert VaultNotLicensed(); if (!vaultsKerosene[id].add(vault)) revert VaultAlreadyAdded(); emit Added(id, vault); }
This causes that an nft now has the same two vaults, but under different categories. Doing this becomes a source of accounting issues, as deposits into the vaults will be counted twice.
An example of this is in escaping liquidation, assuming the nft currently has 1 non-kerosene vault (and all vaults are licensed), worth $100 with a minted dyad of 80, giving a collateral ratio of 100/80 = 1,25 - which is ripe for liquidation. The total usd value of this vault is currently calculated as:
function getNonKeroseneValue( uint id ) public view returns (uint) { uint totalUsdValue; uint numberOfVaults = vaults[id].length(); //@note same vault for (uint i = 0; i < numberOfVaults; i++) { Vault vault = Vault(vaults[id].at(i)); uint usdValue; if (vaultLicenser.isLicensed(address(vault))) { usdValue = vault.getUsdValue(id); } totalUsdValue += usdValue; } return totalUsdValue; }
Now, upon adding the vault again, this time as a kerosene vault, the totalusdvalue is now calculated as the sum of nonkerosene vault value + kerosene vault value
function getKeroseneValue( uint id ) public view returns (uint) { uint totalUsdValue; uint numberOfVaults = vaultsKerosene[id].length(); //@note same vault for (uint i = 0; i < numberOfVaults; i++) { Vault vault = Vault(vaultsKerosene[id].at(i)); uint usdValue; if (keroseneManager.isLicensed(address(vault))) { usdValue = vault.getUsdValue(id); } totalUsdValue += usdValue; } return totalUsdValue; }
This causes that the overall TotalUsdValue is now doubled, from $100 to $200 usd, suddenly, the collateral ratio is 200 / 80 is 2,5 making such a vault overcollateralized.
function getTotalUsdValue( uint id ) public view returns (uint) { return getNonKeroseneValue(id) + getKeroseneValue(id); }
There are more issues that can occur from this issue, the case pointed above is just one of the numerous.
Manual code review
It's not clear why wsteth and eth vaults are being licensed by both vaultlicenser and kerosene manager. So i'd recommend just not doing that. However, if its a design choice, the checks in the add
and addKerosene
functions can be further beefed up by checking for vault existence in the other vaults before adding.
Context
#0 - c4-pre-sort
2024-04-29T05:27:19Z
JustDravee marked the issue as duplicate of #966
#1 - c4-pre-sort
2024-04-29T08:38:01Z
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:30Z
koolexcrypto marked the issue as duplicate of #1133
#4 - c4-judge
2024-05-29T07:06:53Z
koolexcrypto marked the issue as satisfactory
🌟 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/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L101 https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L125
Users who have had vaults added to their list of vaults can have the removal process continually griefed by malicious users, due to a check in the remove function that ensures that vaults must not have any assets deposited in them. Since anyone can deposit into the NFTs, malicious user can continuosly grief the removal process by depositing 1 wei of the underlying token into the vault. Considering that there's a limit on the amount vaults that can be added, and that for a host of reasons, a user might want to switch vaults, having the functionality operate normally is quite important.
The vault manager allows anyone to deposit into a valid note nft's vault. And imposes no minimum deposit value.
function deposit( uint id, address vault, uint amount ) external isValidDNft(id) //@note { idToBlockOfLastDeposit[id] = block.number; Vault _vault = Vault(vault); _vault.asset().safeTransferFrom(msg.sender, address(vault), amount); _vault.deposit(id, amount); //@note }
And, when the nft owner decides to remove a vault from list of vault, a check is made for asset balance to prevent loss of funds during removal. If there's any asset left in the vault, the function reverts.
function remove( uint id, address vault ) external isDNftOwner(id) { if (Vault(vault).id2asset(id) > 0) revert VaultHasAssets(); //@note if (!vaults[id].remove(vault)) revert VaultNotAdded(); emit Removed(id, vault); }
Since anyone can deposit into the vault, by continuosly depositing 1 wei of the underlying tokens into vault just before removal, the function keeps faling, causing vault removal to be impossible.
The cost of the attack is pretty low too, as for instance, in the WETH vault, the malicious user will be able to deposit up to 10_000_000_000_000_000 times before incurring a significant cost of about $31 (excluding gas)
Manual code review
Introduce a minimum deposit amount, this makes the attack cost more,
DoS
#0 - c4-pre-sort
2024-04-27T13:34:44Z
JustDravee marked the issue as duplicate of #489
#1 - c4-pre-sort
2024-04-29T09:28:41Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T20:38:18Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2024-05-05T20:39:23Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#4 - c4-judge
2024-05-05T20:39:27Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#5 - c4-judge
2024-05-05T21:42:14Z
koolexcrypto marked the issue as nullified
#6 - c4-judge
2024-05-05T21:42:20Z
koolexcrypto marked the issue as not nullified
#7 - c4-judge
2024-05-08T15:27:57Z
koolexcrypto marked the issue as duplicate of #1001
#8 - c4-judge
2024-05-11T19:49:52Z
koolexcrypto marked the issue as satisfactory
#9 - c4-judge
2024-05-13T18:34:30Z
koolexcrypto changed the severity to 3 (High Risk)
🌟 Selected for report: carrotsmuggler
Also found by: Al-Qa-qa, Emmanuel, TheFabled, TheSavageTeddy, ZanyBonzy, adam-idarrha, alix40, lian886
746.4915 USDC - $746.49
Judge has assessed an item in Issue #389 as 2 risk. The relevant finding follows:
Impact The vault manager implements a flashloan protection to prevent users from depositing and withdrawing in the same block. This is done to prevent kerosene price manipulation.This protection can be bypassed by users depositing and liquidating (possibly to self) in one block to manipulate kerosine prices. The process only involves making sure their NFT is at a state at which it can be liquidated, depositing just enough to still put the NFT at a liquidatable state, but not enough to take it out and in the same block liquidating the NFT, to another NFT they own or are in cohorts with. At its core, the liquidate function acts like the a withdraw and deposit function due to move which reduces amoount from one nft and moves it to another. So by aggregating a multicall involving deposiing to an nft A in a liquitable state (depositing enough to keep it in that state), liquidating nft A to another nft B and withdrawing from nft B, all in one block, a user can successfully manipulate kerosene token prices.
Recommended Mitigation Steps Include the same check against withdrawal in the same block in the liquidate function.
#0 - c4-judge
2024-05-10T11:50:46Z
koolexcrypto marked the issue as duplicate of #68
#1 - c4-judge
2024-05-11T12:13:36Z
koolexcrypto marked the issue as satisfactory
#2 - c4-judge
2024-05-13T11:26:23Z
koolexcrypto marked the issue as not a duplicate
#3 - koolexcrypto
2024-05-13T11:26:48Z
submitted already by the same warden as saperate issue
#4 - c4-judge
2024-05-13T11:27:02Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#5 - c4-judge
2024-05-29T17:41:55Z
koolexcrypto removed the grade
#6 - c4-judge
2024-05-29T17:42:45Z
koolexcrypto marked the issue as duplicate of #68
#7 - c4-judge
2024-05-29T17:42:48Z
koolexcrypto marked the issue as satisfactory
#8 - koolexcrypto
2024-05-29T17:44:52Z
Hi @koolexcrypto , Apologies for the comment after pjqa, but I'm not sure what other issue you're referring to. This is the only instance of me submitting the issue.
Sorry, my mistake. You have two issues, mistakenly , I thought they are the same. fixed now.
#9 - c4-judge
2024-05-29T17:50:37Z
koolexcrypto changed the severity to 3 (High Risk)
🌟 Selected for report: Infect3d
Also found by: 0x486776, 0xAlix2, 0xleadwizard, 0xnilay, Abdessamed, ArmedGoose, Bauchibred, Bigsam, GalloDaSballo, HChang26, Myrault, OMEN, SBSecurity, T1MOH, ZanyBonzy, alix40, atoko, iamandreiski, jesjupyter, ke1caM, miaowu, peanuts, vahdrak1
17.2908 USDC - $17.29
Judge has assessed an item in Issue #389 as 2 risk. The relevant finding follows:
Impact When liquidators liquidate a distressed NFT, they perform a expect to receive a significant profit for their efforts while factoring gas costs and cost of liquidation. However, if a position lacks sufficient collateral to cover the short amount being liquidated, liquidators receive the remaining collateral which, which could be minimal when compared to the price of dyad needed to be burned to liquidate position. This scenario causes protocol to accumulate bad debts as multiple distressed/liquidatable nfts won’t be liquidated cause there are no incentives to do so (if there assets are relatively low in value), discouraging liquidators from participating in the protocol and impacting the protocol’s liquidity and overall efficiency.
#0 - c4-judge
2024-05-29T11:15:36Z
koolexcrypto marked the issue as duplicate of #977
#1 - c4-judge
2024-05-29T11:15:39Z
koolexcrypto marked the issue as satisfactory
🌟 Selected for report: Bauchibred
Also found by: Al-Qa-qa, K42, SBSecurity, Sathish9098, VAD37, ZanyBonzy, albahaca, clara, niloy, oakcobalt, sxima
50.721 USDC - $50.72
Lines of code*
Anyone with kerosene can potentially maniulate denominator, hence asset prices by donating tokens to mainnet address due to querying of balance of mainnet owner's address.
function denominator() external view returns (uint) { // @dev: We subtract all the Kerosene in the multi-sig. // We are aware that this is not a great solution. That is // why we can switch out Denominator contracts. return kerosine.totalSupply() - kerosine.balanceOf(MAINNET_OWNER); }
Lines of code*
Kerosene address is not set in the KerosineDenominator contract, only in the constructor. Since it cannot be changed, the parameter can be marked immutable.
Kerosine public immutable kerosine;
Lines of code* https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/Vault.kerosine.bounded.sol#L29 https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/Vault.kerosine.unbounded.sol#L47
assetPrice
can easily be manipulated through donations to the vaultLines of code*
The use of balanceOf of the vault opens the assetPrice manipulation by donating tokens to vault's address.
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; }
setKeroseneManager
can be frontrunLines of code*
Anyone who frontruns call to the setKeroseneManager
can essentially take over an important part of the protocol which can be significantly misuesed. Important to note that once the address is set, it cannot be changed, hence it's imperative that the function be protected from frontrunners to prevent need for redeployment.
function setKeroseneManager(KerosineManager _keroseneManager) external initializer { keroseneManager = _keroseneManager; }
Consider calling the function in the constructor instead.
Lines of code*
The setKeroseneManager
function is protected by the initializer modifier, meaning, once set, cannot be changed.
function setKeroseneManager(KerosineManager _keroseneManager) external initializer { keroseneManager = _keroseneManager; }
Consider then marking its declaration as immutable.
KerosineManager public immutable keroseneManager;
Lines of code *
https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L101 https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/Vault.kerosine.bounded.sol#L41
Users who have had bounded kerosene vaults added to their list of vaults cannot have it removed, due to a check in the remove function that ensures that vaults must not have any assets deposited in them.
function remove( uint id, address vault ) external isDNftOwner(id) { if (Vault(vault).id2asset(id) > 0) revert VaultHasAssets(); if (!vaults[id].remove(vault)) revert VaultNotAdded(); emit Removed(id, vault); }
This causes that the owner cannot remove bounded kerosene vaults due to inability to withdraw from it. Since anyone can deposit into a vault, a malicious user can deposit as low as 1 wei into the vault and permanently DOS vault removal.
function withdraw( uint id, address to, uint amount ) external view onlyVaultManager { revert NotWithdrawable(id, to, amount); }
Considering that there's a limit on the amount vaults that can be added, and that for a host of reasons including if vaults get compromised, a user might want to switch vaults, having a functionality to do that is quite handy.
Lines of code*
https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/staking/KerosineDenominator.sol#L21 https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/Vault.kerosine.unbounded.sol#L66
The denominator is calculated as totalSupply - mainnetowner's balance.
function denominator() external view returns (uint) { // @dev: We subtract all the Kerosene in the multi-sig. // We are aware that this is not a great solution. That is // why we can switch out Denominator contracts. return kerosine.totalSupply() - kerosine.balanceOf(MAINNET_OWNER); }
And upon, kerosene minting in the constructor, the total supply (and max supply) of 1 billion, and the entire supply is minted to the deployer of the kerosene contract which can potentially be the mainnet owner, denominator during this time period will always 0,
constructor() { _mint(msg.sender, 1_000_000_000 * 10**18); // 1 billion }
which will DOS involving getting assetprices as a division by zero error will occur. Any user using the bounded or unbounded kerosene vaults will be affected by this.
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; }
Important to also note that if transferred by the mainnet owner, the tokens can always be donated back to the address to trigger this bug.
Lines of code*
https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/Vault.wsteth.sol#L103 https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/Vault.sol#L102 https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L146 https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L197 https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L238
The asset prices is gotten from the vault contracts, which queries chainlink feed for token prices. The issue is that the answer
validation is not sufficient enough as it only accounts for negative prices and not 0.
function assetPrice() public view returns (uint) { ( , int256 answer, , uint256 updatedAt, ) = oracle.latestRoundData(); if (block.timestamp > updatedAt + STALE_DATA_TIMEOUT) revert StaleData(); return answer.toUint256(); //@note }
The function uses toUint256
function to convert the asset price, which however only reverts on negative prices but not zero.
function toUint256(int256 value) internal pure returns (uint256) { require(value >= 0, "SafeCast: value must be positive"); return uint256(value); }
This means that when chainlink prices return zero for one reason or the other, the protocol uses the value. This can lead to a host of unexpected issues in the protocol including unfair liquidations, unprofitable or blocked redemptions/withdrawals and so on. A user for instance with only the vault will instantly have his switch from healthy to zero in an instant, opening him up to be unfairly liquidated by malicious liquidators.
Include a check to ensure prive is > 0 instead.
Lines of code*
https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/Vault.wsteth.sol#L103 https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/Vault.sol#L102
Chainlink oracles can be taken offline or token prices can fall to below zero, especially during times of high volatility. In these cases, liquidations and other protocol operations will be frozen (all calls will revert) for any debt holders holding this token, even though they may be some of the most important times to allow liquidations to retain the solvency of the protocol. This is because there is no fallback logic to be executed when the access to the Chainlink data feed is denied by Chainlink's multisigs. The multisigs can immediately block access to price feeds at will.
https://blog.openzeppelin.com/secure-smart-contract-guidelines-the-dangers-of-price-oracles/
uint256 updatedAt, ) = oracle.latestRoundData(); if (block.timestamp > updatedAt + STALE_DATA_TIMEOUT) revert StaleData(); return answer.toUint256(); // }
This can cause a full time lockdown of the protocol's operations.
A fallback oracle should be provided that will be used in place of chainklink to handle this scenario. The whole set up should be placed in a try-catch block.
redeem
function can turn a one step burn and withdraw process into two.Lines of code*
The redeemDyad
function allows for burning and withdrawing in one atomic transaction. It however calculates the asset to withdraw without accounting for solidity's rounding down feature, which defeats the purpose of the function.
function redeemDyad( uint id, address vault, uint amount, address to ) external isDNftOwner(id) returns (uint) { dyad.burn(id, msg.sender, amount); Vault _vault = Vault(vault); uint asset = amount * (10**(_vault.oracle().decimals() + _vault.asset().decimals())) / _vault.assetPrice() / 1e18; withdraw(id, vault, asset, to); emit RedeemDyad(id, vault, amount, to); return asset; }
The asset
calculation formula can be simplified to this: amt * 10**(OD + VD) / (P * 10**OD) / 1e18
Considering _vault.asset().decimals())
is 18, the formula when broken down equals amt / P;
And due to solidity's rounding down feature, whenever amount of dyad to redeem is less than price, the function simply burns the tokens and withdraws zero assets. This can pose issues in cases of integrations with other protcols and defeats the purpose of having the function.
Consider introducing a normalizer parameter which should be accounted for in the withdraw
function.
Lines of code*
https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L230-L239 https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/DNft.sol#L4 https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/lib/openzeppelin-contracts/contracts/token/ERC721/extensions/ERC721Enumerable.sol#L6 https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/lib/openzeppelin-contracts/contracts/token/ERC721/ERC721.sol#L150-L170
Depending on valuable the price of dyad becomes(incae of an upward depeg) , a user can use a dNFT
to mint dyad worth lots of usd value, while racking up a poor collateral ratio. Considering that collateral ratio is calculated by comparing usd value of the vaults held by the nft against the amount of dyad minted not its usd value. It can become more profitable for a user to , rather than burn dyad to retrieve his collateral, sell it on the market place to an unsuspecting victim. The victim pays possibly a substantial amount for the nft and receives an nft with a poor collateral ratio opening him up to liquidations, or having to pay extra to put the vault in a stable state.
function collatRatio( uint id ) public view returns (uint) { uint _dyad = dyad.mintedDyad(address(this), id); if (_dyad == 0) return type(uint).max; return getTotalUsdValue(id).divWadDown(_dyad); }
Disable transfer of NFTs when its undercollateralized.
Lines of code*
When liquidators liquidate a distressed NFT, they perform a expect to receive a significant profit for their efforts while factoring gas costs and cost of liquidation. However, if a position lacks sufficient collateral to cover the short amount being liquidated, liquidators receive the remaining collateral which, which could be minimal when compared to the price of dyad needed to be burned to liquidate position. This scenario causes protocol to accumulate bad debts as multiple distressed/liquidatable nfts won't be liquidated cause there are no incentives to do so (if there assets are relatively low in value), discouraging liquidators from participating in the protocol and impacting the protocol's liquidity and overall efficiency.
Lines of code* https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/staking/KerosineDenominator.sol#L21 https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/Vault.kerosine.unbounded.sol#L66
The denominator is calculated as totalSupply - mainnetowner's balance.
function denominator() external view returns (uint) { // @dev: We subtract all the Kerosene in the multi-sig. // We are aware that this is not a great solution. That is // why we can switch out Denominator contracts. return kerosine.totalSupply() - kerosine.balanceOf(MAINNET_OWNER); }
And upon, kerosene minting in the constructor, the total supply (and max supply) of 1 billion, and the entire supply is minted to the deployer of the kerosene contract which can potentially be the mainnet owner.
constructor() { _mint(msg.sender, 1_000_000_000 * 10**18); // 1 billion }
The mainnet owner will then continously transfer the tokens to the staking contract, causing their balance to decrease and consiquently, denominator to get bigger. Eventually, a point might be reached in which the numerator becomes potentially smaller, or close to the denominator causing the asset price to get much lesser. This will negatively affect users holding the bounded and unbounded kerosene tokens in their vaults and might put them at risk of unfair liquidations.
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; }
Links to affected code * https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L119 https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L205
The vault manager implements a flashloan protection to prevent users from depositing and withdrawing in the same block. This is done to prevent kerosene price manipulation.This protection can be bypassed by users depositing and liquidating (possibly to self) in one block to manipulate kerosine prices. The process only involves making sure their NFT is at a state at which it can be liquidated, depositing just enough to still put the NFT at a liquidatable state, but not enough to take it out and in the same block liquidating the NFT, to another NFT they own or are in cohorts with. At its core, the liquidate function acts like the a withdraw and deposit function due to move which reduces amoount from one nft and moves it to another. So by aggregating a multicall involving deposiing to an nft A in a liquitable state (depositing enough to keep it in that state), liquidating nft A to another nft B and withdrawing from nft B, all in one block, a user can successfully manipulate kerosene token prices.
Include the same check against withdrawal in the same block in the liquidate function.
Links to affected code * https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/script/deploy/Deploy.V2.s.sol#L64-L65 https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L269-L287
In the deployment script, ethVault and wstETH vaults are wrongly added as kerosene vaults.
kerosineManager.add(address(ethVault)); kerosineManager.add(address(wstEth));
Users will only be able to add it to kerosene vaults using the addKerosene
function.
function addKerosene( uint id, address vault ) external isDNftOwner(id) { if (vaultsKerosene[id].length() >= MAX_VAULTS_KEROSENE) revert TooManyVaults(); if (!keroseneManager.isLicensed(vault)) revert VaultNotLicensed(); if (!vaultsKerosene[id].add(vault)) revert VaultAlreadyAdded(); emit Added(id, vault); }
which will affect their ability to mint as its value will only be calculated as kerosene value.
function getKeroseneValue( uint id ) public view returns (uint) { uint totalUsdValue; uint numberOfVaults = vaultsKerosene[id].length(); for (uint i = 0; i < numberOfVaults; i++) { Vault vault = Vault(vaultsKerosene[id].at(i)); uint usdValue; if (keroseneManager.isLicensed(address(vault))) { usdValue = vault.getUsdValue(id); } totalUsdValue += usdValue; } return totalUsdValue; }
Minting is only allowed if user has healthy nonKeroseoneValue, so users who added this as their kerosene vaults will not be able to normally mint.
function mintDyad( uint id, uint amount, address to ) external isDNftOwner(id) { uint newDyadMinted = dyad.mintedDyad(address(this), id) + amount; if (getNonKeroseneValue(id) < newDyadMinted) revert NotEnoughExoCollat(); dyad.mint(id, to, amount); if (collatRatio(id) < MIN_COLLATERIZATION_RATIO) revert CrTooLow(); emit MintDyad(id, amount, to); }
Consider removing this and lisitng kerosene vaults instead.
#0 - c4-pre-sort
2024-04-28T09:25:27Z
JustDravee marked the issue as high quality report
#1 - JustDravee
2024-04-28T09:25:46Z
Some findings should probably be upgraded depending on final severity
#2 - c4-judge
2024-05-10T11:31:07Z
koolexcrypto marked the issue as grade-a
#3 - c4-judge
2024-05-13T11:30:07Z
koolexcrypto marked the issue as grade-b
#4 - koolexcrypto
2024-05-29T09:59:12Z
Thanks for the feedback.
L13 extracted.
L12 and L14 are invalid
#5 - koolexcrypto
2024-05-29T10:02:06Z
Regarding the grade, This was between a and b, However, there are issues reported in files which are out of scope. Due to this, I don't believe it qualify for a