Status DataClose notification

OpenEden Disclosed Report

First-deposit minimum bypass in `instantMintAndWrap` path

Company
Created date
Oct 12 2025

Target

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

Vulnerability Details

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.

Summary

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.


Vulnerability Details

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.


Impact

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.

Tools Used

Manual Review.

Recommendation

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

Validation steps

  1. Precondition. Assume _firstDepositAmount > _mintMinimum. Ensure two KYC’d EOAs, Alice and Bob. No special roles needed.
  2. Step 1. Alice calls 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.
  3. Step 2. Bob calls 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.
  4. Observation. If Bob instead uses 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:

  • The reachability of the vulnerable line is trivial. KYC checks are explicit and satisfied by ordinary users. No special roles, no oracles, no timing.
  • The first-deposit gate runs before transfers and wrapping. There is no later per-user recheck in the wrap path.
  • The program’s scope accepts functional correctness and “fails to deliver promised behavior” issues. This is precisely that.

Attachments

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