Usual

eth0-protocol

Cantina Security Report

Organization

@Ender13120

Engagement Type

Cantina Reviews

Period

-

Researchers


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

  1. 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 by DaoCollateral.redeem calculates the required amount of USD0 to be burned in exchange for the underlying collateral.

    As a first step, the entire eth0Amount is burned, and afterwards, the stableFee amount is minted to the $.treasuryYield. Therefore, the burnedEth0 describes the effectively burned ETH0.

    // 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 calling redeem. Therefore, only if the CBR mechanism is not , the fee 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 of collateral that should be returned to the user, always uses burnedEth0 variable. This is correct if the stableFee has been moved to the treasury because the corresponding collateral for the stableFee 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 no stableFee 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 the cbr 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.

  2. 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:

    1. Protocol maintains 1:1 rate: Users can still swap stETH for ETH0 at par through the protocol
    2. Market reflects true value: ETH0's actual backing is worth 0.5 × 0.8 + 0.5 × 1.0 = 0.9 ETH
    3. 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:

    1. Add oracle validation: Use a Chainlink stETH/ETH oracle to monitor secondary market pricing
    2. Apply depeg thresholds: Configure the existing _checkDepegPrice() mechanism to pause operations when stETH deviates beyond acceptable limits
    3. 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

  1. DaoCollateral.redeem is not blocked in a depeg event with a zero redeemFee

    Severity

    Severity: Low

    Submitted by

    Manuel


    Description

    The eth0.mint function includes a collateralBackingInETH check. If not sucessful the mint call will revert.

    Since the redeem function requires minting the eth0 stableFee into the treasury, a revert in eth0.mint due to the collateralBackingInETH check would revert the entire redeem 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 the CBR 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-up eth0.mint call, which would allow to sucessful redeem if the protocol has depegged.

    Recommendation

    To ensure the redeem function is consistently blocked during a depeg befor the CBR get's activated, consider requiring the redeemFee > 0. Alternatively, introduce a public function in eth0 for the collateralBackingInETH and ensure it is called in all scenarios.

    Usual

    https://github.com/usual-dao/eth0-protocol/pull/5

    Cantina

    Fixed as recommended.

  2. decrease of the eth0.mintCap by setting to a lower amount than eth0.totalSupply() will block DaoCollateral.redeem

    Severity

    Severity: Low

    Submitted by

    Manuel


    Description

    In case the mint cap for ETH0 is decreased by calling eth0.setMintCap and a big swap transaction happens before, it can result in a state where the totalSupply() > mintCap.

    The DaoCollateral.redeem first burns all eth0 tokens and afterwards eth0.mint to mint the fees into the treasury.

    However, if redeem amount is smaller than the difference betweentotalSupply() - mintCap the eth0.mint call would revert because of the mintCap constraint. Even after burning the eth0 tokens the totalSupply() would be above the mintCap and eth0.mint would revert.

    This would result in a state where it is not possible to call DaoCallateral.swap since the mintCap has been reached, but it would block the reward of smaller amounts.

    Recommendation

    Don't allow setting the mintCap below the current totalSupply by adding the following check to the eth0.setMintCap function.

    if (newMintCap < totalSupply()) {    revert MintCapTooSmall();}

    Usual

    https://github.com/usual-dao/eth0-protocol/pull/4

    Cantina

    Fixed as recommended.

  3. redeemDao() function lacks pause protection allowing operations during system shutdown

    Severity

    Severity: Low

    Likelihood: Low

    ×

    Impact: Medium

    Submitted by

    phaze


    Description

    The redeemDao() function in the DaoCollateral contract is not protected by the whenNotPaused modifier, unlike the regular redeem() and swap() 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 both whenRedeemNotPaused and whenNotPaused
    • swap(): Protected by both whenSwapNotPaused and whenNotPaused
    • redeemDao(): Only protected by nonReentrant, 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 to redeemDao() 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.

  4. 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:

    1. Initial fee calculation: Uses Math.Rounding.Floor which rounds down
    2. Normalization process: The wadAmountToDecimals() and tokenAmountToWad() 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.

  5. 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 of wstETH, this will happen because of Lido staking rewards. Eth0 holders can only redeem the equivalent of 1 ether for 1 eth0 but not the rewards.

    The surplus is captured by the protocol in the form of newly issued eth0. Inside the eth0.mint function, there is a check to ensure that the overall totalSupply can never be higher than the mintCap.

    The permissioned eth0.mint call is intended to be used by governance to issue the protocol surplus. However, if the mintCap 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 the protocol surplus. The eth0.setMintCap and minting of the protocol surplus are required to be in the same transaction. Otherwise, other users could use the increased mintCap to swap again.

    Recommendation

    Consider adding another permissioned function for minting the surplus, which can increase the mintCap if needed. A higher totalSupply() than the mintCap can block the redeem 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

  1. 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.

  2. 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 calls oracle.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:

    1. Admin adds a collateral token via addEth0CollateralToken()
    2. Token is successfully added to the mapping
    3. Oracle initialization is delayed or forgotten
    4. 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.

  3. Inconsistent amount validation between mint and burn functions

    Severity

    Severity: Informational

    Submitted by

    phaze


    Description

    The ETH0 contract has inconsistent input validation between its mint() and burn functions. The mint() function includes a check to revert when amount == 0, but the burnFrom() and burn() functions lack this validation, creating inconsistency in the contract's input handling.

    Current validation patterns:

    • mint(): Includes if (amount == 0) revert AmountIsZero();
    • burnFrom(): No zero amount validation
    • burn(): 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.

  4. 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.

  5. outdated constants in constants.sol

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    Manuel


    Description

    The constants.sol defines constants that are for the USD0 deployment. Like the USUAL_MULTISIG_MAINNET, REGISTRY_CONTRACT_MAINNET or the USUAL_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.