Organization
- @Ender13120
Engagement Type
Cantina Reviews
Period
-
Findings
Medium Risk
2 findings
0 fixed
2 acknowledged
Low Risk
5 findings
3 fixed
2 acknowledged
Informational
5 findings
1 fixed
4 acknowledged
Medium Risk2 findings
DaoCollateral.redeem doesn't return the corresponding fee collateral to the redeemUser if CBR is active
State
- Acknowledged
Severity
- Severity: Medium
Submitted by
Manuel
The
_burnEth0TokenAndTransferCollateral
function called byDaoCollateral.redeem
calculates the required amount ofUSD0
to be burned in exchange for the underlying collateral.As a first step, the entire
eth0Amount
is burned, and afterwards, thestableFee
amount is minted to the$.treasuryYield
. Therefore, theburnedEth0
describes the effectively burnedETH0
.// we burn the remaining ETH0 tokenuint256 burnedEth0 = eth0Amount - stableFee;// we burn all the ETH0 token$.eth0.burnFrom(msg.sender, eth0Amount);
If the
CBR
mechanism is activated, users don't need to pay a fee for callingredeem
. Therefore, only if theCBR
mechanism is not , thefee
gets minted.// transfer the fee to the treasury if the redemption-fee is above 0 and CBR isn't turned on.// if CBR is on fees are forfeitedif (stableFee > 0 && !$.isCBROn) { $.eth0.mint($.treasuryYield, stableFee);}
However, in the following
_getTokenAmountForAmountInETH
call, which calculates the amount ofcollateral
that should be returned to the user, always usesburnedEth0
variable. This is correct if thestableFee
has been moved to thetreasury
because the corresponding collateral for thestableFee
should stay in the protocol.// get the amount of collateral token for the amount of ETH0 burned by calling the oraclereturnedCollateral = _getTokenAmountForAmountInETH(burnedEth0, collateralToken);
This is incorrect, in the
stableFee> 0 && isCBROn==true
case. Since nostableFee
needs to be paid, the entire collateral can be returned to the user in that case.- returnedCollateral = _getTokenAmountForAmountInETH(burnedEth0, collateralToken);+ returnedCollateral = _getTokenAmountForAmountInETH(eth0Amount, collateralToken);
Recommendation
The function should use the
eth0Amount
if thecbr
mechanism is activated for the_getTokenAmountForAmountInETH
calculation. This would also return the corresponding fee collateral to the user with the outcome that the user doesn't pay a fee.Usual
As a part of the ETH0 design, If the CBR is on, the fees that are usually supposed to be minted for the Usual Treasury are forfeited to increase recollateralization through redemptions instead.
Cantina
Acknowledged. A code comment is now included to clarify this behavior.
Lido oracle creates cross-collateral arbitrage risk in multi-collateral system
State
- Acknowledged
Severity
- Severity: Medium
≈
Likelihood: Low×
Impact: High Submitted by
phaze
Summary
The LidoWstEthOracle assumes a 1:1 peg between stETH and ETH, which creates arbitrage vulnerabilities when the protocol introduces additional collateral types that use market-based pricing. This design inconsistency could allow attackers to systematically drain higher-valued collateral during stETH depeg events.
Description
The current Lido oracle implementation uses the internal stETH per token rate rather than secondary market pricing:
answer = int256(IWstETH(WST_ETH_CONTRACT).stEthPerToken());
While this provides accurate wstETH:stETH conversion rates, it assumes stETH maintains parity with ETH regardless of secondary market conditions. This assumption becomes problematic when combined with other collateral types.
Consider ETH0 backed by multiple collateral types:
- 50% wstETH (valued using Lido's 1:1 assumption)
- 50% WETH (valued using market rates)
When stETH depegs to 0.8 ETH on secondary markets:
- Protocol maintains 1:1 rate: Users can still swap stETH for ETH0 at par through the protocol
- Market reflects true value: ETH0's actual backing is worth
0.5 × 0.8 + 0.5 × 1.0 = 0.9 ETH
- Arbitrage opportunity emerges:
- Buy stETH at 0.8 ETH on secondary markets
- Swap for ETH0 at 1:1 through the protocol
- Redeem ETH0 for WETH at 1:1 through the protocol
- Net profit: 0.2 ETH per cycle
This attack systematically drains the higher-valued collateral (WETH) while flooding the protocol with lower-valued collateral (stETH), potentially continuing until WETH reserves are exhausted.
For temporary price fluctuations, this might not pose a fundamental risk to the protocol, as backing value would be restored when the peg recovers. However, ETH0 faces greater liquidity risk than stETH itself due to instant redemption capabilities - while stETH holders must wait 1-5 days to unstake, ETH0 holders can immediately exit to any supported collateral type, amplifying the speed and impact of potential bank runs during depeg events.
The problem is worsened by stETH's withdrawal mechanics:
- Normal stETH unstaking: 1-5 day queue, subject to delays
- Protocol conversion: Instant stETH → ETH0 → other assets
This makes the protocol an attractive "fast lane" for stETH liquidity during market stress, potentially at significant cost to the protocol.
Impact Explanation
The impact is high as this design flaw could lead to systematic drainage of protocol reserves during stETH depeg events. Even if the peg is restored and backing value recovers, ETH0 will always be exposed to increased systemic risk - any depeg of a collateral token creates potential for cross-collateral arbitrage that could destabilize the entire protocol. The attack becomes more profitable and damaging as the price deviation increases and as more collateral types are added to the system.
Likelihood Explanation
The likelihood is low. While historical data shows stETH has experienced periods of depeg, for the protocol to suffer significant damage the depeg would need to persist long enough for attackers to systematically drain reserves. Most stETH depegs have been temporary, and the protocol's CBR mechanisms could potentially be activated to mitigate extended arbitrage attacks.
Recommendation
Primary Solution: Implement Depeg Protection for stETH
Treat stETH with the same depeg protection mechanisms used for other collateral:
- Add oracle validation: Use a Chainlink stETH/ETH oracle to monitor secondary market pricing
- Apply depeg thresholds: Configure the existing
_checkDepegPrice()
mechanism to pause operations when stETH deviates beyond acceptable limits - Maintain internal rate precision: Continue using Lido's
stEthPerToken()
for accurate wstETH conversions, but only when stETH is within acceptable peg range
Implementation approach:
// In oracle price checksfunction getPrice(address token) public view override returns (uint256) { if (token == WSTETH_ADDRESS) { // Check stETH peg using Chainlink oracle uint256 marketPrice = getChainlinkStETHPrice(); _checkDepegPrice(STETH_ADDRESS, marketPrice); // Revert if depegged // Use precise Lido rate only when peg is maintained return getLidoStETHPerToken(); } // ... other token logic}
Additional Safeguard: Collateral Composition Limits
Consider implementing caps on collateral backing percentages or maximum daily changes in collateral composition. This would prevent complete drainage of one collateral type in favor of another:
- Percentage caps: Limit any single collateral type to a maximum percentage of total backing
- Rate limiting: Restrict how quickly the collateral composition can shift between types
- Minimum reserves: Maintain minimum amounts of each collateral type
This approach preserves the accuracy of Lido's internal rates while protecting against cross-collateral arbitrage by pausing operations during significant depeg events, similar to how other stablecoins are protected in the existing
AbstractOracle
implementation.Usual
We use the on-chain wstETH → stETH → ETH rate (Lido oracle) because it is the source of truth for backing, immune to market noise, cheaper in gas, and follows Aave’s precedent.
If ETH0 ever trades off that rate, arbitrage closes the gap and the redemption fee keeps it uneconomical to drain funds.
Additionally, a Chainlink stETH/ETH feed Watcher Bot watches for extreme deviations and auto-pauses DaoCollateral if Lido is ever hacked, so we get black-swan protection without daily oracle freezes.
If any of these conditions change, we can additionally also swap to a different oracle at any time on the Usual Protocol itself.
Cantina
Acknowledged.
Low Risk5 findings
DaoCollateral.redeem is not blocked in a depeg event with a zero redeemFee
State
Severity
- Severity: Low
Submitted by
Manuel
Description
The
eth0.mint
function includes acollateralBackingInETH
check. If not sucessful themint
call will revert.Since the
redeem
function requires minting theeth0
stableFee
into the treasury, a revert ineth0.mint
due to thecollateralBackingInETH
check would revert the entireredeem
transaction.// transfer the fee to the treasury if the redemption-fee is above 0 and CBR isn't turned on.// if CBR is on fees are forfeitedif (stableFee > 0 && !$.isCBROn) { $.eth0.mint($.treasuryYield, stableFee);}
While this behavior is intended by the protocol to block the
redeem
calls until theCBR
mechanism gets activated.There is still the edge case, that the
redeemFee
could be set to zero basis points.This would result in a
stableFee=0
and no follow-upeth0.mint
call, which would allow to sucessfulredeem
if the protocol has depegged.Recommendation
To ensure the
redeem
function is consistently blocked during a depeg befor theCBR
get's activated, consider requiring theredeemFee > 0
. Alternatively, introduce a public function ineth0
for thecollateralBackingInETH
and ensure it is called in all scenarios.Usual
https://github.com/usual-dao/eth0-protocol/pull/5
Cantina
Fixed as recommended.
decrease of the eth0.mintCap by setting to a lower amount than eth0.totalSupply() will block DaoCollateral.redeem
State
Severity
- Severity: Low
Submitted by
Manuel
Description
In case the mint cap for
ETH0
is decreased by callingeth0.setMintCap
and a bigswap
transaction happens before, it can result in a state where thetotalSupply() > mintCap
.The
DaoCollateral.redeem
first burns all eth0 tokens and afterwardseth0.mint
tomint
the fees into the treasury.However, if
redeem
amount is smaller than the difference betweentotalSupply() - mintCap
theeth0.mint
call would revert because of themintCap
constraint. Even after burning theeth0
tokens thetotalSupply()
would be above themintCap
andeth0.mint
would revert.This would result in a state where it is not possible to call
DaoCallateral.swap
since themintCap
has been reached, but it would block the reward of smaller amounts.Recommendation
Don't allow setting the
mintCap
below the currenttotalSupply
by adding the following check to theeth0.setMintCap
function.if (newMintCap < totalSupply()) { revert MintCapTooSmall();}
Usual
https://github.com/usual-dao/eth0-protocol/pull/4
Cantina
Fixed as recommended.
redeemDao() function lacks pause protection allowing operations during system shutdown
State
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Medium Submitted by
phaze
Description
The
redeemDao()
function in the DaoCollateral contract is not protected by thewhenNotPaused
modifier, unlike the regularredeem()
andswap()
functions. This creates an inconsistency in the contract's pause mechanism and potentially allows DAO redemptions to continue even during system-wide emergency shutdowns.The current pause structure includes:
redeem()
: Protected by bothwhenRedeemNotPaused
andwhenNotPaused
swap()
: Protected by bothwhenSwapNotPaused
andwhenNotPaused
redeemDao()
: Only protected bynonReentrant
, no pause modifiers
This design means that when the contract is globally paused via
pause()
, regular user operations are halted but DAO redemptions can continue unimpeded. While this might be intentional to allow DAO operations during emergencies, it creates a potential gap in emergency response capabilities.If there's a critical issue requiring a complete system shutdown (such as a security vulnerability or oracle failure), administrators currently have no way to prevent DAO redemptions from occurring, which could potentially worsen the situation or interfere with emergency response procedures.
Recommendation
Consider adding the
whenNotPaused
modifier toredeemDao()
to enable complete system shutdown when necessary:function redeemDao(address collateralToken, uint256 amount) external nonReentrant + whenNotPaused { // ... function implementation}
This change would establish a clear hierarchy:
- Individual function pauses (
whenSwapNotPaused
,whenRedeemNotPaused
) for targeted operational control - Global pause (
whenNotPaused
) for system-wide emergency shutdown that affects all operations including DAO redemptions
If DAO operations need to remain available during specific emergencies, this can be achieved by using only the individual pause functions rather than the global pause.
Usual
https://github.com/usual-dao/eth0-protocol/pull/2
Cantina
Fixed as recommended.
Fee calculation rounding favors users over protocol
State
- Acknowledged
Severity
- Severity: Low
Submitted by
phaze
Description
In the
_calculateFee()
function of the DaoCollateral contract, the fee calculation and normalization steps consistently round in favor of users rather than the protocol. This occurs in two places:- Initial fee calculation: Uses
Math.Rounding.Floor
which rounds down - Normalization process: The
wadAmountToDecimals()
andtokenAmountToWad()
functions in the Normalize library use floor rounding by default
stableFee = Math.mulDiv(eth0Amount, $.redeemFee, BASIS_POINT_BASE, Math.Rounding.Floor);// ...if (tokenDecimals < 18) { stableFee = Normalize.tokenAmountToWad( Normalize.wadAmountToDecimals(stableFee, tokenDecimals), tokenDecimals );}
Both operations consistently round down, meaning users pay slightly less in fees than the intended percentage, and the protocol collects less revenue than designed.
While the team has indicated this behavior is intentional (carried over from the USD0 protocol), it represents a design choice where precision loss consistently favors users over the protocol's fee collection.
Recommendation
This appears to be an intentional design decision. However, if the protocol wishes to ensure it collects the full intended fee amount, consider using ceiling rounding for fee calculations:
- stableFee = Math.mulDiv(eth0Amount, $.redeemFee, BASIS_POINT_BASE, Math.Rounding.Floor);+ stableFee = Math.mulDiv(eth0Amount, $.redeemFee, BASIS_POINT_BASE, Math.Rounding.Ceil);
The current implementation prioritizes user-friendly rounding at the expense of protocol fee collection accuracy.
Usual
We ack this and will keep this in favor for user, impact should be marginal.
Cantina
Acknowledged.
eth0.mintCap can block the minting of the protocol surplus
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Manuel
Description
The underlying collateral of
eth0
will increase in value. In the case ofwstETH
, this will happen because of Lido staking rewards.Eth0
holders can only redeem the equivalent of1 ether
for1 eth0
but not the rewards.The surplus is captured by the protocol in the form of newly issued
eth0
. Inside theeth0.mint
function, there is a check to ensure that the overalltotalSupply
can never be higher than themintCap
.The permissioned
eth0.mint
call is intended to be used by governance to issue the protocol surplus. However, if themintCap
is reached, it won't be possible to issue the surplus for the protocol.It would be required to increase the
mintCap
again only to mint theprotocol
surplus. Theeth0.setMintCap
andminting
of the protocol surplus are required to be in the same transaction. Otherwise, other users could use the increasedmintCap
to swap again.Recommendation
Consider adding another permissioned function for minting the
surplus
, which can increase themintCap
if needed. A highertotalSupply()
than themintCap
can block theredeem
functionality as described in another issue. Therefore, we would recommend increasing it instead of not performing the check for surplus minting.Usual
We can raise mintcap when price of wstETH raised significantly over time. No need to do anything
Cantina
Acknowledged.
Informational5 findings
Missing mechanism to remove collateral tokens from token mapping
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
phaze
Description
The TokenMapping contract only provides functionality to add collateral tokens via
addEth0CollateralToken()
but lacks a corresponding mechanism to remove tokens once they have been added. This design limitation can lead to operational issues in several scenarios.When a collateral token becomes problematic (faulty, insolvent, or deprecated), it cannot be removed from the system. This creates potential operational risks:
- A faulty token could cause the entire
Eth0.mint()
function to halt when it attempts to iterate through all collateral tokens to calculate backing - If a token becomes insolvent, the protocol may need to pause operations, but resuming would require removing the problematic token first
- The current implementation permanently locks tokens into the mapping with no administrative override
While such issues could potentially be resolved through contract upgrades in worst-case scenarios, this approach introduces unnecessary complexity and delay in emergency situations.
Recommendation
Consider adding an administrative function to remove collateral tokens from the mapping. This would provide administrators with the flexibility to respond to problematic tokens without requiring contract upgrades.
Usual
It was decided not to add this functionality to be consistent with pegasus repo
Cantina
Acknowledged.
Adding collateral tokens without oracle validation can halt minting operations
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
phaze
Description
The
addEth0CollateralToken()
function in the TokenMapping contract does not verify that an oracle has been initialized for the collateral token being added. This oversight can cause all ETH0 minting operations to fail when the system attempts to calculate collateral backing.When
Eth0.mint()
is called, it iterates through all registered collateral tokens and callsoracle.getPrice(collateralToken)
to calculate the total backing. If any token lacks an initialized oracle, this call will revert with "OracleNotInitialized", effectively halting all minting operations until the issue is resolved.The current flow allows for a problematic sequence:
- Admin adds a collateral token via
addEth0CollateralToken()
- Token is successfully added to the mapping
- Oracle initialization is delayed or forgotten
- Any attempt to mint ETH0 fails when calculating collateral backing
This creates an operational vulnerability where adding tokens and initializing their oracles are not atomic operations, potentially disrupting core protocol functionality.
Recommendation
Consider adding oracle validation to the
addEth0CollateralToken()
function to ensure the token has a properly initialized oracle before being added to the mapping:function addEth0CollateralToken(address collateral) external returns (bool) { if (collateral == address(0)) { revert NullAddress(); } // check if there is a decimals function at the address // and if there is at least 1 decimal // if not, revert if (IERC20Metadata(collateral).decimals() == 0) { revert Invalid(); } TokenMappingStorageV0 storage $ = _tokenMappingStorageV0(); $._registryAccess.onlyMatchingRole(DEFAULT_ADMIN_ROLE);+ // Verify that oracle is initialized for this token+ IOracle oracle = IOracle($.registryContract.getContract(CONTRACT_ORACLE));+ try oracle.getPrice(collateral) returns (uint256) {+ // Oracle exists and is working+ } catch {+ revert OracleNotInitialized();+ } // is the collateral token already registered as a ETH0 collateral if ($.isEth0Collateral[collateral]) revert SameValue(); // ... rest of function}
Alternatively, consider making the token addition and oracle initialization atomic by combining both operations into a single administrative function.
Usual
Acknowledged, shall be no risk involved as we validate everything before launch and test on tenderly
Cantina
Acknowledged.
Inconsistent amount validation between mint and burn functions
State
Severity
- Severity: Informational
Submitted by
phaze
Description
The ETH0 contract has inconsistent input validation between its
mint()
and burn functions. Themint()
function includes a check to revert whenamount == 0
, but theburnFrom()
andburn()
functions lack this validation, creating inconsistency in the contract's input handling.Current validation patterns:
mint()
: Includesif (amount == 0) revert AmountIsZero();
burnFrom()
: No zero amount validationburn()
: No zero amount validation
While burning zero tokens is technically a no-op that doesn't cause harm, the inconsistency could lead to confusion and unexpected behavior differences between similar operations. More importantly, in the context of the broader protocol, burn operations are often associated with releasing collateral or other state changes, so it's important to prevent scenarios where collateral could be released while burning zero tokens.
Recommendation
Consider adding zero amount validation to the burn functions for consistency:
function burnFrom(address account, uint256 amount) public {+ if (amount == 0) {+ revert AmountIsZero();+ } Eth0StorageV0 storage $ = _eth0StorageV0(); $.registryAccess.onlyMatchingRole(ETH0_BURN); _burn(account, amount);} function burn(uint256 amount) public {+ if (amount == 0) {+ revert AmountIsZero();+ } Eth0StorageV0 storage $ = _eth0StorageV0(); $.registryAccess.onlyMatchingRole(ETH0_BURN); _burn(msg.sender, amount);}
This change would ensure consistent input validation across all token operations and provide clearer error messages for invalid inputs.
Usual
https://github.com/usual-dao/eth0-protocol/pull/6
Cantina
Fixed as recommended.
Registry contract changes not immediately reflected in dependent contracts
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
phaze
Description
The DaoCollateral contract (and other contracts in the protocol) cache contract addresses from the registry during initialization but do not update these cached addresses when the registry is modified. This creates a potential lag between registry updates and their reflection in dependent contracts.
During initialization, the contract fetches and stores contract addresses:
IRegistryContract registryContract = IRegistryContract(_registryContract);$.registryAccess = IRegistryAccess(registryContract.getContract(CONTRACT_REGISTRY_ACCESS));$.treasury = address(registryContract.getContract(CONTRACT_TREASURY));$.tokenMapping = ITokenMapping(registryContract.getContract(CONTRACT_TOKEN_MAPPING));$.eth0 = IEth0(registryContract.getContract(CONTRACT_ETH0));$.oracle = IOracle(registryContract.getContract(CONTRACT_ORACLE));$.treasuryYield = registryContract.getContract(CONTRACT_YIELD_TREASURY);
If any of these contract addresses are updated in the registry after initialization, the DaoCollateral contract will continue using the old cached addresses until it is upgraded or redeployed.
While this design pattern is common across the Usual protocol contracts, it creates a potential operational issue where registry updates don't take immediate effect.
Recommendation
This appears to be a deliberate architectural choice that prioritizes gas efficiency over immediate registry synchronization and this limitation should be documented.
Usual
Acked, not going to fix.
Cantina
Acknowledged.
outdated constants in constants.sol
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Manuel
Description
The
constants.sol
defines constants that are for theUSD0
deployment. Like theUSUAL_MULTISIG_MAINNET
,REGISTRY_CONTRACT_MAINNET
or theUSUAL_PROXY_ADMIN_MAINNET
.A deployed
LIDO_STETH_ORACLE_MAINNET
is not used in the codebase.Recommendation
Remove the outdated constants from the
constants.sol
file.Usual
Acknowledged. This will be changed/replaced during deployment phase when we will have all new multisigs ready
Cantina
Acknowledged.