Prepared by:
HALBORN
Last Updated 07/28/2025
Date of Engagement: June 10th, 2025 - June 19th, 2025
100% of all REPORTED Findings have been addressed
All findings
20
Critical
2
High
7
Medium
3
Low
1
Informational
7
Lair Finance
engaged Halborn to conduct a security assessment of their smart contracts from June 10th to June 19th, 2025, with a follow-up review from July 19th to July 23rd, 2025. The scope of this assessment was limited to the smart contracts provided to the Halborn team. Commit hashes and additional details are documented in the Scope section of this report.
The Halborn
team dedicated a total of twelve days to this engagement, deploying one full-time security engineer to evaluate the smart contracts’ security posture.
The assigned security engineer is an expert in blockchain and smart contract security, with advanced skills in penetration testing, smart contract exploitation, and a comprehensive understanding of multiple blockchain protocols.
The objectives of this assessment were to:
Verify that the smart contract functions operate as intended.
Identify potential security vulnerabilities within the smart contracts.
In summary, Halborn
identified several areas for improvement to reduce both the likelihood and impact of potential risks. The Lair Finance team
has partially addressed some of these recommendations. The primary suggestions include:
Restrict receivedToken() to internal calls to prevent unauthorized token transfers by approved users.
Use correct decimals for price calculations in reward swaps.
Verify proper Kodiak interface usage.
Correct arithmetic underflow in the getTokenAmountByToken1 function to prevent staking denial-of-service (DoS) via token1.
Fix incorrect refund logic in _stake to prevent staking reverts caused by ERC20InsufficientBalance errors.
Address unit mismatch between BGT and WBERA tokens during token0 swap to prevent DoS in token0 staking.
Mitigate front-running attacks on reward harvesting by integrating reward execution within stake and unstake flows.
Implement a safe token approval mechanism compatible with tokens like USDT to prevent reverts.
Incorporate slippage protection in reward token swaps to defend against MEV extraction.
Follow established smart contract best practices.
Halborn employed a combination of manual, semi-automated, and automated security testing methods to ensure effectiveness, efficiency, and accuracy within the scope of this assessment. Manual testing was vital for uncovering issues related to logic, processes, and implementation details, while automated techniques enhanced code coverage and helped identify deviations from security best practices. The assessment involved the following phases and tools:
Research into the architecture and purpose of the smart contracts.
Manual review and walkthrough of the smart contract code.
Manual evaluation of critical Solidity variables and functions to identify potential vulnerability classes.
Manual testing using custom scripts.
Static security analysis of the scoped contracts and imported functions utilizing Slither
.
Local deployment and testing with Foundry
.
EXPLOITABILITY METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Attack Origin (AO) | Arbitrary (AO:A) Specific (AO:S) | 1 0.2 |
Attack Cost (AC) | Low (AC:L) Medium (AC:M) High (AC:H) | 1 0.67 0.33 |
Attack Complexity (AX) | Low (AX:L) Medium (AX:M) High (AX:H) | 1 0.67 0.33 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Confidentiality (C) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Integrity (I) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Availability (A) | None (A:N) Low (A:L) Medium (A:M) High (A:H) Critical (A:C) | 0 0.25 0.5 0.75 1 |
Deposit (D) | None (D:N) Low (D:L) Medium (D:M) High (D:H) Critical (D:C) | 0 0.25 0.5 0.75 1 |
Yield (Y) | None (Y:N) Low (Y:L) Medium (Y:M) High (Y:H) Critical (Y:C) | 0 0.25 0.5 0.75 1 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
2
High
7
Medium
3
Low
1
Informational
7
Security analysis | Risk level | Remediation Date |
---|---|---|
Token1 Single-Stake Function Permanent DoS Due to Arithmetic Underflow | Critical | Solved - 06/26/2025 |
Incorrect Refund Calculation causes Permanent Staking DoS | Critical | Solved - 07/26/2025 |
Token Decimal Mismatch in Reward Swaps | High | Solved - 07/25/2025 |
Incorrect Minimum Swap Amount for Non-18 Decimal Tokens | High | Solved - 07/26/2025 |
Oracle Interface Mismatch DoS | High | Solved - 07/24/2025 |
Unauthorized Fund Transfer Vulnerability Enables Token Theft from Approved Users | High | Solved - 07/25/2025 |
Unit Mismatch Causes Permanent Staking Failures for Token0 | High | Solved - 06/26/2025 |
Step-Wise Jumps In the Reward System Allows Attacker To Steal Rewards | High | Solved - 06/26/2025 |
MEV Extraction via Zero-Slippage Reward Swaps | High | Solved - 06/26/2025 |
Unsafe Token Approval Pattern | Medium | Solved - 06/26/2025 |
Potential Flash Loan Oracle Manipulation | Medium | Risk Accepted - 07/26/2025 |
Stale Ratio Parameters Could Affect Legitimate Function Calls | Medium | Risk Accepted - 07/25/2025 |
Division by Zero in Ratio Calculations | Low | Acknowledged - 07/26/2025 |
Hard-coded Slippage in LP Reward Staking Could Cause Reverts | Informational | Solved - 06/26/2025 |
Misleading Function Names | Informational | Acknowledged - 07/26/2025 |
Duplicate Code in Swap Functions | Informational | Acknowledged - 07/26/2025 |
Inconsistent Error Messages | Informational | Acknowledged - 07/26/2025 |
Magic Numbers Without Named Constants | Informational | Acknowledged - 07/26/2025 |
Missing NatSpec Documentation | Informational | Acknowledged - 07/26/2025 |
Missing Event Emission for Critical Configuration Changes | Informational | Solved - 06/26/2025 |
//
A vulnerability exists in the getTokenAmountByToken1
function within LairBGTManagerHelper.sol
that causes a systematic arithmetic underflow. This results in a complete denial of service for users attempting single-token staking with token1
.
token1ForBgtAmount = accessBgtTokenAmount * DECIMAL
/ token0AndToken1PriceRatio
* token0BgtPriceRatio
/ DECIMAL;
uint256 remainToken1Amount = token1Amount - token1ForBgtAmount;
The price conversion formula contains a fundamental mathematical error. To convert BGT to token1 correctly, the process should be:
BGT → token0
: Divide BGT by token0BgtPriceRatio
.
token0 → token1
: Divide the result by token0AndToken1PriceRatio
.
Expected Formula: token1 = BGT ÷ token0BgtPriceRatio ÷ token0AndToken1PriceRatio
.
Actual Implementation: token1 = BGT ÷ token0AndToken1PriceRatio * token0BgtPriceRatio
.
The following test function tries to stake using token1 (LAIR)
:
function testSingleStakeLair() public {
vm.startPrank(alice);
IERC20(LAIR_TOKEN).approve(address(lairBGTManager), TC.TEST_LAIR_AMOUNT);
Params.StakeParams memory params = _createSingleStakeParams(address(LAIR_TOKEN));
lairBGTManager.singleStake(params);
vm.stopPrank();
}
Output:
The stake call reverts due to underflow:
Replace the incorrect multiplication with the correct division as follows:
// Correct Formula
token1ForBgtAmount = accessBgtTokenAmount * DECIMAL / token0BgtPriceRatio * DECIMAL / token0AndToken1PriceRatio;
SOLVED: The suggested mitigation was implemented.
//
The vulnerability lies in the refund calculation logic at the end of the LairBGTManager::_stake
function:
uint256 remainBgtTokenAmount = receivedBgtTokenAmount - realBgtTokenAmount;
// ...
if (remainBgtTokenAmount > 0) {
IERC20(bgtTokenAddress).safeTransfer(sender, remainBgtTokenAmount);
}
The contract incorrectly calculates the remaining BGT tokens to refund as follows:
The user sends receivedBgtTokenAmount
.
The contract determines how much of that amount is allocated to the strategy (bgtTokenAmount
) and how much remains on hand (receivedBgtTokenAmount – bgtTokenAmount
).
It then deducts the fee bgtDepositFeeAmount = bgtTokenAmount * depositFee / 10_000
and transfers this fee to feeVaultAddress
.
The actual amount staked is realBgtTokenAmount = bgtTokenAmount – bgtDepositFeeAmount
.
It subtracts the net amount after the fee instead of the original bgtTokenAmount
. As a result, remainBgtTokenAmount
includes the fee that was already transferred in step 3.
The contract then attempts to safeTransfer
this remainBgtTokenAmount
back to the user, but since it is short by the fee amount, the call reverts with ERC20InsufficientBalance
.
The following test function tries to stake IBGT
:
function testSingleStakeBGT() public {
vm.startPrank(alice);
IERC20(IBGT_TOKEN).approve(address(lairBGTManager), TC.TEST_BGT_AMOUNT);
Params.StakeParams memory params = _createSingleStakeParams(address(IBGT_TOKEN));
lairBGTManager.singleStake(params);
vm.stopPrank();
}
Output:
The staking call reverts with ERC20InsufficientBalance:
Replace the incorrect calculation with the correct one:
function _stake(
address sender,
uint256 receivedBgtTokenAmount,
uint256 receivedToken0Amount,
uint256 receivedToken1Amount,
uint256 lpSlippage
) private validMoreThanZero(receivedBgtTokenAmount) returns (uint256) {
//...
//Incorrect Calculation
//uint256 remainBgtTokenAmount = receivedBgtTokenAmount - realBgtTokenAmount;
//Correct Calculation
uint256 remainBgtTokenAmount = receivedBgtTokenAmount - bgtTokenAmount;
//...
}
SOLVED: The suggested mitigation was implemented.
//
In LairBGTManagerHelper::getSwapPrice()
, the price calculations assume all tokens have 18 decimals. When reward tokens have different decimal places, such as USDC with 6 decimals, these calculations become significantly incorrect, potentially causing failed swaps or financial losses.
function getSwapPrice(address pool, address baseToken, address quoteToken) public view returns (uint256 price) {
uint128 baseTokenAmount = 10 ** 18; // ← ASSUMES ALL TOKENS HAVE 18 DECIMALS
(, int24 currentTick,,,,,) = IKodiakV3PoolState(pool).slot0();
price = OracleLibrary.getQuoteAtTick(currentTick, baseTokenAmount, baseToken, quoteToken);
}
Consider USDC (6 decimals) reward conversion as an example:
Actual balance: 1,000 USDC = 1,000,000,000 units (USDC has 6 decimals)
The oracle calculates the price based on: 1e18 units = 1e12 USDC tokens
If 1 USDC = 0.001 WBERA, the oracle returns a price for 1e9 WBERA units
tokenAsToken0Ratio = 1e9 (massively inflated)
Minimum swap amount calculation: 1,000 USDC (which is 1e6 units) multiplied by 1e9 and divided by 1e18 results in 1e15, an incorrect value by a factor of 1,000,000,000,000 (1e12)
Run the following test function:
function test_TokenDecimalMismatch() public {
console.log("=== PoC: Token Decimal Mismatch in Reward Swaps ===");
console.log("Lower decimal reward tokens cause reward conversion to fail\n");
// Setup: Stake some BGT to enable reward processing
vm.startPrank(alice);
IERC20(IBGT_TOKEN).approve(address(lairBGTManager), TC.TEST_BGT_AMOUNT);
lairBGTManager.singleStake(_createSingleStakeParams(address(IBGT_TOKEN)));
vm.stopPrank();
vm.mockCall(
IBGT_VAULT,
abi.encodeWithSelector(IMultiRewards.getAllRewardTokens.selector),
abi.encode(_createSingleRewardTokenArray(USDC_TOKEN))
);
deal(USDC_TOKEN, address(lairBGTManager), 1000 * 10 ** 6); // 1000 USDC
// Set up USDC swap pool
vm.startPrank(owner);
lairBGTManagerHelper.setSwapPoolInfo(USDC_TOKEN, WBERA, USDC_WBERA_POOL, true);
uint256 wberaBalanceBefore = IERC20(WBERA).balanceOf(address(lairBGTManager));
lairBGTManager.rewardByAdmin();
uint256 wberaBalanceAfter = IERC20(WBERA).balanceOf(address(lairBGTManager));
uint256 wberaReceived = wberaBalanceAfter - wberaBalanceBefore;
vm.stopPrank();
}
Output
As we can observe the return value of getSwapPriceWithFallback
, the value is inflated:
Ensure the correct number of token decimals is used:
uint8 baseDecimals = IERC20Metadata(baseToken).decimals();
uint128 baseTokenAmount = uint128(10 ** baseDecimals);
(, int24 currentTick,,,,,) = IKodiakV3PoolState(pool).slot0();
price = OracleLibrary.getQuoteAtTick(currentTick, baseTokenAmount, baseToken, quoteToken);
SOLVED: The recommended mitigation measure has been successfully implemented.
//
In LairBGTManagerHelper::getRewardSwapMinimumAmount()
, the minimum swap amount calculations always divide by DECIMAL = 10**18
, regardless of the actual token decimals.
function getRewardSwapMinimumAmount(uint256 tokenAsToken0Ratio, uint256 tokenAmount, uint256 swapSlippage)
public pure returns (uint256)
{
return (tokenAmount * tokenAsToken0Ratio) / DECIMAL * (PERCENT - swapSlippage) / PERCENT;
}
This causes incorrect slippage protection for reward tokens with different decimal places, potentially allowing swaps to execute with massive slippage that should be rejected.
Output:
Add the token address parameter and ensure the correct decimal base is used for calculations:
function getRewardSwapMinimumAmount(
uint256 tokenAsToken0Ratio,
uint256 tokenAmount,
uint256 swapSlippage,
address rewardToken
) public view returns (uint256) {
uint8 tokenDecimals = IERC20Metadata(rewardToken).decimals();
uint256 tokenDecimalBase = 10 ** tokenDecimals;
return (tokenAmount * tokenAsToken0Ratio) / tokenDecimalBase * (PERCENT - swapSlippage) / PERCENT;
}
SOLVED: The suggested mitigation was implemented.
//
The UniSwapHelper.sol
contract exhibits an interface mismatch that causes the oracle fallback mechanism to fail entirely. This vulnerability arises when TWAP (Time-Weighted Average Price) data is unavailable or insufficient. When it does occur, it results in a total denial of service affecting all core protocol functionalities, including staking, unstaking, and reward distribution.
Kodiak pools utilize a modified version of the Uniswap V3 slot0()
function, which has a different return type than the standard interface.
Standard Uniswap Interface:
function slot0() external view returns (
uint160 sqrtPriceX96,
int24 tick,
uint16 observationIndex,
uint16 observationCardinality,
uint16 observationCardinalityNext,
uint8 feeProtocol,
bool unlocked
);
Kodiak Interface:
function slot0() external view returns (
uint160 sqrtPriceX96,
int24 tick,
uint16 observationIndex,
uint16 observationCardinality,
uint16 observationCardinalityNext,
uint32 feeProtocol,
bool unlocked
);
The vulnerability occurs through the following sequence:
TWAP Failure Trigger: The pool lacks sufficient observation data, which is a common scenario.
Oracle Call: Any operation requiring price data initiates a call.
TWAP Attempt: getSwapPriceWithFallback()
attempts to retrieve the TWAP first.
TWAP Fails: Returns an "OLD" error due to insufficient data.
Fallback Triggered: The system attempts to fetch the spot price via getSwapPrice()
.
Interface Mismatch: The slot0()
function is called with expectations based on the standard interface.
ABI Decode Failure: Kodiak returns a feeProtocol = 229379500
(a valid uint32
), but the decoder expects a uint8
.
Complete Revert: The operation fails entirely, leading to a denial of service.
Run the following test function:
function test_TokenDecimalMismatch_RewardSwapFailure() public {
console.log("=== PoC: Token Decimal Mismatch in Reward Swaps ===");
console.log("Lower decimal reward tokens cause reward conversion to fail\n");
// Setup: Stake some BGT to enable reward processing
vm.startPrank(alice);
IERC20(IBGT_TOKEN).approve(address(lairBGTManager), TC.TEST_BGT_AMOUNT);
lairBGTManager.singleStake(_createSingleStakeParams(address(IBGT_TOKEN)));
vm.stopPrank();
console.log("USDC decimals:", IERC20Metadata(USDC_TOKEN).decimals());
console.log("Problem: getSwapPrice() assumes all tokens have 18 decimals");
// Mock USDC as a reward token and fund the manager
vm.mockCall(
IBGT_VAULT,
abi.encodeWithSelector(IMultiRewards.getAllRewardTokens.selector),
abi.encode(_createSingleRewardTokenArray(USDC_TOKEN))
);
deal(USDC_TOKEN, address(lairBGTManager), 1000 * 10 ** 6); // 1000 USDC
// Set up USDC swap pool
vm.startPrank(owner);
lairBGTManagerHelper.setSwapPoolInfo(USDC_TOKEN, WBERA, USDC_WBERA_POOL, true);
// Attempt reward conversion - this succeeds but with wrong pricing
console.log("\nAttempting reward conversion with 6-decimal USDC...");
uint256 wberaBalanceBefore = IERC20(WBERA).balanceOf(address(lairBGTManager));
lairBGTManager.rewardByAdmin();
uint256 wberaBalanceAfter = IERC20(WBERA).balanceOf(address(lairBGTManager));
uint256 wberaReceived = wberaBalanceAfter - wberaBalanceBefore;
console.log("SUCCESS: Swap completed but with WRONG PRICING!");
console.log("WBERA received from 1000 USDC:", wberaReceived);
console.log("This is ~451e18 WBERA = 451,000,000,000,000,000 WBERA!");
console.log("VULNERABILITY: Decimal mismatch causes massive price inflation");
vm.stopPrank();
}
Output:
Replace the standard Uniswap interface with the correct Kodiak interface:
function slot0() external view returns (
uint160 sqrtPriceX96,
int24 tick,
uint16 observationIndex,
uint16 observationCardinality,
uint16 observationCardinalityNext,
uint32 feeProtocol,
bool unlocked
);
SOLVED: The suggested mitigation was implemented.
//
The receivedToken()
function in the LairBGTManager
contract contains an access control vulnerability that allows attackers to drain tokens from any user who has granted the contract spending approval. The function is mistakenly declared as public
instead of private
, permitting direct external calls that bypass all staking validations and security checks.
function receivedToken(
address sender,
address token0Address,
address token1Address,
address _bgtTokenAddress,
uint256 token0Amount,
uint256 token1Amount,
uint256 bgtTokenAmount
) public payable returns (uint256 receivedBgtTokenAmount, uint256 receivedToken0Amount, uint256 receivedToken1Amount)
{
// ... function body that calls receiveErcToken(sender, tokenAddress, amount)
}
function receiveErcToken(address sender, address tokenAddress, uint256 amount) private
validAddress(sender)
validAddress(tokenAddress)
validMoreThanZero(amount) {
IERC20(tokenAddress).safeTransferFrom(sender, address(this), amount);
}
Exploitation Flow:
A user approves the LairBGTManager
contract to spend their IBGT, WBERA, or other tokens for legitimate staking purposes.
An attacker directly invokes receivedToken()
, specifying the victim's address as the sender
parameter.
The function executes safeTransferFrom(victim, contract, amount)
using the victim's existing token approvals.
The victim receives no LairBGT tokens, gains no staking benefits, and permanently loses their tokens.
The following test function demonstrates the vulnerability:
function testUnauthorizedFundTransfer() public {
// Victim (Alice) approves the manager as in normal staking flow
uint256 stealAmount = 50 ether;
vm.startPrank(alice);
IERC20(IBGT_TOKEN).approve(address(lairBGTManager), stealAmount);
uint256 aliceBalanceBefore = IERC20(IBGT_TOKEN).balanceOf(alice);
vm.stopPrank();
// Snapshot manager and attacker balances before the attack
uint256 managerBalanceBefore = IERC20(IBGT_TOKEN).balanceOf(address(lairBGTManager));
uint256 attackerBalanceBefore = IERC20(IBGT_TOKEN).balanceOf(bob);
// === EXPLOIT ===
// Attacker calls the *public* receivedToken() function, passing Alice as the sender
vm.startPrank(bob);
lairBGTManager.receivedToken(
alice,
WBERA, // token0Address (unused in this call)
LAIR_TOKEN, // token1Address (unused)
IBGT_TOKEN, // _bgtTokenAddress
0, // token0Amount
0, // token1Amount
stealAmount // bgtTokenAmount to steal
);
vm.stopPrank();
// === END EXPLOIT ===
// Post-state: Alice has lost tokens, manager gained them, attacker spent none
uint256 aliceBalanceAfter = IERC20(IBGT_TOKEN).balanceOf(alice);
uint256 managerBalanceAfter = IERC20(IBGT_TOKEN).balanceOf(address(lairBGTManager));
uint256 attackerBalanceAfter = IERC20(IBGT_TOKEN).balanceOf(bob);
assertEq(aliceBalanceAfter, aliceBalanceBefore - stealAmount, "Alice's IBGT not deducted correctly");
assertEq(managerBalanceAfter, managerBalanceBefore + stealAmount, "Manager did not receive stolen IBGT");
assertEq(attackerBalanceAfter, attackerBalanceBefore, "Attacker should not spend their own IBGT");
// No LrBGT minted for Alice (victim receives no benefit)
assertEq(lairBGTToken.balanceOf(alice), 0, "Victim unexpectedly received LrBGT");
}
Output:
The test passes, proving the vulnerability exists.
Modify the function visibility from public
to private
as follows:
function receivedToken(
address sender,
address token0Address,
address token1Address,
address _bgtTokenAddress,
uint256 token0Amount,
uint256 token1Amount,
uint256 bgtTokenAmount
) private payable returns (uint256 receivedBgtTokenAmount, uint256 receivedToken0Amount, uint256 receivedToken1Amount)
{
// Function body remains unchanged
}
SOLVED: The suggested mitigation was implemented.
//
A unit mismatch vulnerability exists in the LairBGTManager.token0Swap()
function, resulting in transaction failures. This issue arises when users stake exclusively with WBERA (token0), as BGT-denominated values are mistakenly interpreted as WBERA amounts during swap operations.
In LairBGTManagerHelper.getTokenAmountByToken0()
:
uint256 remainBgt = (remainToken0Amount * token0BgtPriceRatio) / DECIMAL;
(, accessToken1) = calcBaseTokenPriceAndLpRatio(
remainBgt,
amount0,
amount1,
token0BgtPriceRatio,
token0AndToken1PriceRatio
);
In LairBGTManager.token0Swap()
:
swapToken1Amount = uniSwapV3SingleHopSwap(
vaultInfo.token1SwapAddress,
vaultInfo.token0Address,
vaultInfo.token1Address,
vaultInfo.token1SwapFee,
accessToken1,
token1MinimumAmount
);
swapToken0Amount = receivedToken0Amount - token0ForBgtAmount - accessToken1;
The getTokenAmountByToken0()
helper function returns accessToken1
denominated in BGT units, but the main contract incorrectly treats this value as if it were denominated in WBERA units. This mismatch causes the token0Swap
function to always revert, resulting in a Denial of Service (DoS) for token0
staking.
The following function tries to stake WBERA (token0)
:
function testSingleStakeWbera() public {
vm.startPrank(alice);
IERC20(WBERA).approve(address(lairBGTManager), TC.TEST_WBERA_AMOUNT);
Params.StakeParams memory params = _createSingleStakeParams(address(WBERA));
lairBGTManager.singleStake(params);
vm.stopPrank();
}
Output:
Because the units do not match, the router later attempts to transferFrom
more WBERA than the contract actually holds, causing an STF
(insufficient funds) revert.
Convert the BGT-denominated accessToken1
to WBERA units before using it in swap operations:
function token0Swap(
uint256 receivedToken0Amount,
uint256 bgtTokenMinimumAmount,
uint256 token0BgtPriceRatio,
uint256 token1MinimumAmount,
uint256 token0AndToken1PriceRatio
) private returns (uint256 swapBgtTokenAmount, uint256 swapToken1Amount, uint256 swapToken0Amount) {
(, , uint256 accessToken1Bgt, uint256 token0ForBgtAmount)
= ILairBGTManagerHelper(lairBGTManagerHelperAddress).getTokenAmountByToken0(
vaultInfo.lpTokenAddress,
receivedToken0Amount,
token0BgtPriceRatio,
token0AndToken1PriceRatio,
lpBuyRatio
);
swapBgtTokenAmount = uniSwapV3SingleHopSwap(
vaultInfo.token0SwapAddress,
vaultInfo.token0Address,
bgtTokenAddress,
vaultInfo.token0SwapFee,
token0ForBgtAmount,
bgtTokenMinimumAmount
);
// Convert BGT units to WBERA units
uint256 accessToken1Wbera = accessToken1Bgt * DECIMAL / token0BgtPriceRatio;
swapToken1Amount = uniSwapV3SingleHopSwap(
vaultInfo.token1SwapAddress,
vaultInfo.token0Address,
vaultInfo.token1Address,
vaultInfo.token1SwapFee,
accessToken1Wbera, // Now correctly in WBERA units
token1MinimumAmount
);
// Use WBERA units in accounting
swapToken0Amount = receivedToken0Amount - token0ForBgtAmount - accessToken1Wbera;
}
SOLVED: The suggested mitigation was implemented.
//
The LairBGTManager.reward()
function introduces a step-wise jump in the share price, enabling attackers to front-run reward harvesting and steal a portion of rewards intended for legitimate long-term stakers.
This vulnerability exists in the reward()
function of LairBGTManager.sol
. When invoked, this function:
Claims pending rewards from Infrared vaults via getReward()
.
Immediately restakes these rewards back into the vaults.
Causes an instantaneous increase in the vault's pricePerShare
ratio.
The attack vector allows a malicious actor to:
Monitor the mempool for incoming reward()
transactions.
Front-run with a large stake (or by using a flash loan) just before the reward harvesting.
Capture a proportional share of the newly harvested rewards.
Immediately withdraw, profiting from rewards they did not contribute to earning.
function reward() public nonReentrant whenNotPaused onlyRole(SETTING_MANAGER) {
uint256 currentBgtStakedAmount = totalBgtStakedAmount - totalBgtUnStakedAmount;
uint256 currentLpStakedAmount = totalLpStakedAmount - totalLpUnStakedAmount;
if (currentBgtStakedAmount == 0 && currentLpStakedAmount == 0) {
return;
}
IMultiRewards(bgtVaultAddress).getReward();
IMultiRewards(lpVaultAddress).getReward();
address[] memory bgtTokenRewardList = IMultiRewards(bgtVaultAddress).getAllRewardTokens();
address[] memory lpTokenRewardList = IMultiRewards(lpVaultAddress).getAllRewardTokens();
convertRewardTokenToWBera(bgtTokenRewardList);
convertRewardTokenToWBera(lpTokenRewardList);
(uint256 bgtAmount, uint256 bgtFeeAmount) = bgtRewardStake();
(uint256 lpAmount, uint256 lpFeeAmount) = lpRewardStake();
emit UpdateReward(
bgtAmount,
bgtFeeAmount,
lpAmount,
lpFeeAmount
);
reCalculateLpBuyRatio();
}
The core issue is that all rewards are immediately restaked, causing an atomic jump in the ratio of totalUnderlying / totalLrBGT
.
Impact
Reward Theft: Attackers can steal a significant portion of rewards from legitimate stakers.
Unfair Distribution: Long-term stakers receive reduced rewards due to dilution.
Economic Loss: The protocol incurs value loss to front-runners on every reward harvest.
Trust Erosion: Users may lose confidence in the fairness of reward distribution.
The following test function demonstrates the vulnerability:
function testStepwiseJump_WithAttacker() public {
(Gains memory aliceG, Gains memory attackerG) = _runStepwiseScenario(true);
console.log("Alice gains WITH attacker - BGT", aliceG.bgt);
console.log("token0", aliceG.token0);
console.log("token1", aliceG.token1);
console.log("Attacker loot - BGT", attackerG.bgt);
console.log("token0", attackerG.token0);
console.log("token1", attackerG.token1);
}
function _runStepwiseScenario(bool enableAttacker) internal returns (Gains memory aliceG, Gains memory attackerG) {
// Reset fees for clarity
vm.startPrank(owner);
lairBGTManager.setDepositFee(0);
lairBGTManager.setWithdrawFee(0);
vm.stopPrank();
address attacker = bob;
// ---------- Honest user Alice stakes ----------
uint256 honestDeposit = 1000 ether;
vm.startPrank(alice);
IERC20(IBGT_TOKEN).approve(address(lairBGTManager), honestDeposit);
uint256 aliceMinted = lairBGTManager.singleStake(_createSingleStakeParams(address(IBGT_TOKEN)));
vm.stopPrank();
// ---------- Inject pending rewards ----------
deal(IBGT_TOKEN, address(lairBGTManager), 400 ether);
deal(WBERA, address(lairBGTManager), 20 ether);
skip(30 days);
// ---------- Optional attacker front-run ----------
uint256 attackerMinted;
if (enableAttacker) {
uint256 attackerDeposit = 1000 ether;
deal(IBGT_TOKEN, attacker, attackerDeposit);
vm.startPrank(attacker);
IERC20(IBGT_TOKEN).approve(address(lairBGTManager), attackerDeposit);
attackerMinted = lairBGTManager.singleStake(_createSingleStakeParams(address(IBGT_TOKEN)));
vm.stopPrank();
}
// Harvest – causes NAV jump
vm.prank(owner);
lairBGTManager.reward();
// ---------- Alice unstakes ----------
vm.startPrank(alice);
IERC20(address(lairBGTToken)).approve(address(lairBGTManager), aliceMinted);
(uint256 predictedBgt,) = lairBGTManager.unStakeAmount(aliceMinted);
Params.UnStakeParams memory unParamsAlice = Params.UnStakeParams({
amount: aliceMinted,
minimumBgtTokenAmount: predictedBgt * 90 / 100,
minimumToken0Amount: 1,
minimumToken1Amount: 1
});
(uint256 bgtOut,, uint256 t0Out, uint256 t1Out) = lairBGTManager.unStake(unParamsAlice);
vm.stopPrank();
aliceG = Gains({ bgt: bgtOut, token0: t0Out, token1: t1Out });
// ---------- Attacker unstakes (if any) ----------
if (enableAttacker) {
vm.startPrank(attacker);
IERC20(address(lairBGTToken)).approve(address(lairBGTManager), attackerMinted);
(uint256 predBgtA,) = lairBGTManager.unStakeAmount(attackerMinted);
Params.UnStakeParams memory unA = Params.UnStakeParams({
amount: attackerMinted,
minimumBgtTokenAmount: predBgtA * 90 / 100,
minimumToken0Amount: 1,
minimumToken1Amount: 1
});
(uint256 bgtA,, uint256 t0A, uint256 t1A) = lairBGTManager.unStake(unA);
vm.stopPrank();
attackerG = Gains({ bgt: bgtA, token0: t0A, token1: t1A });
}
}
struct Gains {
uint256 bgt;
uint256 token0;
uint256 token1;
}
Output:
The economic design is vulnerable: any account that mints LrBGT
just before reward()
function captures a pro-rata share of the harvest, diluting existing stakers.
Invoke reward()
automatically during every stake and unstake operation to prevent large step-wise jumps.
SOLVED: The suggested mitigation was implemented.
//
The reward conversion mechanism in the LairBGTManager::reward()
function contains a vulnerability that exposes all reward tokens to Maximum Extractable Value (MEV) attacks. When converting non-standard reward tokens to WBERA, the contract executes swaps with amountOutMinimum = 0
, effectively providing no slippage protection. This creates a risk-free arbitrage opportunity for MEV bots to extract substantial value from the protocol's reward streams.
The convertRewardTokenToWBera()
function processes rewards from both IBGT and LP staking vaults:
function convertRewardTokenToWBera(address[] memory rewardTokenList) private {
for (uint256 index = 0; index < rewardTokenList.length; index++) {
address rewardToken = rewardTokenList[index];
if (rewardToken == wrapBeraAddress || rewardToken == bgtTokenAddress ||
rewardToken == vaultInfo.token1Address || rewardToken == vaultInfo.token0Address) {
continue;
}
uint256 rewardBalance = IERC20(rewardToken).balanceOf(address(this));
if (rewardBalance == 0) {
continue;
}
uniSwapV3SingleHopSwap(
vaultInfo.token0SwapAddress,
rewardToken,
wrapBeraAddress,
vaultInfo.token0SwapFee,
rewardBalance,
0
);
}
}
Rewards can accumulate to significant amounts over time, making attacks more profitable. The attack is deterministic and risk-free for MEV extractors since amountOutMinimum = 0
guarantees swap execution. This affects the auto-compounding mechanism that's core to the protocol's value proposition
Implement slippage protection by calculating a minimum output amount.
SOLVED: A Uniswap price oracle was integrated to accurately determine the minimum slippage amount.
//
The LairBGTManager
contract uses an unsafe token approval pattern that will cause transaction reverts when interacting with certain ERC20 tokens like USDT. These tokens implement a safety mechanism that prevents setting a non-zero allowance when there's already an existing non-zero allowance, requiring allowances to be reset to zero first.
USDT and similar tokens revert when approve(spender, amount)
is called with amount > 0
while the current allowance is also > 0
. The contract's repeated approvals without resetting will fail on the second and subsequent calls.
function _bgtTokenStake(address _bgtTokenAddress, address _bgtVaultAddress, uint256 bgtTokenAmount) private {
IERC20 bgtToken = IERC20(_bgtTokenAddress);
bgtToken.approve(_bgtVaultAddress, bgtTokenAmount);
IMultiRewards(_bgtVaultAddress).stake(bgtTokenAmount);
}
function uniSwapV3SingleHopSwap(...) private returns (uint256 amount) {
IERC20(tokenIn).approve(swapRouter, amountIn);
// ... swap execution
}
function _lpTokenMake(...) private returns (...) {
IERC20(vaultInfo.token0Address).approve(vaultInfo.routerAddress, token0AmountMax);
IERC20(vaultInfo.token1Address).approve(vaultInfo.routerAddress, token1AmountMax);
}
Failure Scenario:
// First stake with USDT as BGT token - succeeds
_bgtTokenStake(USDT, vault, 1000e6); // allowance: 0 → 1000e6
// Second stake attempt - reverts
_bgtTokenStake(USDT, vault, 500e6); // REVERT: cannot approve when allowance > 0
Impact:
Protocol Unusable: Complete failure when USDT or similar tokens are used as BGT, token0, token1, or reward tokens
LP Creation Blocked: Cannot create liquidity pairs involving USDT
Reward Processing Fails: Automatic reward conversion breaks if USDT is a reward token
Implement a safe approval logic to prevent the reverts:
function safeApprove(IERC20 token, address spender, uint256 amount) private {
uint256 currentAllowance = token.allowance(address(this), spender);
if (currentAllowance != 0) {
token.approve(spender, 0); // Reset to zero first
}
token.approve(spender, amount); // Set new allowance
}
SOLVED: The safeApprove
function was implemented to mitigate the vulnerability.
//
The getSwapPrice()
function in UniSwapHelper.sol
allows attackers to manipulate protocol pricing through flash loan attacks. When TWAP oracle data is insufficient, the getSwapPriceWithFallback
function falls back to using spot prices from slot0()
, which can be manipulated within a single transaction.
function getSwapPriceWithFallback(address pool, address baseToken, address quoteToken)
public view returns (uint256 price)
{
// ... TWAP attempt ...
if (twapOk) {
price = OracleLibrary.getQuoteAtTick(twapTick, baseTokenAmount, baseToken, quoteToken);
} else {
return getSwapPrice(pool, baseToken, quoteToken);
}
}
function getSwapPrice(address pool, address baseToken, address quoteToken)
public view returns (uint256 price)
{
(, int24 currentTick,,,,,) = IKodiakV3PoolState(pool).slot0();
price = OracleLibrary.getQuoteAtTick(currentTick, baseTokenAmount, baseToken, quoteToken);
}
It is recommended to revert instead of falling back to spot price
RISK ACCEPTED: The Lair Finance team accepted the risk with the following comment: "The risk is acknowledged, and it is presumed that there is a minimum slippage in the swap, which can be mitigated to some extent."
//
The singleStake()
and balanceStake()
functions within LairBGTManager
contain a race condition where user-supplied ratio parameters become outdated. This issue arises because reward processing occurs before parameter validation, leading to the potential rejection of legitimate user transactions.
function singleStake(Params.StakeParams memory params) public payable nonReentrant whenNotPaused returns (uint256) {
reward();
checkStakeParams(params);
// ...
}
function balanceStake(Params.StakeParams memory params) public payable nonReentrant whenNotPaused returns (uint256) {
reward();
checkStakeParams(params);
// ...
}
function reward() private {
// ... processes rewards ...
reCalculateLpBuyRatio();
setRatio();
}
Vulnerable execution flow:
User Preparation: The user queries current ratio values and prepares a transaction with valid parameters based on the latest data.
Reward Distribution: After the user's transaction is initiated, rewards are distributed, resulting in updated ratios and stake data.
Ratio Updates: The reward()
function updates the ratios to reflect new stake conditions.
Validation Failure: The user's previously valid parameters no longer align with the updated ratios, causing validation to fail.
Transaction Reversion: The transaction reverts with errors related to outdated ratios, such as "bgtRatio" or "lpRatio".
Implement a mechanism to retrieve the latest ratios prior to invoking the staking functions.
RISK ACCEPTED: The Lair Finance team accepted the risk with the following comment: "The UI currently calculates the slippage by adding the reward value, and I believe the current process is correct to retry if the slippage is greater than the actual amount requested by the front."
//
The calculateLairBgtTokenAmount()
function in LairBGTManager
contains division operations using bgtRatio
and lpRatio
without proper zero-value validation. If these ratios are initialized to zero, all staking operations will permanently revert.
function initialize(
// ...
uint256 _bgtRatio,
uint256 _lpRatio
) public initializer {
bgtRatio = _bgtRatio;
lpRatio = _lpRatio;
// ...
}
Revert initialization when encountering zero values.
ACKNOWLEDGED: This finding has been acknowledged.
//
In LairBGTManager.sol::lpRewardStake()
function, it uses a hard-coded 1% slippage tolerance instead of the configurable lpSlippage
parameter, potentially causing unnecessary transaction failures during volatile market conditions.
uint256 slippageNeedsAmount0 = swapAmount0 - (swapAmount0 * 100) / PERCENT;
uint256 slippageNeedsAmount1 = swapAmount1 - (swapAmount1 * 100) / PERCENT;
Impact: Reward compounding may fail during market volatility, reducing protocol availability.
Use configurable slippage parameter:
function lpRewardStake() private returns (uint256, uint256) {
// ... existing code ...
uint256 slippageNeedsAmount0 = swapAmount0 - (swapAmount0 * lpSlippage) / PERCENT;
uint256 slippageNeedsAmount1 = swapAmount1 - (swapAmount1 * lpSlippage) / PERCENT;
// ... existing code ...
}
SOLVED: The suggested mitigation was implemented.
//
Function names in `LairBGTManager
don't clearly indicate their actual behavior, potentially confusing developers and integrators.
function singleStake
: Actually handles multiple asset paths and swapping
function balanceStake
: Name doesn't clearly indicate balanced liquidity provision
Impact: Reduced code clarity and potential integration errors.
Use more descriptive names:
function stakeWithAutoSwap
: Clearly indicates automatic swapping
function stakeBalancedLiquidity
: Clearly indicates balanced LP provision
ACKNOWLEDGED: The Lair Finance team acknowledged the finding.
//
In LairBGTManager
, the three swap functions (bgtTokenSwap
, token0Swap
, token1Swap
) contain nearly identical logic with slight variations.
Impact: Increased maintenance burden and higher deployment costs.
Refactor into a common helper function:
function _performTokenSwap(
SwapParams memory params
) private returns (uint256, uint256, uint256) {
// Common swap logic with parameterized inputs
}
ACKNOWLEDGED: The Lair Finance team acknowledged the finding.
//
Error messages in LairBGTManager
use inconsistent formatting and terminology, reducing user experience quality.
require(fee <= PERCENT, "PERCENT");
require(totalBgtStakedAmount == 0, "already");
require(_lpBuyRatio >= DECIMAL, "10**18");
Impact: Poor user experience and debugging difficulty.
Standardize error messages:
require(fee <= PERCENT, "Fee exceeds maximum allowed percentage");
require(totalBgtStakedAmount == 0, "Cannot modify configuration after staking begins");
require(_lpBuyRatio >= DECIMAL, "LP buy ratio must be at least 1e18");
ACKNOWLEDGED: The Lair Finance team acknowledged the finding.
//
In LairBGTManager
, literal numbers are used directly in calculations without named constants, reducing code readability and maintainability.
uint256 swapAmount0 = token0Amount / 2;
uint256 slippageAmount = amount * 100 / PERCENT;
Impact: Reduced code clarity and increased chance of errors during maintenance.
Define named constants:
uint256 private constant HALF_SPLIT = 2;
uint256 private constant DEFAULT_SLIPPAGE_BPS = 100; // 1%
uint256 swapAmount0 = token0Amount / HALF_SPLIT;
uint256 slippageAmount = amount * DEFAULT_SLIPPAGE_BPS / PERCENT;
ACKNOWLEDGED: The Lair Finance team acknowledged the finding.
//
In LairBGTManager
, many public and external functions lack comprehensive NatSpec documentation, making integration and maintenance more difficult.
Impact: Reduced developer experience and integration difficulty.
Add NatSpec documentation to all functions:
/**
* @notice Stakes tokens with automatic swapping and LP creation
* @param params Staking parameters including amounts, ratios, and slippage
* @return mintedAmount The amount of LairBGT tokens minted to the user
* @dev Supports staking with single token type or balanced amounts
*/
function singleStake(Params.StakeParams memory params) public payable returns (uint256) {
// Implementation
}
ACKNOWLEDGED: The Lair Finance team acknowledged the finding.
//
In the LairBGTManager
contract, administrative functions that modify critical protocol parameters don't emit events, making it difficult to monitor for malicious configuration changes or create proper audit trails.
Emit events for critical state changes:
event ConfigurationChanged(string indexed parameter, address oldValue, address newValue);
function setFeeVaultAddress(address _feeVaultAddress) public onlyRole(SETTING_MANAGER) {
address oldAddress = feeVaultAddress;
feeVaultAddress = _feeVaultAddress;
emit ConfigurationChanged("feeVaultAddress", oldAddress, _feeVaultAddress);
}
SOLVED: The suggested mitigation was implemented.
Halborn utilized automated testing techniques to improve coverage of specific areas within the smart contracts under review. One of the primary tools employed was Slither
, a static analysis framework for Solidity. After successfully verifying and compiling the smart contracts in the repository into their ABI and binary formats, Slither
was executed against the contracts. This tool performs static verification of mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs throughout the entire codebase.
The security team conducted a comprehensive review of the findings generated by the Slither
static analysis tool. No significant issues were identified, as the reported findings were determined to be false positives.
Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.
// Download the full report
Bera LRT Contracts
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed