OpenEden Disclosed Report

Double Fee Without Disclosure

Company
Created date
Oct 11 2025

Target

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

Vulnerability Details

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:

  1. Redemption contract fee (5%): 1000 * 0.95 = 950 USDC payout
  2. This contract's fee (2%): 1000 * 0.02 = 20 USDC
  3. User receives: 950 - 20 = 930 USDC
  4. Total loss: 70 USDC (7%)

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;

}

Validation steps

// 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");
}

}

Attachments

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