DittoETH

Ditto
DeFiFoundryOracle
55,000 USDC
View results
Submission Details
Severity: low
Valid

Exchange rate of 1:1 stETH:ETH:zETH value could lead to MEV and arbitrage (which may drain the Ditto protocol)

Summary

The depositEth, withdraw, unstake, and _ethConversion functions in BridgeSteth.sol and BridgeRouterFacet.sol, as well as getZethValue in BridgeSteth.sol, treat zETH as 1:1 with stETH and ETH. But zETH is a basket of liquid ether staking tokens so its value won't necessarily be 1:1. For example, stETH itself represents ETH plus staking rewards so theoretically it should be worth more than ETH, but in June 2022 it depegged significantly from ETH (worth around 10% less). It is possible that zETH will also be available on third party decentralized exchanges which could mean that there will be an external price that probably won’t be 1:1 with ETH. If you don’t actively incentivize third party liquidity for zETH, you could have an outside source for buying it with thin liquidity which will mean inaccurate prices. Thin liquidity also makes it easier to manipulate prices. This can result in opportunities for MEV and arbitrage and could result in the protocol being undercollateralized compared to the true value of zETH.

Vulnerability Details

The depositEth, withdraw, and unstake functions all assume a 1:1 ratio between stETH and zETH or ETH and zETH (unless the total amount of zETH in the system has decreased due to something like slashing). This is due to the _ethConversion function, which does not actually convert from the true value of ETH to zETH (e.g., from an oracle) but instead assumes it is 1:1 except in cases where the total deposits decreased due to slashing. The getZethValue function in BridgeSteth.sol, which is used in _ethConversion also treats the balance of stETH as equal to the balance of zETH.

function deposit(address bridge, uint88 amount)
external
nonReentrant
onlyValidBridge(bridge)
{
if (amount < Constants.MIN_DEPOSIT) revert Errors.UnderMinimumDeposit();
// @dev amount after deposit might be less, if bridge takes a fee
uint88 zethAmount = uint88(IBridge(bridge).deposit(msg.sender, amount)); // @dev(safe-cast)
uint256 vault;
if (bridge == rethBridge || bridge == stethBridge) {
vault = Vault.CARBON;
} else {
vault = s.bridge[bridge].vault;
}
vault.addZeth(zethAmount);
maybeUpdateYield(vault, zethAmount);
emit Events.Deposit(bridge, msg.sender, zethAmount);
}
function depositEth(address bridge)
external
payable
nonReentrant
onlyValidBridge(bridge)
{
if (msg.value < Constants.MIN_DEPOSIT) revert Errors.UnderMinimumDeposit();
uint256 vault;
if (bridge == rethBridge || bridge == stethBridge) {
vault = Vault.CARBON;
} else {
vault = s.bridge[bridge].vault;
}
uint88 zethAmount = uint88(IBridge(bridge).depositEth{value: msg.value}()); // Assumes 1 ETH = 1 ZETH
vault.addZeth(zethAmount);
maybeUpdateYield(vault, zethAmount);
emit Events.DepositEth(bridge, msg.sender, zethAmount);
}
function withdraw(address bridge, uint88 zethAmount)
external
nonReentrant
onlyValidBridge(bridge)
{
if (zethAmount == 0) revert Errors.ParameterIsZero();
uint88 fee;
uint256 withdrawalFee = bridge.withdrawalFee();
uint256 vault;
if (bridge == rethBridge || bridge == stethBridge) {
vault = Vault.CARBON;
} else {
vault = s.bridge[bridge].vault;
}
if (withdrawalFee > 0) {
fee = zethAmount.mulU88(withdrawalFee);
zethAmount -= fee;
s.vaultUser[vault][address(this)].ethEscrowed += fee;
}
uint88 ethAmount = _ethConversion(vault, zethAmount);
vault.removeZeth(zethAmount, fee);
IBridge(bridge).withdraw(msg.sender, ethAmount);
emit Events.Withdraw(bridge, msg.sender, zethAmount, fee);
}
function unstakeEth(address bridge, uint88 zethAmount)
external
nonReentrant
onlyValidBridge(bridge)
{
if (zethAmount == 0) revert Errors.ParameterIsZero();
uint88 fee = zethAmount.mulU88(bridge.unstakeFee());
uint256 vault;
if (bridge == rethBridge || bridge == stethBridge) {
vault = Vault.CARBON;
} else {
vault = s.bridge[bridge].vault;
}
if (fee > 0) {
zethAmount -= fee;
s.vaultUser[vault][address(this)].ethEscrowed += fee;
}
uint88 ethAmount = _ethConversion(vault, zethAmount);
vault.removeZeth(zethAmount, fee);
IBridge(bridge).unstake(msg.sender, ethAmount);
emit Events.UnstakeEth(bridge, msg.sender, zethAmount, fee);
}
function _ethConversion(uint256 vault, uint88 amount) private view returns (uint88) {
uint256 zethTotalNew = vault.getZethTotal();
uint88 zethTotal = s.vault[vault].zethTotal;
if (zethTotalNew >= zethTotal) {
// when yield is positive 1 zeth = 1 eth
return amount;
} else {
// negative yield means 1 zeth < 1 eth
return amount.mulU88(zethTotalNew).divU88(zethTotal);
}
}
}
function getZethValue() external view returns (uint256) {
return steth.balanceOf(address(this));
}

Impact

Validators can reorder transactions and front run deposits and withdrawals from the protocol. Also, there are pools on platforms like Curve where you can exchange from ETH to stETH...if stETH were worth more than ETH or vice versa in Curve pools, people could withdraw from Ditto at the 1:1 ratio and then sell on Curve for a profit, which could lead to Ditto's assets being drained. Or people could deposit stETH and then withdraw into rETH if that presented arbitrage opportunities. Also, treating zETH as if it is 1:1 to ETH when that might not be the value if it were swapped on other markets - new tokens like this are often not valued 1:1 because of the risk of the protocol issuing them - means that the protocol may not be as well-collateralized as the designer intends. Granted the minimum collateralization ratio is pretty high, and it is unlikely that zETH:ETH would be like 0.5:1 or anything extreme like that, but just treating zETH as equal to ETH does obscure the true collateralization of the protocol.

Tools Used

Manual review

Recommendations

You could create a pool on a third party market (probably Curve) and bootstrap liquidity so that there was an alternative place to measure the true exchange from zETH to ETH or zETH to stETH - you would probably have to incentivize people to deposit zETH there using Ditto tokens. Then you could use the exchange rate established on that market when converting between these various tokens. But that is admittedly a lot more coding and complication and extra Ditto token inflation.

Updates

Lead Judging Commences

0xnevi Lead Judge almost 2 years ago
Submission Judgement Published
Validated
Assigned finding tags:

finding-579

happyformerlawyer Submitter
almost 2 years ago
0xnevi Lead Judge
almost 2 years ago
0xnevi Lead Judge almost 2 years ago
Submission Judgement Published
Validated
Assigned finding tags:

finding-579

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.