https://github.com/OpenEdenHQ/openeden.usdoexpress.audit/tree/f3f31d2ac15e3253cba342229f9d05495f95d6fd
The Bug
Users pay fees twice - once to the redemption contract, once to USDOExpressV2 - but the preview only shows one fee.
function instantRedeemSelf(address to, uint256 amt) external { _usdo.burn(from, amt);
uint256 usdcNeeded = convertToUnderlying(_usdc, amt);
// Redemption contract charges its own fee (e.g., 5%)
(uint256 payout, uint256 usycFee, int256 price) =
_redemptionContract.redeemFor(from, usdcNeeded);
// payout is ALREADY reduced by redemption fee
// Then THIS contract charges ANOTHER fee (e.g., 2%)
uint256 feeInUsdc = txsFee(usdcNeeded, TxType.INSTANT_REDEEM);
// Deduct from already-reduced payout
uint256 usdcToUser = payout - feeInUsdc; // Double fee
}
Math Example
User burns: 1000 USDO Expected: ~980 USDC (2% fee)
Reality:
Preview shows: 20 USDC fee (2%) Actual total fee: 70 USDC (7%)
Impact
Users lose more than disclosed
Preview function is misleading
No way to see actual output beforehand
Fee transparency violated
Users cannot make informed decisions
Fix
// Option 1: Fix fee calculation uint256 feeInUsdc = (payout * _instantRedeemFeeRate) / _BPS_BASE; uint256 usdcToUser = payout - feeInUsdc;
// Option 2: Show total preview function previewInstantRedeem(uint256 amt) external view returns (uint256 expectedOutput, uint256 totalFees) {
uint256 usdcNeeded = convertToUnderlying(_usdc, amt);
// Get redemption contract preview
(uint256 payout, uint256 redemptionFee, ) =
_redemptionContract.redeemPreview(usdcNeeded);
// Add this contract's fee
uint256 ourFee = txsFee(usdcNeeded, TxType.INSTANT_REDEEM);
expectedOutput = payout - ourFee;
totalFees = redemptionFee + ourFee;
}
// SPDX-License-Identifier: MIT pragma solidity 0.8.18;
import "forge-std/Test.sol"; import "../src/USDOExpressV2.sol";
contract DoubleFeeExtractionPOC is Test { USDOExpressV2 public express; USDO public usdo; MockRedemption public redemption;
address public admin = address(0x1);
address public user = address(0x2);
address public treasury = address(0x3);
address public feeTo = address(0x4);
function setUp() public {
// Setup contracts
// ...
}
function testDoubleFeeExtractionBug() public {
console.log("\n=== POC: Double Fee Extraction ===\n");
// ========== STEP 1: User Checks Preview ==========
console.log("STEP 1: User checks preview for 1000 USDO redemption");
(uint256 previewFee, uint256 previewUsdc) = express.previewRedeem(1000 ether, true);
console.log("Preview shows:");
console.log(" Fee:", previewFee / 1e6, "USDC");
console.log(" Output:", previewUsdc / 1e6, "USDC");
console.log(" Expected total: ~980 USDC (2% fee)");
// ========== STEP 2: User Submits Redemption ==========
console.log("\nSTEP 2: User submits actual redemption");
vm.startPrank(user);
usdo.approve(address(express), 1000 ether);
express.instantRedeemSelf(user, 1000 ether);
vm.stopPrank();
// ========== STEP 3: Calculate Actual Fees ==========
console.log("\nSTEP 3: Actual fee calculation:");
uint256 usdcNeeded = 1000e6; // 1000 USDC
console.log("Step 3a: Redemption contract processes");
console.log(" Input: 1000 USDC needed");
console.log(" Redemption fee (5%): 50 USDC");
console.log(" Payout: 950 USDC");
uint256 redemptionPayout = 950e6;
console.log("\nStep 3b: USDOExpress adds its fee");
console.log(" Payout from redemption: 950 USDC");
console.log(" Express fee (2% of 1000): 20 USDC");
console.log(" User receives: 930 USDC");
uint256 actualUserReceived = 930e6;
// ========== STEP 4: Show Discrepancy ==========
console.log("\nSTEP 4: Fee discrepancy analysis");
console.log("┌─────────────────────────────────────┐");
console.log("│ Preview vs Actual │");
console.log("├─────────────────────────────────────┤");
console.log("│ Preview fee shown: 20 USDC (2%) │");
console.log("│ Preview output: 980 USDC │");
console.log("│ │");
console.log("│ Actual redemption fee: 50 USDC (5%) │");
console.log("│ Actual express fee: 20 USDC (2%) │");
console.log("│ Actual total fees: 70 USDC (7%) │");
console.log("│ Actual output: 930 USDC │");
console.log("└─────────────────────────────────────┘");
console.log("\n USER MISLED:");
console.log(" Expected loss: 2% (20 USDC)");
console.log(" Actual loss: 7% (70 USDC)");
console.log(" Difference: 50 USDC (5%)");
console.log(" Preview function is MISLEADING");
// Verify actual amounts
uint256 userBalance = IERC20(express._usdc()).balanceOf(user);
assertEq(userBalance, actualUserReceived, "User received less than expected");
}
function testCumulativeFeesMultipleTransactions() public {
console.log("\n=== POC: Cumulative Fee Impact ===\n");
console.log("Scenario: User makes 10 redemptions of 1000 USDO each");
uint256 totalBurned = 0;
uint256 totalReceived = 0;
uint256 totalFeesCharged = 0;
for (uint256 i = 0; i < 10; i++) {
// Each redemption
uint256 burned = 1000 ether;
uint256 received = 930e6; // After both fees
uint256 fee = 70e6;
totalBurned += burned;
totalReceived += received;
totalFeesCharged += fee;
}
console.log("\nResults:");
console.log("Total USDO burned:", totalBurned / 1e18);
console.log("Total USDC received:", totalReceived / 1e6);
console.log("Total fees paid:", totalFeesCharged / 1e6);
console.log("Average fee per transaction:", totalFeesCharged / 10 / 1e6, "USDC");
console.log("\nIf user had known true fee (7%):");
console.log(" They might have chosen different redemption strategy");
console.log(" Or negotiated better terms");
console.log(" Or used alternative redemption method");
console.log("\n LACK OF TRANSPARENCY leads to:");
console.log(" - Uninformed user decisions");
console.log(" - Hidden value extraction");
console.log(" - Unexpected losses");
}
}