Lambo.win
Findings & Analysis Report
2025-02-03
Table of contents
- Summary
- Scope
- Severity Criteria
-
- [H-01] Loss of User Funds in VirtualToken’s
cashIn
Function Due to Incorrect Amount Minting - [H-02] LamboFactory can be permanently DoS-ed due to
createPair
call reversal - [H-03] Calculation for
directionMask
is incorrect - [H-04] Anyone can call
LamboRebalanceOnUniwap.sol::rebalance()
function with any arbitrary value, leading to rebalancing goal i.e. (1:1 peg) unsuccessful.
- [H-01] Loss of User Funds in VirtualToken’s
-
- [M-01] Since the cost of launching a new pool is minimal, an attacker can maliciously consume
VirtualTokens
- [M-02]
LamboRebalanceOnUniswap::_getTokenInOut
formula used to compute rebalancing amount is wrong for a UniV3 pool - [M-03]
sellQuote
andbuyQuote
are missing deadline check inLamboVEthRouter
- [M-04] Accumulated ETH in the LamboVEthRouter will be irretrievable
- [M-05] Incorrect Struct Field and Hardcoded
sqrtPriceLimitX96
in_getQuoteAndDirection
- [M-06] Attacker can capture
VETH-WETH
depeg profits through a malicious pool, rendering rebalancer useless if VETH Price > WETH Price - [M-07] Rebalance profit requirement prevents maintaining VETH/WETH peg
- [M-08] Users can prevent protocol from rebalancing for his gain and cause loss of funds for protocol and its users
- Proof of Concept
- [M-09] Rebalance will be completely dossed if OKX commision rate goes beyond the fee limits
- [M-10] LP for v3 pool of underlying tokens with decimals
!= 18
would have incorrect NFT metadata
- [M-01] Since the cost of launching a new pool is minimal, an attacker can maliciously consume
-
Low Risk and Non-Critical Issues
- Table of Contents
- 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
- Disclosures
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
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);
}
Recommended mitigation steps
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:]
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 ...
}
Recommended mitigation steps
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.
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
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:
uint256 private constant _ONE_FOR_ZERO_MASK = 1 << 255; // Mask for identifying if the swap is one-for-zero
let zeroForOne := eq(and(_pool, _ONE_FOR_ZERO_MASK), 0)
Recommended mitigation steps
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 -
- Do the usual rebalancing operation by executing
rebalance()
, by proving parameter frompreviewRebalance()
and legitdirectionMask
. - After snapshot revert, it calls the
rebalance()
function from an unauthorised user with an abritrary value. - 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"
);
}
Recommended mitigation steps
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.
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
- User 1 and User 2 submit transactions to launch pools with virtual liquidity of 10 ether and 20 ether, respectively.
- 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.
- Due to the
takeLoan
debt limit, the transactions of User 1 and User 2 revert. - 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.
Recommended mitigation steps
- Charge a launch fee for new pools to increase attack costs
- 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
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:
- 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
- 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.
Recommended Mitigation Steps
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.
Recommended Mitigation Steps
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
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.
Recommended mitigation steps
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;
}
Recommended mitigation steps
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
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:
- Price of
WETH-VETH
depegs, soprice(VETH) / price(WETH) > 1
. - Attacker creates a malicious Token (Token.sol) and a deployer (Deployer.sol) which deploys the malicious token using a salt.
- 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 usesfindSalt.js
to find the correct salt, given correct parameters. - After finding the right salt, the attacker deploys the malicious token and creates a VETH-TOKEN Uniswap v3 pair.
-
Now its possible to call
LamboRebalanceOnUniwap::rebalance
withMASK
so_v3Pool
points to the attacker’s pool:uint256 _v3pool = uint256(uint160(uniswapPool)) | (directionMask); uint256[] memory pools = new uint256[](1); pools[0] = _v3pool;
-
The attacker deploys
Attacker.sol
. ItstakeProfit
function takes a Morpho flashloan, then callsLamboRebalanceOnUniwap::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)); }
-
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 uponVETH=>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.
-
Attacker.sol::onMorphoFlashLoan
adds liquidity into the malicious pool before callingLamboRebalanceOnUniwap::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);
-
LamboRebalanceOnUniwap::rebalance
is called with the attacker’sdirectionMask
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
- Malicious pool now received VETH at a discount price.
-
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);
-
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 );
-
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.
Recommended mitigation steps
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
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
Recommended Mitigation Steps
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
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 makerebalance
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 desiredtargetToken
.
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.
Recommended mitigation steps
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
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:
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:
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.
Recommended mitigation steps
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.
Recommended Mitigation Steps
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
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.

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.
Recommended Mitigation Steps
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
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:
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");
}
Recommended Mitigation Steps
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
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);
Recommended Mitigation Steps
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);
}
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.
Recommended Mitigation Steps
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.
Recommended Mitigation Steps
- 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.
Recommended Mitigation Steps
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
Recommended Mitigation Steps
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.