https://github.com/OpenEdenHQ/openeden.usdoexpress.audit/tree/f3f31d2ac15e3253cba342229f9d05495f95d6fd
The system tracks the “first deposit” on the contract’s own address during wrap, so after one large wrap happens, everyone else can do smaller first deposits via the same path and skip the big first-deposit requirement.
The protocol intends to enforce a larger “first deposit” threshold per user, then a smaller standard minimum for subsequent deposits. The mapping that tracks whether a user has made a first deposit is _firstDeposit[address]. In instantMintAndWrap, the mint is performed to address(this) and only then wrapped to the external recipient. This sets _firstDeposit[address(this)] = true on the first call, not _firstDeposit[recipient]. After that one call, future instantMintAndWrap calls for any recipient only hit the smaller “mint minimum” branch. This violates the product invariant that every new user must satisfy the higher first-deposit threshold. It is a clean, input-reachable logic flaw, requires no special roles beyond KYC, and is fully compatible with mainnet conditions. Functional correctness is explicitly in scope for this program, which aligns this issue with accepted impacts.
A. The vulnerable check is performed on the wrong address
function _instantMintInternal(
address underlying,
address from,
address to,
uint256 amt
) internal returns (uint256, uint256) {
// if the user has not deposited before, the first deposit amount should be set
// if the user has deposited before, the mint amount should be greater than the mint minimum
// do noted: the first deposit amount will be greater than the mint minimum
if (!_firstDeposit[to]) {
if (amt < _firstDepositAmount) revert FirstDepositLessThanRequired(amt, _firstDepositAmount);
_firstDeposit[to] = true;
} else {
if (amt < _mintMinimum) revert MintLessThanMinimum(amt, _mintMinimum);
}
(uint256 netAmt, uint256 fee, uint256 usdoAmtCurr, ) = previewMint(underlying, amt);
_checkMintLimit(usdoAmtCurr);
if (fee > 0) SafeERC20Upgradeable.safeTransferFrom(IERC20Upgradeable(underlying), from, _feeTo, fee);
SafeERC20Upgradeable.safeTransferFrom(IERC20Upgradeable(underlying), from, address(_treasury), netAmt);
_safeMintInternal(to, usdoAmtCurr);
return (usdoAmtCurr, fee);
}
This gate keys off _firstDeposit[to]. The correctness of “to” is everything here.
B. The wrap flow sets “to” to the contract, not the end user
function instantMintAndWrap(address underlying, address to, uint256 amt) external whenNotPausedMint {
address from = _msgSender();
if (!_kycList[from] || !_kycList[to]) revert USDOExpressNotInKycList(from, to);
(uint256 usdoAmtCurr, uint256 fee) = _instantMintInternal(underlying, from, address(this), amt);
_usdo.approve(address(_cusdo), usdoAmtCurr);
uint256 cusdoAmt = _cusdo.deposit(usdoAmtCurr, to);
emit InstantMint(underlying, from, to, amt, usdoAmtCurr, fee);
emit InstantMintAndWrap(underlying, from, to, amt, usdoAmtCurr, cusdoAmt, fee);
}
Here, the mint is performed with to = address(this), which sets _firstDeposit[address(this)] = true during the first call that satisfies the first-deposit amount. Later calls never re-check the recipient’s personal first-deposit branch.
C. The non-wrap flow shows intended behavior for contrast
function instantMint(address underlying, address to, uint256 amt) external whenNotPausedMint {
address from = _msgSender();
if (!_kycList[from] || !_kycList[to]) revert USDOExpressNotInKycList(from, to);
(uint256 usdoAmtCurr, uint256 fee) = _instantMintInternal(underlying, from, to, amt);
emit InstantMint(underlying, from, to, amt, usdoAmtCurr, fee);
}
This passes the end user’s address directly to _instantMintInternal, so the first-deposit check is per user. The wrap path is the outlier.
D. The mapping that records “first deposit” is global and public
mapping(address => bool) public _firstDeposit;
Which confirms the design is “per recipient address”. The bug is that the recipient address for the gate is the contract itself during wrap.
This breaks a key onboarding invariant. The product intends to gate each user’s first deposit at a higher threshold to manage compliance and liquidity risk. Once any KYC’d user completes a single large instantMintAndWrap, the entire system moves into a state where all future wrap-based first deposits by new users can be below _firstDepositAmount. This undermines policy, weakens risk controls, and creates inconsistent enforcement across entry points. It also increases support overhead because the same business rule appears enforced for instantMint, yet silently weakened for instantMintAndWrap. The mismatch can be exploited at scale by regular users and will persist until code is changed.
Manual Review.
Enforce the first-deposit rule against the end recipient on the wrap path.
Option 1. Pre-check the recipient before minting to the contract. In instantMintAndWrap, assert the first-deposit requirement on _firstDeposit[to] prior to calling _instantMintInternal, then proceed with the current mint-to-contract and wrap flow. This leaves storage layout untouched and is the least invasive.
Option 2. Pass the recipient into _instantMintInternal and adjust the wrap. Call _instantMintInternal(underlying, from, to, amt) so the first-deposit check keys off the user. After minting to the user, move the freshly minted USDO from the user to the contract and deposit into cUSDO for the user. This can be done with an allowance or permit on USDO. It preserves the invariant across both paths.
Option 3. Extend _instantMintInternal with an explicit accountForFirstDepositCheck. Use the user’s address for the check while still minting to address(this) for operational reasons. This makes the intent unambiguous.
Any of these changes realigns the code with the documented behavior and eliminates the systemic bypass.
Validation steps
_firstDepositAmount > _mintMinimum. Ensure two KYC’d EOAs, Alice and Bob. No special roles needed.instantMintAndWrap(underlying, Alice, amtA) with amtA >= _firstDepositAmount.
Result. Inside _instantMintInternal, to = address(this). Since _firstDeposit[address(this)] is false the first time, the call requires amtA >= _firstDepositAmount and then sets _firstDeposit[address(this)] = true. Events InstantMint and InstantMintAndWrap fire.instantMintAndWrap(underlying, Bob, amtB) with _mintMinimum <= amtB < _firstDepositAmount.
Result. _instantMintInternal again uses to = address(this). _firstDeposit[address(this)] is already true, so only the _mintMinimum check runs. Bob completes a smaller first deposit through wrap, contrary to policy. Events fire normally.instantMint (no wrap), he would correctly hit the first-deposit branch on his own address and be required to meet _firstDepositAmount. This shows the bypass exists only in the wrap path.Test Code
function test_FirstDepositBypass_InstantMintAndWrap() public {
address alice = address(0xA11CE);
address bob = address(0xB0B);
uint256 firstDepositAmt = 1_000e6;
uint256 mintMinimum = 100e6;
vm.startPrank(maintainer);
usdoExpress.setFirstDepositAmount(firstDepositAmt);
usdoExpress.setMintMinimum(mintMinimum);
usdoExpress.setKyc(alice, true);
usdoExpress.setKyc(bob, true);
vm.stopPrank();
deal(address(usdc), alice, firstDepositAmt, true);
deal(address(usdc), bob, mintMinimum, true);
vm.startPrank(alice);
IERC20(address(usdc)).approve(address(usdoExpress), type(uint256).max);
vm.stopPrank();
vm.startPrank(bob);
IERC20(address(usdc)).approve(address(usdoExpress), type(uint256).max);
vm.stopPrank();
vm.prank(alice);
usdoExpress.instantMintAndWrap(address(usdc), alice, firstDepositAmt);
assertTrue(usdoExpress._firstDeposit(address(usdoExpress)));
assertFalse(usdoExpress._firstDeposit(alice));
vm.prank(bob);
usdoExpress.instantMintAndWrap(address(usdc), bob, mintMinimum);
vm.prank(bob);
vm.expectRevert(USDOExpressV2.FirstDepositLessThanRequired.selector);
usdoExpress.instantMint(address(usdc), bob, mintMinimum);
}
Key insights that establish validity: