Prepared by:
HALBORN
Last Updated 09/11/2025
Date of Engagement: July 22nd, 2025 - August 11th, 2025
100% of all REPORTED Findings have been addressed
All findings
7
Critical
0
High
2
Medium
1
Low
0
Informational
4
Bonzo Finance
engaged Halborn
to conduct a security assessment on their smart contracts beginning on July 22nd, 2025 and ending on August 11th, 2025. The security assessment was scoped to the smart contracts provided to Halborn. Commit hashes and further details can be found in the Scope section of this report.
The Bonzo Finance
codebase in scope consists of different smart contracts, allowing users to generate yield on their assets, using underlying protocols like Uniswap and Aave forks. The contracts are forked from Beefy, with a good amount of modifications.
Halborn
was provided 21 days for the engagement and assigned a full-time security engineer to review the security of the smart contracts in scope.
The purpose of the assessment is to:
Identify potential security issues within the smart contracts.
Ensure that smart contract functionality operates as intended.
In summary, Halborn
identified areas for improvement to reduce the likelihood and impact of potential risks, which were partially addressed by the Bonzo finance Team
:
Handle conversions between assets correctly.
Handle precision properly when calculating pool prices.
Choose ticks according to the way Uniswap works to avoid DoS.
Avoid insecure use of 'slot0' for liquidity calculation to prevent sandwich attacks.
Prevent griefing attacks via 'permit' in 'stakeWithPermit'.
Ensure event arguments in '_harvest' are correct.
Correct looping approves to avoid an extra unnecessary iteration.
Halborn
performed a manual review of the code. Manual testing is great to uncover flaws in logic, process, and implementation.
The following phases and associated tools were used throughout the term of the assessment:
Research into architecture, purpose and use of the platform.
Smart contract manual code review and walkthrough to identify any logic issue.
Thorough assessment of safety and usage of critical Solidity variables and functions in scope that could led to arithmetic related vulnerabilities.
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
0
High
2
Medium
1
Low
0
Informational
4
Security analysis | Risk level | Remediation Date |
---|---|---|
Incorrect sauce token conversions | High | Solved - 08/14/2025 |
Pool price is computed incorrectly | High | Solved - 08/14/2025 |
Wrong tick choice causes DoS of vault operations | Medium | Solved - 08/14/2025 |
Liquidity calculation based on unvalidated slot0 price | Informational | Acknowledged - 08/21/2025 |
Griefing Attack via permit in stakeWithPermit | Informational | Solved - 08/21/2025 |
Looping approves for 1 extra iteration | Informational | Acknowledged - 08/14/2025 |
Incorrect Event Argument in _harvest | Informational | Solved - 08/21/2025 |
//
In BonzoSauceLeveragedLiqStaking
, we aim to compute the conversion rate from xSauce
tokens to Sauce
tokens:
ILendingPool(lendingPool).deposit(want, amount, address(this), 0);
uint256 currentCollateral = amount;
uint256 totalBorrowed = 0;
uint256 xSaucePerSauce = ISaucerSwapMothership(stakingPool).xSauceForSauce(1e6);
The amount
value represents the quantity of xSauce
tokens, which serve as the collateral token. We then attempt to calculate the equivalent collateral value in Sauce
tokens:
uint256 inputCollateralValueInSauce = (currentCollateral * xSaucePerSauce) / 1e6;
However, this calculation is incorrect. Since currentCollateral
reflects the amount of xSauce
, multiplying it directly by xSaucePerSauce
leads to an inaccurate value. For example, with simplified numbers (no extra precision), consider the following scenario:
currentCollateral
(initially equal to amount
) = 100.
xSaucePerSauce
= 2 (meaning each Sauce
token is worth 2 xSauce
).
The calculated inputCollateralValueInSauce
= 100 * 2 = 200.
Nevertheless, this is incorrect because each Sauce
token is actually worth only 2 xSauce
, so the correct value should be 50, not 100. This miscalculation leads to errors in subsequent computations, resulting in inaccurate or unexpected outcomes.
Consider calling sauceForxSauce()
instead.
Solved: The Benzo team resolved the issue by updating xSaucePerSauce
to saucePerXSauce
.
//
The SaucerSwapCLMLib::getPoolPrice()
function operates as shown below:
function getPoolPrice(address pool) external view returns (uint256 _price) {
(uint160 sqrtPriceX96, , , , , , ) = IUniswapV3Pool(pool).slot0();
uint256 scaledPrice = FullMath.mulDiv(uint256(sqrtPriceX96), SQRT_PRECISION, (2 ** 96));
_price = FullMath.mulDiv(scaledPrice, scaledPrice, SQRT_PRECISION);
}
The handling of decimal precision in this implementation is incorrect, leading to unexpected results. For example, in the DAI/WETH
pool, the function returns the amount of DAI obtainable for 1 WETH, approximately 3600e18 (note that this is for an inverted pool price; otherwise, the price would represent how much WETH one can get for 1 DAI, which is less intuitive to interpret). In the USDC/USDT pool, the function returns a value close to 1e18, reflecting the near 1:1 exchange rate between these stablecoins.
However, due to differences in token decimals, USDT and USDC have 6 decimals, the function scales this value by approximately 1e12.
This inconsistency indicates that the function mishandles token decimals, resulting in one pool's price being returned with no additional precision, while another includes an extra 12 decimals, causing significant discrepancies and confusion.
Remove the last division by SQRT_PRECISION
.
Solved: The Benzo team resolved the issue by removing the final division by SQRT_PRECISION
in SaucerSwapCLMLib::getPoolPrice().
Added another solution that also handles different decimals of LP tokens case, also updated PRECISION in BonzoVaultConcLiq to 18.
//
The StrategyPassiveManagerSaucerSwap
functions by deploying two positions within a SaucerSwap
pool, which is a fork of UniswapV3
.
The first position is based on the current pool price and aims to provide liquidity in both tokens. To achieve this, the code selects lower and upper ticks such that the current pool tick lies between them.
The second position aims to utilize any remaining token after adding liquidity to the main position. This is done by selecting pool ticks that are both above or below the current pool tick, resulting in single-sided liquidity provision.
The alternate position is configured in StrategyPassiveManagerSaucerSwap::_setAltTick()
:
if (amount0 < bal1) {
(positionAlt.tickLower, ) = TickUtils.baseTicks(tick, width, distance);
(, positionAlt.tickUpper) = TickUtils.baseTicks(tick, distance, distance);
}
For illustration, consider the following parameter values:
tickSpacing
= 10
positionWidth
= 10
tick
= 1000
Since the tick spacing is 10 and the position width is 10, the width
parameter is calculated as 100. Ticks are obtained using the following function of Tickutils
contract:
function baseTicks(
int24 currentTick,
int24 baseThreshold,
int24 tickSpacing
) internal pure returns (int24 tickLower, int24 tickUpper) {
int24 tickFloor = floor(currentTick, tickSpacing);
tickLower = tickFloor - baseThreshold;
tickUpper = tickFloor + baseThreshold;
}
Given that tickFloor
is 1000, matching the current tick (since the tick is exactly divisible by the tick spacing), the first call uses width
as the baseThreshold
. Therefore, it returns tickLower
as 1000 - 100 = 900
. The second call uses distance
as the baseThreshold
, resulting in tickUpper
being 1000 + 10 = 1010
.
Note that the distance
value corresponds to the tick spacing.
This situation creates an issue: the upper tick surpasses the current pool tick. Consequently, the position is set to provide liquidity in both tokens. However, since the pool already fully utilized one token during the main position addition, deploying the secondary position with both tokens can cause a revert because it attempts an imbalanced liquidity addition.
Use the first return value in the second baseTicks()
call (the lower tick).
Solved: The Benzo
team resolved the issue by using the first return value in the second baseTicks()
call in StrategyPassiveManagerSaucerSwap's _setAltTick()
.
//
The function SaucerSwapCLMLib
::calculateLiquidityWithPriceCheck
relies on the Uniswap pool’s slot0
value to fetch the current adjustedSqrtPrice
during deposits.
function calculateLiquidityWithPriceCheck(
address pool,
uint160 initialSqrtPrice,
int24 tickLower,
int24 tickUpper,
uint256 amount0,
uint256 amount1,
uint256 priceDeviationTolerance
) external view returns (uint128 liquidity, uint160 adjustedSqrtPrice) {
(adjustedSqrtPrice, , , , , , ) = IUniswapV3Pool(pool).slot0();
Function flow:
calculateLiquidityWithPriceCheck
retrieves adjustedSqrtPrice
from slot0
.
It is called within _calculateLiquidityWithPriceCheck
in StrategyPassiveManagerSaucerSwap
.
_calculateLiquidityWithPriceCheck
is then invoked by _addLiquidity()
.
_addLiquidity()
is executed during the deposit()
function.
The issue arises because slot0
reflects the current spot price, which can be manipulated by attackers. There is no protection against price manipulation, and no slippage checks are enforced when using this price to compute liquidity amounts.
Theoretical attack scenario:
A classical sandwich attack would normally follow this sequence:
A user submits a deposit()
transaction.
An attacker or MEV bot observes this transaction in the mempool.
The attacker front-runs the transaction with a large swap, temporarily shifting the pool price (slot0
).
When the user's deposit()
executes, calculateLiquidityWithPriceCheck
uses the manipulated slot0
price to determine liquidity placement.
The attacker then back-runs with an opposite swap, restoring the price and securing a profit at the expense of the user.
This attack relies entirely on front-running capabilities. On Hedera, front-running is not possible because there is no public mempool and transaction ordering is determined by consensus. This scenario is documented under a defense in depth approach: while infeasible on Hedera, if the same code were deployed on EVM-compatible chains, it could expose users to sandwich attacks.
Instead of relying on slot0
(which reflects the instantaneous spot price), use a time-weighted average price (TWAP) or an external trusted oracle for adjustedSqrtPrice
. This mitigates short-term price manipulation, as TWAP/oracle-based prices cannot be skewed by a single large swap within a block.
Additionally, enforce slippage checks on add/remove liquidity operations to ensure that deposits are executed only within acceptable price bounds.
Acknowledged: The Bonzo
team acknowledged this finding with no code changes, stating that:
No change since there is no front running possible on Hedera.
//
The stakeWithPermit
function in BeefyRewardPool uses a user-provided signature to call IERC20PermitUpgradeable.permit()
before staking tokens.
function stakeWithPermit(
address _user,
uint256 _amount,
uint256 _deadline,
uint8 _v,
bytes32 _r,
bytes32 _s
) external update(_user) {
IERC20PermitUpgradeable(address(stakedToken)).permit(
_user, address(this), _amount, _deadline, _v, _r, _s);
_stake(_user, _amount);
}
ERC20 Permit uses nonces for replay protection. Once a signature is used, the nonce increments, preventing the same signature from being reused.An attacker monitoring the mempool can front-run the user’s transaction, call permit()
themselves with the same signature, and increase the nonce.
This causes the victim’s pending stakeWithPermit
transaction to fail, effectively blocking them from staking.
Theoretical attack scenario
Front-running is not possible on the Hedera network because there is no public mempool and transaction ordering is determined by consensus. However, this scenario could be relevant on other EVM-based chains with a public mempool. The sequence would be as follows
A user generates an ERC20 Permit signature (v, r, s) that allows BeefyRewardPool to spend their tokens.
The user then submits a stakeWithPermit
transaction to the mempool with this signature.
The attacker sees the pending stakeWithPermit
transaction.
They extract the permit
signature (v, r, s
) and transaction details.
Attacker front-runs with permit()
Before the victim’s transaction is confirmed, the attacker broadcasts a transaction directly calling and increments the victim’s nonce.
When the victim’s stakeWithPermit
transaction executes, the permit()
call reverts because the nonce is no longer valid. As a result, the entire stakeWithPermit
reverts and the victim cannot stake.
Users can still stake through the normal stake
function (with prior approval), so the impact is limited to the stakeWithPermit
flow.
In the BeefyRewardPool's stakeWithPermit
function, check if it has the approval it needs. If not, then only submit the permit signature.
function stakeWithPermit(
address _user,
uint256 _amount,
uint256 _deadline,
uint8 _v,
bytes32 _r,
bytes32 _s
) external update(_user) {
if (IERC20(stakedToken).allowance(_user, address(this)) < _amount) {
IERC20PermitUpgradeable(address(stakedToken)).permit(
_user, address(this), _amount, _deadline, _v, _r, _s
);
}
_stake(_user, _amount);
}
Solved: The Benzo
team resolved the issue by checking the allowances()
of the token before calling the permit()
function.
//
The internal YieldLoopConfigurable::_leverage()
function implements the following logic:
IERC20(want).approve(lendingPool, _amount * (leverageLoops + 1));
ILendingPool(lendingPool).deposit(want, _amount, address(this), 0);
uint256 currentCollateral = _amount;
uint256 totalBorrowed = 0;
// Loop for additional leverage
for (uint256 i = 0; i < leverageLoops - 1; i++) {
// Looping logic...
}
Considering the case where leverageLoops
equals 1, the process is as follows:
The code approves an amount of _amount * 2
, effectively doubling the initial amount.
The subsequent deposit uses only _amount
of the approved funds.
Since 0 < 1 - 1
evaluates to false, the loop does not execute.
This results in the lending pool being approved for an extra iteration that is not performed.
Modify IERC20(want).approve
to set the allowance precisely to _amount
when leverageLoops == 1
, and to _amount * (leverageLoops + 1)
only when leverageLoops > 1
, in order to prevent granting unnecessary token approvals.
Acknowledged: The Benzo
team acknowledged this finding, stating the following:
We have added loop+1 approval amount because borrowed amount is again deposited into Lending Pool. So, while doing second deposit there is no need of approval again.
//
In the BonzoSupplyStrategy
::_harvest
function, the StratHarvest
event is emitted using msg.sender
instead of tx.origin
:
function harvest() external virtual {
_harvest(tx.origin);
}
function _harvest(address callFeeRecipient) internal whenNotPaused {
emit StratHarvest(msg.sender, wantHarvested, balanceOf());
}
Since msg.sender
refers to the vault or router contract calling harvest()
, the event does not correctly represent the original user who triggered the transaction.
In BonzoSupplyStrategy
::_harvest
() Use tx.origin
instead of msg.sender
when emitting the event to correctly capture the user initiating the harvest:
emit StratHarvest(tx.origin, balanceOfToken0(), balanceOfToken1());
Solved: The Benzo
team resolved the issue by adding tx.origin
in the Harvest
event emit.
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
Beefy Hedera Contracts
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed