https://github.com/OpenEdenHQ/openeden.usdoexpress.audit/tree/f3f31d2ac15e3253cba342229f9d05495f95d6fd
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.
The vulnerability exists in the interaction between the redeemRequest() and cancel() functions in the redemption queue system.
Flow of the Issue:
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
}
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);
}
USD0ExpressV2.sol:
function cancel(uint256 _len) external onlyRole(MAINTAINER_ROLE) {
// @Cropped
// Mint USDO back to the user
@> _safeMintInternal(sender, usdoAmt);
// @Cropped
}
_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.
redeemRequestmint function which accept minting with specific multiplierUSD0Express.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);
});
npx hardhat test --grep 'POC: User loses tokens when redemption is cancelled due to bonus multiplier increase'============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 %