OpenEden Disclosed Report

Redemption Cancellation Results in shares Loss Due to Bonus Multiplier Mismatch

Company
Created date
Oct 12 2025

Target

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

Vulnerability Details

Summary

When redemption requests are cancelled by the maintainer, users receive fewer shares than they originally burned due to the reminting process using the current bonus multiplier instead of the original multiplier from when shares were burned. This results in permanent shares loss for users.

Description

The vulnerability exists in the interaction between the redeemRequest() and cancel() functions in the redemption queue system.

Flow of the Issue:

  1. User Requests Redemption: When a user calls redeemRequest(), their USD0 shares are burned:
USD0ExpressV2.sol:
function redeemRequest(address to, uint256 amt) external whenNotPausedRedeem {

 // @Cropped
    // Burn USDO from the user
  @>  _usdo.burn(from, amt);
  _redemptionInfo[to] += amt;

 // @Cropped
}
  1. Multiplier Increases: The bonus multiplier increases over time according to the protocol's mechanics or Admin updated the multipler:
USD0ExpressV2.sol:
    function addBonusMultiplier() external onlyRole(MULTIPLIER_ROLE) {
        if (_lastUpdateTS != 0) {
            if (block.timestamp < _lastUpdateTS + _timeBuffer) revert USDOExpressTooEarly(block.timestamp);
        }

   @>     _usdo.addBonusMultiplier(_increment);
        _lastUpdateTS = block.timestamp;
    }
USD0.sol:
    function updateBonusMultiplier(uint256 _bonusMultiplier) external onlyRole(DEFAULT_ADMIN_ROLE) {
        _updateBonusMultiplier(_bonusMultiplier);
    }
  1. Maintainer Cancels Redemption: When the maintainer cancels the redemption, tokens are reminted using the current higher multiplier:
USD0ExpressV2.sol:
function cancel(uint256 _len) external onlyRole(MAINTAINER_ROLE) {
 // @Cropped

        // Mint USDO back to the user
      
@>        _safeMintInternal(sender, usdoAmt);
 // @Cropped
 }
  1. Tokens Minted with Wrong Multiplier: The _safeMintInternal() function eventually calls _mint(), which converts the amount to shares using the current multiplier:
USD0.sol:
function convertToShares(uint256 amount) public view returns (uint256) {
 @>   return (amount * _BASE) / bonusMultiplier; // current multiplier
}
USD0.sol
function _mint(address to, uint256 amount) private {
   // @Cropped
   
    (bool isAllowed, uint256 newTotalSupply, uint256 shares) = @>> checkNewTotalSupply(amount);
    if (!isAllowed) {
        revert USDOExceedsTotalSupplyCap(newTotalSupply, _totalSupplyCap);
    }

@>    _totalShares += shares;

    unchecked {
@>        _shares[to] += shares;
    }

    _afterTokenTransfer(address(0), to, amount);
}


Root Cause: The redemption queue only stores the token amount but not the bonus multiplier at the time of burning. When reminting occurs, the current (higher) multiplier is used, resulting in fewer shares and thus fewer effective tokens for the user.

Impact

  1. Permanent shares Loss: Users lose shares proportional to the multiplier increase between burn and remint
  2. No User Control: Users have no control over:
    • Whether their redemption will be cancelled
    • How long it takes before cancellation
    • The multiplier increase or change during this period
  3. Breaks User Trust: Users requesting legitimate redemptions are penalized for protocol operational decisions

Mitigation

  1. store multiplier in the redeemRequest
  2. refactor or create another mint function which accept minting with specific multiplier

Validation steps

Attack path

  1. User Requests Redemption (tokens burnt)
  2. Time Passes & Multiplier Increases OR Admin directly updates
  3. Maintainer Cancels Redemption for any reason (Token Remint with new rate)

Proof of Concept

Paste the following test in USD0Express.ts::'Queue Redemption System'

 it('POC: User loses tokens when redemption is cancelled due to bonus multiplier increase', async function () {
      console.log('\n============MyTestLogsStartHere============');
     await usdoExpress.connect(maintainer).updateAPY(2000); // 20.00%
       
      await usdo.mint(whitelistedUser.address,ethers.utils.parseUnits('500', 18));
      const initialUserBalance = await usdo.balanceOf(whitelistedUser.address);
      console.log('Initial user USDO balance:', ethers.utils.formatUnits(initialUserBalance, 18));
      const initialSharesBalance = await usdo.sharesOf(whitelistedUser.address);
      console.log('Initial user shares balance:', ethers.utils.formatUnits(initialSharesBalance, 18));
      const initialMultiplier = await usdo.bonusMultiplier();
      console.log('Initial bonus multiplier:', initialMultiplier.toString());

      // User requests redemption - tokens are burned
      const redeemAmount = ethers.utils.parseUnits('1000', 18);
      await usdoExpress.connect(whitelistedUser).redeemRequest(whitelistedUser.address, redeemAmount);

      const balanceAfterRedeem = await usdo.balanceOf(whitelistedUser.address);
      console.log('User balance after redemption request:', ethers.utils.formatUnits(balanceAfterRedeem, 18));
      expect(balanceAfterRedeem).to.equal(0); // All tokens burned

      // Scenario 1: Normal daily multiplier increments (simulating time passing)
      console.log('\n--- Scenario 1: Normal Daily Increments (20% APY) ---');

      // Simulate 10 days of multiplier increases (assuming 20% APY )
      // With time buffer, operator calls addBonusMultiplier once per day
      for (let i = 0; i <= 10; i++) {
        await usdoExpress.connect(operator).addBonusMultiplier();
        // Advance time by 1 day to pass the time buffer check
        await ethers.provider.send('evm_increaseTime', [86400]); // 1 day
        await ethers.provider.send('evm_mine', []);
      }

      const multiplierAfter10Days = await usdo.bonusMultiplier();
      console.log('Multiplier after 10 days:', multiplierAfter10Days.toString());

      const multiplierIncrease10Days = multiplierAfter10Days.sub(initialMultiplier);
      const percentageIncrease10Days = multiplierIncrease10Days.mul(10000).div(initialMultiplier);
      console.log('Multiplier increase after 10 days:', ethers.utils.formatUnits(percentageIncrease10Days, 2), '%');

      // Maintainer cancels the redemption - tokens are reminted with NEW multiplier
      await usdoExpress.connect(maintainer).cancel(1);

      const UserBalanceAfter = await usdo.balanceOf(whitelistedUser.address);
      console.log(' USDO balance After:', ethers.utils.formatUnits(UserBalanceAfter, 18));
      const sharesBalanceAfter = await usdo.sharesOf(whitelistedUser.address);
      console.log('User shares after redemption request:', ethers.utils.formatUnits(sharesBalanceAfter, 18));
      
      
      const shareloss10Days = initialSharesBalance.sub(sharesBalanceAfter);
      const lossPercentage10Days = shareloss10Days.mul(10000).div(initialSharesBalance);
      console.log('share loss after 10 days:', ethers.utils.formatUnits(shareloss10Days, 18));
      console.log('Loss percentage:', ethers.utils.formatUnits(lossPercentage10Days, 2), '%');

      // User has lost shares due to multiplier increase
      expect(sharesBalanceAfter).to.be.lt(initialSharesBalance);
      console.log('\n--- Scenario 2: Admin Directly Updates Multiplier ---');
      const initialUserBalance2 = await usdo.balanceOf(whitelistedUser.address);
      console.log('User balance before second redemption request:', ethers.utils.formatUnits(initialUserBalance2, 18));
      const initialSharesBalance2 = await usdo.sharesOf(whitelistedUser.address);
      console.log('User shares balance before second redemption request:', ethers.utils.formatUnits(initialUserBalance2, 18));
      // Reset for scenario 2: Burn remaining tokens and request redemption again
      await usdoExpress.connect(whitelistedUser).redeemRequest(whitelistedUser.address, UserBalanceAfter);

      // Scenario 2: Admin directly updates multiplier (operational change)
     

      const currentMultiplier = await usdo.bonusMultiplier();
      console.log('Current multiplier before admin update:', currentMultiplier.toString());

      // Admin updates multiplier significantly (e.g., due to high APY update or direct manipulation)
      // Increase by another 5% (simulating APY change or direct admin action)
      const newMultiplier = currentMultiplier.mul(105).div(100); // 5% increase
      await usdo.connect(owner).updateBonusMultiplier(newMultiplier);

      const multiplierAfterAdminUpdate = await usdo.bonusMultiplier();
      console.log('Multiplier after admin update:', multiplierAfterAdminUpdate.toString());

      const totalMultiplierIncrease = multiplierAfterAdminUpdate.sub(initialMultiplier);
      const totalPercentageIncrease = totalMultiplierIncrease.mul(10000).div(initialMultiplier);
      console.log('Total multiplier increase from initial:', ethers.utils.formatUnits(totalPercentageIncrease, 2), '%');
  
      // Maintainer cancels this redemption too
      await usdoExpress.connect(maintainer).cancel(1);

      const finalBalance = await usdo.balanceOf(whitelistedUser.address);
      console.log('User balance after second cancellation:', ethers.utils.formatUnits(finalBalance, 18));
      const finalSharesBalance = await usdo.sharesOf(whitelistedUser.address);
      console.log('User shares after second cancellation:', ethers.utils.formatUnits(finalSharesBalance, 18));

      const totalsharesLoss = initialSharesBalance2.sub(finalSharesBalance);
      const totalLossPercentage = totalsharesLoss.mul(10000).div(initialSharesBalance2);
 
      console.log('Total shares loss:', ethers.utils.formatUnits(totalsharesLoss, 18));
      console.log('Total loss percentage:', ethers.utils.formatUnits(totalLossPercentage, 2), '%');
   

      // Verify significant loss occurred
      expect(finalBalance).to.be.lt(initialUserBalance);
      expect(finalSharesBalance).to.be.lt(initialSharesBalance2);
      expect(totalsharesLoss).to.be.gt(0);
      
    });

Run it via npx hardhat test --grep 'POC: User loses tokens when redemption is cancelled due to bonus multiplier increase'

Logs

============MyTestLogsStartHere============
Initial user USDO balance: 1000.0
Initial user shares balance: 1000.0
Initial bonus multiplier: 1000000000000000000
User balance after redemption request: 0.0

--- Scenario 1: Normal Daily Increments (20% APY) ---
Multiplier after 10 days: 1006027397260273972
Multiplier increase after 10 days: 0.6 %
 USDO balance After: 999.999999999999999999
User shares after redemption request: 994.008714596949891663
share loss after 10 days: 5.991285403050108337
Loss percentage: 0.59 %

--- Scenario 2: Admin Directly Updates Multiplier ---
User balance before second redemption request: 999.999999999999999999
User shares balance before second redemption request: 999.999999999999999999
Current multiplier before admin update: 1006027397260273972
Multiplier after admin update: 1056328767123287670
Total multiplier increase from initial: 5.63 %
User balance after second cancellation: 999.999999999999999999
User shares after second cancellation: 946.674966282809421169
Total shares loss: 47.333748314140470494
Total loss percentage: 4.76 %

Attachments

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