//
VirtualToken's CashIn Function and Router's Functionalities Break When UnderlyingToken is an ERC20
ZhengZuo999 profile imageZhengZuo999
High

[H-1] VirtualToken's CashIn Function and Router's Functionalities Break When UnderlyingToken is an ERC20

Description: The VirtualToken contract allows its underlyingToken to be set as either a native token or an ERC20 token address during deployment:

constructor( string memory name, string memory symbol, @> address _underlyingToken ) ERC20(name, symbol) Ownable(msg.sender) { require(_underlyingToken != address(0), "Invalid underlying token address"); @> underlyingToken = _underlyingToken; }

While VirtualToken::_transferAssetFromUser and VirtualToken::_transferAssetToUser functions correctly handle both token types:

function _transferAssetFromUser(uint256 amount) internal { if (underlyingToken == LaunchPadUtils.NATIVE_TOKEN) { require(msg.value >= amount, "Invalid ETH amount"); } else { @> IERC20(underlyingToken).safeTransferFrom(msg.sender, address(this), amount); } } function _transferAssetToUser(uint256 amount) internal { if (underlyingToken == LaunchPadUtils.NATIVE_TOKEN) { require(address(this).balance >= amount, "Insufficient ETH balance"); (bool success, ) = msg.sender.call{value: amount}(""); require(success, "Transfer failed"); } else { @> IERC20(underlyingToken).safeTransfer(msg.sender, amount); } }

The VirtualToken::cashIn function contains a critical flaw - it only mints virtual tokens based on msg.value regardless of the underlying token type:

function cashIn(uint256 amount) external payable onlyWhiteListed { if (underlyingToken == LaunchPadUtils.NATIVE_TOKEN) { require(msg.value == amount, "Invalid ETH amount"); } else { _transferAssetFromUser(amount); } @> _mint(msg.sender, msg.value); @> emit CashIn(msg.sender, msg.value); }

This creates two serious issues when the underlying token is ERC20:

  1. A whitelisted caller must send both X amount of ETH and X amount of underlying token to mint X amount of virtual tokens
  2. If the caller only sends the ERC20 tokens, no virtual tokens will be minted

Both cases resulting in lost funds

Additionally, the LamboVEthRouter contract assumes the underlying token is always native ETH. This assumption manifests in both the LamboVEthRouter::_buyQuote and LamboVEthRouter::_sellQuote functions.

In LamboVEthRouter::_buyQuote function:

require(msg.value >= amountXIn, "Insufficient msg.value"); // handle fee uint256 fee = (amountXIn * feeRate) / feeDenominator; amountXIn = amountXIn - fee; (bool success, ) = payable(owner()).call{value: fee}(""); require(success, "Transfer to Owner failed");

In LamboVEthRouter::_sellQuote function:

// handle amountOut (bool success, ) = msg.sender.call{value: amountXOut}(""); require(success, "Transfer to User failed"); // handle fee (success, ) = payable(owner()).call{value: fee}(""); require(success, "Transfer to owner() failed");

Impact:
when VirtualToken::underlyingToken is an ERC20 token, several critical issues arise:

  1. Liquidity providers cannot mint virtual tokens through VirtualToken::cashIn function, resulting in lost funds despite transferring the correct amount of underlying ERC20 tokens.
  2. Users cannot swap for quote tokens via LamboVEthRouter::buyQuote as it will revert with "Insufficient msg.value".
  3. Even if users provide both ETH and ERC20 tokens, the transaction will still fail as the LamboVEthRouter cannot transfer the virtualToken's underlying ERC20 token on user's behalf.
  4. While user can obtain the virtual token first, do the swap through UniswapV2Router02::swapExactTokensForTokens to get quoteToken, they won't be able to cash out the quoteToken through LamboVEthRouter::sellQuote, as it attempts to transfer ETH instead of the underlying ERC20 token.

Proof of Concept:
First, install a package to use UniswapV2Router02 in the test:

    forge install Uniswap/v2-periphery --no-commit

Create the test script under test/audit/ERC20VTokenTest.t.sol:

// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {Test, console2} from "forge-std/Test.sol"; import {VirtualToken} from "src/VirtualToken.sol"; import {LamboFactory} from "src/LamboFactory.sol"; import {LamboToken} from "src/LamboToken.sol"; import {LamboVEthRouter} from "src/LamboVEthRouter.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IUniswapV2Router02} from "lib/v2-periphery/contracts/interfaces/IUniswapV2Router02.sol"; contract ERC20VTokenTest is Test { VirtualToken public vDai; LamboFactory public factory; LamboVEthRouter public lamboRouter; LamboToken public lamboTokenV2; address public player = makeAddr("player"); address public multiSigAdmin = makeAddr("multiSigAdmin"); address public v3PoolLP = makeAddr("v3PoolLP"); address constant UNISWAP_V2_ROUTER_02 = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D; IUniswapV2Router02 router02 = IUniswapV2Router02(UNISWAP_V2_ROUTER_02); // DAI address on eth mainnet address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; IERC20 dai = IERC20(DAI); IERC20 token; uint256 constant PLAYER_INITIAL_DAI_BALANCE = 10e18; uint256 constant PLAYER_INITIAL_ETH_BALANCE = 10e18; uint256 constant LP_INITIAL_DAI_BALANCE = 1000000e18; uint256 constant LP_INITIAL_ETH_BALANCE = 1000000e18; function setUp() public virtual { // ankr eth mainnet vm.createSelectFork("https://rpc.ankr.com/eth"); deal(player, PLAYER_INITIAL_ETH_BALANCE); deal(DAI, player, PLAYER_INITIAL_DAI_BALANCE); deal(v3PoolLP, LP_INITIAL_ETH_BALANCE); deal(DAI, v3PoolLP, LP_INITIAL_DAI_BALANCE); lamboTokenV2 = new LamboToken(); vm.startPrank(multiSigAdmin); vDai = new VirtualToken("vETH", "vETH", DAI); factory = new LamboFactory(address(lamboTokenV2)); vm.stopPrank(); lamboRouter = new LamboVEthRouter(address(vDai), multiSigAdmin); vm.startPrank(multiSigAdmin); vDai.updateFactory(address(factory), true); vDai.addToWhiteList(address(lamboRouter)); vDai.addToWhiteList(address(v3PoolLP)); factory.addVTokenWhiteList(address(vDai)); vm.stopPrank(); } // Liquidity providers cannot mint virtual tokens through VirtualToken::cashIn, // resulting in lost funds despite transferring the correct amount of underlying ERC20 tokens function test_cashInCauseLossOfFund() public { assertEq(dai.balanceOf(v3PoolLP), LP_INITIAL_DAI_BALANCE); vm.startPrank(v3PoolLP); dai.approve(address(vDai), LP_INITIAL_DAI_BALANCE); vDai.cashIn(LP_INITIAL_DAI_BALANCE); // v3PoolLP has lost all of his DAI token assertEq(dai.balanceOf(v3PoolLP), 0); // v3PoolLP has not received a single vDai token assertEq(vDai.balanceOf(v3PoolLP), 0); vm.stopPrank(); } // Users cannot swap for quote tokens via LamboVEthRouter::buyQuote as it will revert with "Insufficient msg.value" function test_buyQuoteTokenWithoutETHRevert() public { assertEq(player.balance, PLAYER_INITIAL_ETH_BALANCE); assertEq(dai.balanceOf(player), PLAYER_INITIAL_DAI_BALANCE); vm.startPrank(player); (address quoteToken, ) = factory.createLaunchPad( "LamboToken", "LAMBO", 100 ether, address(vDai) ); dai.approve(address(lamboRouter), PLAYER_INITIAL_DAI_BALANCE); vm.expectRevert(bytes("Insufficient msg.value")); lamboRouter.buyQuote(quoteToken, PLAYER_INITIAL_DAI_BALANCE, 0); vm.stopPrank(); } // Even if users provide both ETH and ERC20 tokens, the transaction will fail as the router cannot handle ERC20 token transfers function test_buyQuoteTokenWithETHAndDaiRevert() public { assertEq(player.balance, PLAYER_INITIAL_ETH_BALANCE); assertEq(dai.balanceOf(player), PLAYER_INITIAL_DAI_BALANCE); vm.startPrank(player); (address quoteToken, ) = factory.createLaunchPad( "LamboToken", "LAMBO", 100 ether, address(vDai) ); dai.approve(address(lamboRouter), PLAYER_INITIAL_DAI_BALANCE); vm.expectRevert("Dai/insufficient-balance"); lamboRouter.buyQuote{value: PLAYER_INITIAL_DAI_BALANCE}( quoteToken, PLAYER_INITIAL_DAI_BALANCE, 0 ); vm.stopPrank(); } // While users can obtain quote tokens through UniswapV2Router02, // they cannot cash out through LamboVEthRouter::sellQuote as it attempts to transfer ETH instead of the underlying ERC20 token function test_userUnableToCashOut() public { vm.startPrank(v3PoolLP); dai.approve(address(vDai), LP_INITIAL_DAI_BALANCE); // LP deposit both ETH and Dai to mint vDai vDai.cashIn{value: LP_INITIAL_DAI_BALANCE}(LP_INITIAL_DAI_BALANCE); // In practice, user should obtain the vDai through uniswap V3 pool, for testing purpose, we just do a simple transfer vDai.transfer(player, 100 ether); vm.stopPrank(); vm.startPrank(player); (address quoteToken, ) = factory.createLaunchPad( "LamboToken", "LAMBO", 300 ether, address(vDai) ); // User can perform a swap directly through UniswapV2Router02 to get quoteToken vDai.approve(address(router02), 100 ether); address[] memory path = new address[](2); path[0] = address(vDai); path[1] = quoteToken; router02.swapExactTokensForTokens( 100 ether, 0, path, player, block.timestamp + 100 ); // User has obtained quoteToken uint256 userQuoteTokenBalance = IERC20(quoteToken).balanceOf(player); assertGt(userQuoteTokenBalance, 0); IERC20(quoteToken).approve(address(lamboRouter), userQuoteTokenBalance); // User won't be able to cash out vm.expectRevert(bytes("Transfer to User failed")); lamboRouter.sellQuote(quoteToken, userQuoteTokenBalance, 0); vm.stopPrank(); } }

To run the test:

    forge test --mp test/audit/ERC20VTokenTest.t.sol -vvv

logs:

    Ran 4 tests for test/audit/ERC20VTokenTest.t.sol:ERC20VTokenTest
    [PASS] test_buyQuoteTokenWithETHAndDaiRevert() (gas: 2959995)
    [PASS] test_buyQuoteTokenWithoutETHRevert() (gas: 2923713)
    [PASS] test_cashInCauseLossOfFund() (gas: 66624)
    [PASS] test_userUnableToCashOut() (gas: 3097163)
    Suite result: ok. 4 passed; 0 failed; 0 skipped; finished in 11.21s (11.54s CPU time)

Recommended Mitigation:

  1. For VirtualToken::cashIn function, replace msg.value in _mint and emit with amount:
function cashIn(uint256 amount) external payable onlyWhiteListed { _transferAssetFromUser(amount); _mint(msg.sender, amount); emit CashIn(msg.sender, amount); }
  1. For LamboVEthRouter::_buyQuote function, rewrite it as below:
function _buyQuote(address quoteToken, uint256 amountXIn, uint256 minReturn) internal returns (uint256 amountYOut) { // handle fee uint256 amountInAfterFee = (amountXIn * (feeDenominator - feeRate)) / feeDenominator; uint256 fee = amountXIn - amountInAfterFee; address underlyingToken = VirtualToken(vETH).underlyingToken(); if (underlyingToken == NATIVE_TOKEN) { require(msg.value == amountXIn, 'Incorrect ETH amount'); // Transfer fee to owner (bool feeSuccess, ) = payable(owner()).call{value: fee}(''); require(feeSuccess, 'Transfer to Owner failed'); // Convert ETH to vETH VirtualToken(vETH).cashIn{value: amountInAfterFee}(amountInAfterFee); } else { require(msg.value == 0, 'ETH not accepted for ERC20 input'); // Transfer input tokens from sender IERC20(underlyingToken).safeTransferFrom(msg.sender, address(this), amountXIn); // Transfer fee to owner IERC20(underlyingToken).safeTransfer(owner(), fee); // Convert underlying token to virtual token IERC20(underlyingToken).approve(vETH, amountInAfterFee); VirtualToken(vETH).cashIn(amountInAfterFee); } // handle swap address pair = UniswapV2Library.pairFor(LaunchPadUtils.UNISWAP_POOL_FACTORY_, vETH, quoteToken); (uint256 reserveIn, uint256 reserveOut) = UniswapV2Library.getReserves( LaunchPadUtils.UNISWAP_POOL_FACTORY_, vETH, quoteToken ); // Calculate the amount of quoteToken to be received amountYOut = UniswapV2Library.getAmountOut(amountInAfterFee, reserveIn, reserveOut); require(amountYOut >= minReturn, 'Insufficient output amount'); // Transfer vETH to the pair IERC20(vETH).safeTransfer(pair, amountInAfterFee); // Perform the swap (uint256 amount0Out, uint256 amount1Out) = vETH < quoteToken ? (uint256(0), amountYOut) : (amountYOut, uint256(0)); IUniswapV2Pair(pair).swap(amount0Out, amount1Out, msg.sender, new bytes(0)); emit BuyQuote(quoteToken, amountXIn, amountYOut); }
  1. For LamboVEthRouter::_sellQuote function, rewrite it as below:
function _sellQuote( address quoteToken, uint256 amountYIn, uint256 minReturn ) internal returns (uint256 amountXOut) { IERC20(quoteToken).safeTransferFrom(msg.sender, address(this), amountYIn); address pair = UniswapV2Library.pairFor(LaunchPadUtils.UNISWAP_POOL_FACTORY_, quoteToken, vETH); (uint256 reserveIn, uint256 reserveOut) = UniswapV2Library.getReserves( LaunchPadUtils.UNISWAP_POOL_FACTORY_, quoteToken, vETH ); // Calculate the amount of vETH to be received amountXOut = UniswapV2Library.getAmountOut(amountYIn, reserveIn, reserveOut); // Transfer quoteToken to the pair IERC20(quoteToken).safeTransfer(pair, amountYIn); // Perform the swap (uint256 amount0Out, uint256 amount1Out) = quoteToken < vETH ? (uint256(0), amountXOut) : (amountXOut, uint256(0)); IUniswapV2Pair(pair).swap(amount0Out, amount1Out, address(this), new bytes(0)); // Convert vETH to ETH and send to the user VirtualToken(vETH).cashOut(amountXOut); // caculate fee uint256 amountOutAfterFee = (amountXOut * (feeDenominator - feeRate)) / feeDenominator; uint256 fee = amountXOut - amountOutAfterFee; require(amountOutAfterFee >= minReturn, 'Insufficient output amount. MinReturn Error.'); address underlyingToken = VirtualToken(vETH).underlyingToken(); if (underlyingToken == NATIVE_TOKEN) { // handle fee (bool success, ) = payable(owner()).call{value: fee}(''); require(success, 'Transfer to owner() failed'); // handle amountOutAfterFee (success, ) = msg.sender.call{value: amountOutAfterFee}(''); require(success, 'Transfer to User failed'); } else { // handle fee IERC20(underlyingToken).safeTransfer(owner(), fee); // handle amountOutAfterFee IERC20(underlyingToken).safeTransfer(msg.sender, amountOutAfterFee); } // Emit the swap event emit SellQuote(quoteToken, amountYIn, amountOutAfterFee); }