Usual logo

Usual sUSD0 Yield Bearing Vault Security Review Report

October 2025

Overview

This report covers the security review of Usual sUSD0, a yield bearing vault for USD0, a synthetic accruing stablecoin denominated in USD, developed by Usual DAO. Our security assessment was a full review of the code, spanning a total of 1 week. During our review, we did not identify any major severity vulnerabilities. We did identify several minor severity vulnerabilities and code optimisations. All of our reported issues were fixed or acknowledged by the development team and consequently validated by us. We can confidently say that the overall security and code quality have increased af ter completion of our audit.

Scope

The analyzed resources are located on:

https://github.com/usual-dao/core-protocol/pull/61

The issues described in this report were fixed in the following commit:

https://github.com/usual-dao/core-protocol/tree/9eecab78131a3d16ae13b5b5a2644bcf8b231826

Summary

Total number of findings
4

Weaknesses

This section contains the list of discovered weaknesses.

USL2-1 | OPTIMIZATION OF BLACKLIST CHECK IN _UPDATE

Severity:

Low

Status:

Acknowledged

Path:

src/vaults/SUsd0.sol:_update#L327-L337

Description:

The function _update is part of the ERC20 implementation and handles the transfers. The SUsd0 contract overwrites this function to add blacklist functionality. It will check both the from and to address for blacklisted status.

The blacklist check can be skipped for address(0), which will happen on a mint and a burn. One SLOAD would be saved every time someone deposits to or withdraws from the vault.

function _update(address from, address to, uint256 amount)
    internal
    override(ERC20Upgradeable)
    whenNotPaused
{
    SUsd0StorageV0 storage $ = _susd0StorageV0();
    if ($.isBlacklisted[from] || $.isBlacklisted[to]) {
        revert Blacklisted();
    }
    super._update(from, to, amount);
}

Remediation:

Optimize the check to skip address(0), for example:

if ((from != address(0) && $.isBlacklisted[from])
    || (to != address(0) && $.isBlacklisted[to])) {
    revert Blacklisted();
}

USL2-4 | WHENEVER REWARDCLAIMFORACCRUINGDT IS SET, ITS ASSET TOKEN SHOULD BE VERIFIED

Severity:

Low

Status:

Acknowledged

Path:

src/modules/RevenueDistributionModule.sol#L278-L284

Description:

The distributeAccruingDT() function is used to distribute newly minted usd0 tokens to the $.rewardClaimForAccruingDT contract by calling the startYieldDistribution() function after minting:

$.usd0.mint(address($.rewardClaimForAccruingDT), amount);
// Start yield distribution
$.rewardClaimForAccruingDT.startYieldDistribution(
    amount, block.timestamp, block.timestamp + ONE_DAY
);

This only works correctly when the asset of the rewardClaimForAccruingDT contract is $.usd0. Otherwise, the startYieldDistribution() function will revert because the incorrect token was minted.

However, there is no check to verify that the asset is correct when setting $.rewardClaimForAccruingDT in the RevenueDistributionModule in the initialize() function or the setRewardClaimForAccruingDT() function.

Remediation:

Whenever the $.rewardClaimForAccruingDT address is updated, its asset token should be verified to match $.usd0.

USL2-2 | REVENUEDISTRIBUTIONMODULE MISSING CAP CHECK FOR DAILY ACCRUING YIELD RATE

Severity:

Informational

Status:

Fixed

Path:

src/modules/RevenueDistributionModule.sol:setDailyAccruingYieldRate

Description:

In the RevenueDistributionModule contract the configuration variable dailyAccruingYieldRate is set in the initializer from the parameter values. It is a rate expressed in micro bps (1_000_000) and it is correctly checked to be at most equal to the maximum value of 100%.

However, in the setter function setDailyAccruingYieldRate, this cap check is missing. The privileged role is able to set a rate that is higher than 100%.

function setDailyAccruingYieldRate(uint256 newDailyRate) external whenNotPaused {
    if (newDailyRate == 0) {
        revert AmountIsZero();
    }
    RevenueDistributionModuleStorageV0 storage $ = _revenueDistributionModuleStorageV0();
    $.registryAccess.onlyMatchingRole(OPERATOR_ADMIN_ROLE);
 
    if (newDailyRate == $.dailyAccruingYieldRate) {
        revert SameValue();
    }
 
    $.dailyAccruingYieldRate = newDailyRate;
 
    emit DailyAccruingYieldRateUpdated(newDailyRate);
}

Remediation:

We recommend to add a sanity check to setDailyAccruingYieldRate to not allow values greater than 100%.

USL2-3 | REDUNDANT CHECK FOR STARTTIME IN THE _STARTYIELDDISTRIBUTION FUNCTION

Severity:

Informational

Status:

Acknowledged

Path:

src/vaults/SUsd0.sol#L343-L383

Description:

In the _startYieldDistribution() function, there is a check to verify that startTime is not below $.periodFinish. However, startTime is already required to be greater than or equal to block.timestamp, and there is also a check to verify that block.timestamp is greater than or equal to $.periodFinish. Therefore, startTime cannot be below $.periodFinish, making this check unnecessary.

function _startYieldDistribution(uint256 yieldAmount, uint256 startTime, uint256 endTime)
    internal
    override
{
    IERC20 _asset = IERC20(asset());
 
    if (yieldAmount == 0) {
        revert ZeroYieldAmount();
    }
    if (startTime < block.timestamp) {
        revert StartTimeNotInFuture();
    }
    if (endTime <= startTime) {
        revert EndTimeNotAfterStartTime();
    }
 
    YieldDataStorage storage $ = _getYieldDataStorage();
 
    if (startTime < $.periodFinish) {
        revert StartTimeBeforePeriodFinish();
    }
    if (block.timestamp < $.periodFinish) {
        revert CurrentTimeBeforePeriodFinish();
    }
 
    _updateYield();
 
    uint256 periodDuration = endTime - startTime;
    uint256 newYieldRate =
        Math.mulDiv(yieldAmount, YIELD_PRECISION, periodDuration, Math.Rounding.Floor);
 
    if (_asset.balanceOf(address(this)) < $.totalDeposits + yieldAmount) {
        revert InsufficientAssetsForYield();
    }
 
    $.yieldRate = newYieldRate;
    $.periodStart = startTime;
    $.periodFinish = endTime;
    $.lastUpdateTime = startTime;
    $.isActive = true;
}

Remediation:

Remove the following check:

if (startTime < $.periodFinish) {
    revert StartTimeBeforePeriodFinish();
}

Table of contents