Overview
This audit focuses on the USD0++ Upgrade & Redemption Token for the Usual protocol. The upgrade introduces the bASSET0 and rt-ASSET0 tokens, representing the next iteration of the existing USD0++ design - a zero-coupon, bond-like instrument. Users enter the bond by deconstructing their principal into yield-bearing (bASSET0) and redemption (rt-ASSET0) tokens at a 1:1 ratio. Our security review spanned one week and included a thorough evaluation of all contracts updated or added in this release. No major vulnerabilities were discovered. We identified six informational-level issues during the assessment. All findings were either fixed or acknowledged by the development team and subsequently verified by our auditors. Af ter the remediation process, we conclude that the protocol's security posture and code quality have been significantly improved as a result of this audit.
Scope
The analyzed resources are located on:
https://github.com/usual-dao/core-protocol/tree/b533cbbb6af61e97d8971d9625cc73fc32758d94
The issues described in this report were fixed in the following commit:
https://github.com/usual-dao/core-protocol
Commit: a92f83a15c698347751ceb206e3cfa2c8ec51a5c
Summary
Weaknesses
This section contains the list of discovered weaknesses.
USL4-1 | INCONSISTENT USE OF MODIFIERS IN MINT FUNCTIONS
Severity:
Status:
Acknowledged
Path:
src/token/Usd0PP.sol#L197-L199
Description:
The mint(uint256 amountUsd0) and mint(uint256 amountUsd0, address bAssetRecipient, address rAssetRecipient) functions are both protected with the nonReentrant and whenNotPaused modifiers.
However, the mintWithPermit() function does not include these modifiers, resulting in inconsistent security and pause protection across the different minting pathways.
function mintWithPermit(uint256 amountUsd0, uint256 deadline, uint8 v, bytes32 r, bytes32 s)
external
{
Remediation:
Add the whenNotPaused and nonReentrant modifier to mintWithPermit() to ensure consistent pause behavior across all minting pathways.
function mintWithPermit(uint256 amountUsd0, uint256 deadline, uint8 v, bytes32 r, bytes32 s)
external
++ nonReentrant
++ whenNotPaused
{
USL4-2 | REDUNDANT PERMIT CALL IN UNLOCKUSD0PPWITHUSUALWITHPERMIT
Severity:
Status:
Fixed
Path:
src/token/Usd0PP.sol#L392-L402
Description:
In the Usd0PP.unlockUSD0ppWithUsualWithPermit() function, lines 392–402 invoke permit() to grant the contract an allowance to spend the caller's Usd0PP tokens:
function unlockUSD0ppWithUsualWithPermit(
uint256 usd0ppAmount,
uint256 maxUsualAmount,
PermitApproval calldata usualApproval,
PermitApproval calldata usd0ppApproval
) external {
Usd0PPStorageV0 storage $ = _usd0ppStorageV0();
// Execute the USUAL permit
try IERC20Permit(address($.usual))
.permit(
msg.sender,
address(this),
maxUsualAmount,
usualApproval.deadline,
usualApproval.v,
usualApproval.r,
usualApproval.s
) {}
catch {} // solhint-disable-line no-empty-blocks
// Execute the bUSD0 permit
try IERC20Permit(address(this))
.permit(
msg.sender,
address(this),
usd0ppAmount,
usd0ppApproval.deadline,
usd0ppApproval.v,
usd0ppApproval.r,
usd0ppApproval.s
) {}
catch {} // solhint-disable-line no-empty-blocks
// Call the standard unlock function
unlockUSD0ppWithUsual(usd0ppAmount, maxUsualAmount);
}
This second permit call is unnecessary. The underlying unlockUSD0ppWithUsual() function burns the caller's Usd0PP using the internal _burn() method, which deducts tokens directly from msg.sender and does not require any allowance. As a result, granting approval for Usd0PP via permit() provides no functional benefit and introduces redundant logic.
Remediation:
Consider removing the second permit call in function unlockUSD0ppWithUsualWithPermit().
USL4-3 | USE SCALAR_ONE INSTEAD OF HARD-CODED 1E18
Severity:
Status:
Acknowledged
Path:
src/token/Usd0PP.sol#L301-L303, src/token/Usd0PP.sol#L327
Description:
Several parts of the contract Usd0PP use the literal value 1e18 directly instead of the defined constant SCALAR_ONE. This leads to inconsistencies and makes future refactoring or scalar-related changes more error-prone.
Two instances where 1e18 is used directly include:
- Comparison of
newFloorPriceinsideupdateFloorPrice()
if (newFloorPrice > 1e18) {
revert FloorPriceTooHigh();
}
- Calculation of
usd0AmountinsideunlockUsd0ppFloorPrice()
uint256 usd0Amount = Math.mulDiv(usd0ppAmount, $.floorPrice, 1e18, Math.Rounding.Floor);
Using the hard-coded literal breaks the intended abstraction of the scalar system and may cause inconsistencies if SCALAR_ONE is ever modified or if the scaling logic needs to evolve.
Remediation:
Consider replacing all occurrences of 1e18 with the predefined constant SCALAR_ONE to ensure consistency.
USL4-4 | MISSING BOND-START CHECK IN _DECONSTRUCT() FUNCTION
Severity:
Status:
Acknowledged
Path:
src/token/Usd0PP.sol#L568-L592
Description:
In the previous contract version, the mint() function explicitly validated that the bond period had already begun before allowing any minting. The logic looked as follows:
function mint(uint256 amountUsd0) public nonReentrant whenNotPaused {
Usd0PPStorageV0 storage $ = _usd0ppStorageV0();
// revert if the bond period isn't started
if (block.timestamp < $.bondStart) {
revert BondNotStarted();
}
// revert if the bond period is finished
if (block.timestamp >= $.bondStart + BOND_DURATION_FOUR_YEAR) {
revert BondFinished();
}
// get the collateral token for the bond
$.usd0.safeTransferFrom(msg.sender, address(this), amountUsd0);
// mint the bond for the sender
_mint(msg.sender, amountUsd0);
}
This implementation correctly reverted when users attempted to mint before the bond period had started.
In the updated version, mint() delegates its core logic to the internal _deconstruct() function. However, _deconstruct() no longer includes the check to ensure that the current timestamp is past bondStart, only validating whether the bond period has already ended:
function _deconstruct(uint256 amountUsd0, address bAssetRecipient, address rAssetRecipient)
internal
{
Usd0PPStorageV0 storage $ = _usd0ppStorageV0();
if (amountUsd0 == 0) {
revert AmountIsZero();
}
// revert if the bond period is finished
if (block.timestamp >= $.bondStart + BOND_DURATION_FOUR_YEAR) {
revert BondFinished();
}
// get the collateral token for the bond
$.usd0.safeTransferFrom(msg.sender, address(this), amountUsd0);
// mint the bond token for the specified recipient
_mint(bAssetRecipient, amountUsd0);
// mint the redemption token for the specified recipient
$.rtusd0.mint(rAssetRecipient, amountUsd0);
emit Deconstructed(msg.sender, amountUsd0, bAssetRecipient, rAssetRecipient);
}
As a result, users are now able to mint before the bond period begins - behavior that differs from the previous design and likely violates the intended lifecycle of the bond.
Remediation:
Consider reintroducing the missing check:
if (block.timestamp < $.bondStart) {
revert BondNotStarted();
}
inside _deconstruct() function.
USL4-5 | REDUNDANT WHENNOTPAUSED MODIFIER USAGE IN USD0PP CONTRACT
Severity:
Status:
Acknowledged
Path:
src/token/Usd0PP.sol#mint(), src/token/Usd0PP.sol#mintWithPermit(), src/token/Usd0PP.sol#unwrap(), src/token/Usd0PP.sol#unwrapWithCap(), src/token/Usd0PP.sol#unwrapPegMaintainer(), src/token/Usd0PP.sol#unlockUsd0ppFloorPrice(), src/token/Usd0PP.sol#unlockUSD0ppWithUsual(), src/token/Usd0PP.sol#reconstruct()
Description:
In the Usd0PP contract, several public functions such as unwrap() and mint() explicitly include the whenNotPaused modifier. However, each of these functions ultimately calls _mint() or _burn(), both of which invoke the internal _update() function.
The _update() function in ERC20PausableUpgradeable already applies the whenNotPaused modifier:
// contract ERC20PausableUpgradeable
function _update(address from, address to, uint256 value)
internal
virtual
override
whenNotPaused
{
super._update(from, to, value);
}
As a result, the pause check is executed twice once at the beginning of the public function and again during the ERC-20 token state update. This redundancy unnecessarily increases gas consumption without adding any additional safety.
Remediation:
Consider removing the whenNotPaused modifier from the public functions that rely on _mint() or _burn(), since the underlying logic already enforces the pause constraint.
USL4-6 | PAUSE() AND TOTALBONDTIMES() CAN BE MARKED EXTERNAL INSTEAD OF PUBLIC
Severity:
Status:
Acknowledged
Path:
src/token/Usd0PP.sol#L166, src/token/Usd0PP.sol#L485
Description:
In the Usd0PP contract, the functions pause() and totalBondTimes() are currently declared as public. However, neither of these functions is called internally within the contract.
function pause() public {
function totalBondTimes() public pure returns (uint256) {
Remediation:
Because they are only intended for external use, it is more efficient to mark them as external instead of public. Declaring them as external can reduce gas costs when invoked and more accurately reflects their intended usage.