https://github.com/OpenEdenHQ/openeden.usdoexpress.audit/tree/f3f31d2ac15e3253cba342229f9d05495f95d6fd
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.”
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.totalSupply (token units).sharesToMint = amount * 1e18 / bonusMultipliernewTotal = (totalShares + sharesToMint) * bonusMultiplier / 1e18require(newTotal <= tokenCap)expressCap != tokenCap, or the bonusMultiplier is changed (governance action), the two views can differ. This allows the scenario:
totalSupply + amount <= expressCap.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).
bonusMultiplier or caps are updated over time.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.”
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.f3f31d2ac15e3253cba342229f9d05495f95d6fd.test/CapMismatch_Real.t.sol inside the repo.forge test -vv
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.// 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);
}
}
[⠰] 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)
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.