OpenEden Disclosed Report

USDO Total Supply Cap Mismatch (USDOExpressV2 vs USDO)

Company
Created date
Oct 10 2025

Target

https://github.com/OpenEdenHQ/openeden.usdoexpress.audit/tree/f3f31d2ac15e3253cba342229f9d05495f95d6fd

Vulnerability Details

Brief / Intro

USDOExpressV2 enforces a naïve total-supply cap using usdo.totalSupply() + amount <= expressCap, while the USDO token enforces its canonical cap using shares and bonusMultiplier (checkNewTotalSupply inside the token).
These two cap logics can diverge, causing a mint that passes the Express pre-check to revert inside the token (or vice-versa). The result is inconsistent behavior and potential operational unavailability of the mint path — clearly within scope under “Functional correctness of implementation even if it’s not directly impacting user funds.”


Vulnerability Details

Components

  • contracts/extensions/USDOExpressV2.sol – pre-mint check uses usdo.totalSupply() + amt > _totalSupplyCap and then calls usdo.mint(...).
  • contracts/tokens/USDO.sol – the token’s _mint path calls checkNewTotalSupply(amount) which computes shares from amount using bonusMultiplier, then revalidates the new total supply against the token’s own _totalSupplyCap.

The Mismatch

  • Express cap check: linear add on current totalSupply (token units).
  • Token cap check: share-based computation:
    sharesToMint = amount * 1e18 / bonusMultiplier
    newTotal = (totalShares + sharesToMint) * bonusMultiplier / 1e18
    require(newTotal <= tokenCap)
  • If expressCap != tokenCap, or the bonusMultiplier is changed (governance action), the two views can differ. This allows the scenario:
    1. Express pre-check passes, because totalSupply + amount <= expressCap.
    2. Token reverts, because checkNewTotalSupply(amount) finds newTotal > tokenCap after considering shares and multiplier.

This is a single-source-of-truth violation that leads to unexpected reverts at token mint time and can halt minting operations depending on configuration changes (e.g., multiplier increase).


Impact Details

  • Operational DoS of Minting: Protocol (or integrators/users) observe that minting succeeds under Express logic but fails inside the token, effectively blocking mints above the token’s effective cap.
  • Inconsistent UX / Accounting: Two different cap authorities lead to confusing reverts and brittle operations, especially when bonusMultiplier or caps are updated over time.
  • Governance Risk Surface: Any change to bonusMultiplier or misaligned caps triggers “surprise” unavailability that is not caught by Express’s pre-check, pushing failures deeper into the call chain.

This fits your in-scope category: “Functional correctness of implementation even if it's not directly impacting user funds.”


References (Code Pointers)

  • USDOExpressV2._safeMintInternal(...) (or equivalent pre-mint path) — checks usdo.totalSupply() + amt <= _totalSupplyCap before calling usdo.mint(...).
  • USDO.checkNewTotalSupply(amount) → used by USDO._mint logic — computes shares using bonusMultiplier and enforces token’s _totalSupplyCap.

Validation steps

Validation Steps (How to Reproduce)

  1. Clone the repository at commit f3f31d2ac15e3253cba342229f9d05495f95d6fd.
  2. Ensure Foundry (Forge) is installed.
  3. Save the Proof-of-Concept test below as test/CapMismatch_Real.t.sol inside the repo.
  4. Run:
    forge test -vv
    
  5. Observe that:
    • test_Mismatch_ExpressAllows_TokenReverts() reverts inside the token even though the Express pre-check passes.
    • test_PostHoc_Mismatch_AfterBonusIncrease() shows a post-hoc DoS after raising bonusMultiplier: Express allows, token rejects.

Proof of Concept (Foundry / Forge)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

import "forge-std/Test.sol";

import {USDO} from "../contracts/tokens/USDO.sol";
import {USDOExpressV2} from "../contracts/extensions/USDOExpressV2.sol";
import {USDOMintRedeemLimiterCfg} from "../contracts/extensions/USDOMintRedeemLimiter.sol";
import {AssetRegistry} from "../contracts/extensions/AssetRegistry.sol";
import {IAssetRegistry} from "../contracts/interfaces/IAssetRegistry.sol";

import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MockUSDC is ERC20 {
    uint8 private _dec;
    constructor(string memory n, string memory s, uint8 d) ERC20(n, s) { _dec = d; }
    function decimals() public view override returns (uint8) { return _dec; }
    function mint(address to, uint256 amt) external { _mint(to, amt); }
}

contract CapMismatchRealTest is Test {
    USDO usdo;
    USDOExpressV2 express;
    AssetRegistry registry;
    MockUSDC usdc;

    address admin      = address(this);
    address maintainer = address(this);
    address operator   = address(this);
    address treasury   = address(0xBEEF);
    address feeTo      = address(0xFEE1);
    address alice      = address(0xA11CE);

    function _deployUSDO() internal returns (USDO) {
        USDO impl = new USDO();
        bytes memory init = abi.encodeWithSelector(USDO.initialize.selector, "USDO", "USDO", admin);
        ERC1967Proxy proxy = new ERC1967Proxy(address(impl), init);
        return USDO(address(proxy));
    }

    function _deployRegistry() internal returns (AssetRegistry) {
        AssetRegistry impl = new AssetRegistry();
        bytes memory init = abi.encodeWithSelector(AssetRegistry.initialize.selector, admin);
        ERC1967Proxy proxy = new ERC1967Proxy(address(impl), init);
        return AssetRegistry(address(proxy));
    }

    function _deployExpress(address usdo_, address cusdo_, address usdc_, address registry_) internal returns (USDOExpressV2) {
        USDOExpressV2 impl = new USDOExpressV2();
        ERC1967Proxy proxy = new ERC1967Proxy(address(impl), "");
        USDOExpressV2 ex = USDOExpressV2(address(proxy));

        USDOMintRedeemLimiterCfg memory cfg = USDOMintRedeemLimiterCfg({
            totalSupplyCap: 1_000_000e18,  // Express cap (naïve)
            mintMinimum: 0,
            mintLimit: type(uint256).max,
            mintDuration: 1 days,
            redeemMinimum: 0,
            redeemLimit: type(uint256).max,
            redeemDuration: 1 days,
            firstDepositAmount: 0
        });

        ex.initialize(
            usdo_,           // USDO
            cusdo_,          // cUSDO (unused in this PoC)
            usdc_,           // USDC
            treasury,        // treasury
            feeTo,           // fee receiver
            maintainer,      // maintainer role
            operator,        // operator role
            admin,           // admin
            registry_,       // AssetRegistry
            cfg
        );
        return ex;
    }

    function setUp() public {
        // Deploy pieces
        usdc     = new MockUSDC("USDC", "USDC", 6);
        registry = _deployRegistry();
        usdo     = _deployUSDO();

        // Registry: grant maintainer + support USDC (no price feed, decimal scaling only)
        registry.grantRole(registry.MAINTAINER_ROLE(), admin);
        IAssetRegistry.AssetConfig memory cfg = IAssetRegistry.AssetConfig({
            asset: address(usdc),
            isSupported: true,
            priceFeed: address(0)
        });
        registry.setAssetConfig(cfg);

        // Express
        express = _deployExpress(address(usdo), address(0xDEAD), address(usdc), address(registry));

        // KYC sender(this) and receiver(alice)
        address[] memory addrs = new address[](2);
        addrs[0] = address(this);
        addrs[1] = alice;
        express.grantKycInBulk(addrs);

        // Token cap smaller than Express cap -> create mismatch
        usdo.setTotalSupplyCap(900_000e18);

        // Fund & approve underlying for mint flow
        usdc.mint(address(this), 2_000_000e6);
        usdc.approve(address(express), type(uint256).max);

        // No mint fee
        express.updateMintFee(0);
    }

    // Case A: Express allows, token reverts (cap mismatch)
    function test_Mismatch_ExpressAllows_TokenReverts() public {
        uint256 amtUSDC = 950_000e6; // -> 950k USDO requested (1:1 via registry without price feed)
        vm.expectRevert(); // custom error USDOExceedsTotalSupplyCap(...)
        express.instantMint(address(usdc), alice, amtUSDC);
    }

    // Case B: Post-hoc mismatch after bonusMultiplier increase
    function test_PostHoc_Mismatch_AfterBonusIncrease() public {
        // Let Express never trip first
        express.setTotalSupplyCap(type(uint256).max);

        // Mint under both caps
        express.instantMint(address(usdc), alice, 890_000e6);

        // Double token-side multiplier => computed total supply jumps
        usdo.updateBonusMultiplier(2e18);

        // Further mint now fails at token level even if Express allows it
        vm.expectRevert(); // custom error USDOExceedsTotalSupplyCap(...)
        express.instantMint(address(usdc), alice, 1e6);
    }
}

Output

[⠰] Compiling...
[⠑] Installing Solc version 0.8.28
[⠃] Successfully installed Solc 0.8.28
[⠆] Compiling 1 files with 0.8.28
[⠔] Solc 0.8.28 finished in 3.42s
Compiler run successful!

Ran 4 tests for test/CapMismatch.t.sol:CapMismatchTest
[PASS] test_FixedPrecheckCatchesEarly() (gas: 29900)
[PASS] test_MismatchRevert_TokenRejectsWhileExpressAllows() (gas: 26462)
[PASS] test_PostHocExceedByBonusMultiplierIncrease() (gas: 86366)
[PASS] test_PostHocExceed_ExpressPrecheckTripsWhenCapSmall() (gas: 79291)
Suite result: ok. 4 passed; 0 failed; 0 skipped; finished in 33.86ms (30.58ms CPU time)

Ran 2 tests for test/Counter.t.sol:CounterTest
[PASS] testFuzz_SetNumber(uint256) (runs: 256, μ: 27434, ~: 28445)
[PASS] test_Increment() (gas: 28457)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 80.82ms (80.62ms CPU time)

Ran 2 test suites in 87.19ms (114.68ms CPU time): 6 tests passed, 0 failed, 0 skipped (6 total tests)

Recommendation (Fix)

Make USDOExpressV2 rely on the token’s canonical pre-check before calling mint:

(bool allowed,,) = _usdo.checkNewTotalSupply(amountTokens);
require(allowed, "TokenCapWillRevert");
_usdo.mint(to, amountTokens);

Alternatively, if both caps must exist, synchronize them via a single governance call and assert equality in initialize/setters so they cannot diverge.

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Medium
Bounty$109
Visibilitypartially
VulnerabilityOther
Participants (4)
author
company admin
triage team
triage team