Prepared by:
HALBORN
Last Updated 01/30/2025
Date of Engagement: January 2nd, 2025 - January 24th, 2025
0% of all REPORTED Findings have been addressed
All findings
6
Critical
0
High
0
Medium
2
Low
3
Informational
1
Harvest Finance
engaged Halborn
to conduct a security assessment on their smart contracts beginning on January 2nd, 2025 and ending on January 27th, 2025. The security assessment was scoped to the smart contracts provided in the private repository provided to Halborn
. Further details can be found in the Scope section of this report.
Halborn
was provided 16 days for the engagement, and assigned one full-time security engineer to review the security of the smart contracts in scope. The engineer is a blockchain and smart contract security expert with advanced penetration testing and smart contract hacking skills, and deep knowledge of multiple blockchain protocols.
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 some improvements to reduce the likelihood and impact of risks, which should be addressed by the Harvest Finance team
:
Use a single consistent precision factor (e.g., 1e18) for accumulating rewards across all tokens.
Replace amountOutMin = 1 with a slippage-aware calculation.
Implement a _claimRewards() function that retrieves external rewards from the lending protocol.
Consider adding a nonReentrant modifier (from OpenZeppelin’s ReentrancyGuard) in all functions across the code-base performing external calls to potentially untrusted contracts.
Add a storage gap in upgradeable contracts that are parent contracts.
Upgrade the Solidity version to 0.8.x.
Halborn
performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of this assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of the code and can quickly identify items that do not follow the security best practices. The following phases and associated tools were used during the assessment:
Research into architecture and purpose.
Smart contract manual code review and walkthrough.
Graphing out functionality and contract logic/connectivity/functions (solgraph
).
Manual assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic related vulnerability classes.
Manual testing by custom scripts.
Static Analysis of security for scoped contract, and imported functions (slither
).
Testnet deployment (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
0
High
0
Medium
2
Low
3
Informational
1
Security analysis | Risk level | Remediation Date |
---|---|---|
HAL-01 - Decimal mismatch in reward calculations | Medium | - |
HAL-02 - Missing slippage checks in multiple locations | Medium | - |
HAL-03 - Potential reentrancy | Low | - |
HAL-04 - VaultV1 upgradeable contract is missing storage gap | Low | - |
HAL-05 - Outdated Solidity version | Low | - |
HAL-06 - Minimal event emissions for key state changes | Informational | - |
//
When computing rewards, the PotPool
contract uses the pool’s LP token decimals (set via _setupDecimals
) in rewardPerToken(...)
and then divides by 10^(rewardToken.decimals())
in the earned(...)
function. In other words:
rewardPerToken(rt)
multiplies by 10^(poolDecimals)
earned(rt, account)
divides by 10^(rewardToken.decimals())
- contracts/base/PotPool.sol
function rewardPerToken(address rt) public view returns (uint256) {
if (totalSupply() == 0) {
return rewardPerTokenStoredForToken[rt];
}
return
rewardPerTokenStoredForToken[rt].add(
lastTimeRewardApplicable(rt)
.sub(lastUpdateTimeForToken[rt])
.mul(rewardRateForToken[rt])
.mul(10**uint256(decimals()))
.div(totalSupply())
);
}
function earned(address rt, address account) public view returns (uint256) {
return
stakedBalanceOf[account]
.mul(rewardPerToken(rt).sub(userRewardPerTokenPaidForToken[rt][account]))
.div(10**uint256(ERC20(rt).decimals()))
.add(rewardsForToken[rt][account]);
}
If the reward token’s decimals differ from the LP token’s decimals, the final reward amounts could be scaled incorrectly or lead to unexpected rounding.
It is recommended to:
Use a single consistent precision factor (e.g., 1e18
) for accumulating rewards across all tokens.
Alternatively, ensure that the reward token’s decimals always match the LP token’s decimals if that is the intended design.
Clearly document any difference between the pool’s decimal usage and the reward token’s decimal usage so that integrators are aware of potential rounding effects.
//
In multiple strategies, when converting reward tokens back to the underlying asset, the code commonly passes a minimum output of 1
:
IUniversalLiquidator(...).swap(rewardToken, underlying, balance, 1, address(this));
This does not protect against front-running or sandwich attacks. If a large slippage event occurs, the strategy might receive far fewer tokens than expected, negatively impacting user yields.
In the RewardForwarder
contract, when calling IUniversalLiquidator(liquidator).swap(...)
, the code sets amountOutMin = 1
. This effectively means “accept any non-zero swap output,” which can lead to highly unfavorable slippage if the market shifts or if the liquidator is front-run. Attackers could exploit this to cause near-zero returns on token swaps:
function _notifyFee(
address _token,
uint256 _profitSharingFee,
uint256 _strategistFee,
uint256 _platformFee
) internal {
address _controller = controller();
address liquidator = IController(_controller).universalLiquidator();
uint totalTransferAmount = _profitSharingFee.add(_strategistFee).add(_platformFee);
require(totalTransferAmount > 0, "totalTransferAmount should not be 0");
IERC20(_token).safeTransferFrom(msg.sender, address(this), totalTransferAmount);
address _targetToken = IController(_controller).targetToken();
if (_token != _targetToken) {
IERC20(_token).safeApprove(liquidator, 0);
IERC20(_token).safeApprove(liquidator, _platformFee);
uint amountOutMin = 1;
if (_platformFee > 0) {
IUniversalLiquidator(liquidator).swap(
_token,
_targetToken,
_platformFee,
amountOutMin,
IController(_controller).protocolFeeReceiver()
);
}
} else {
IERC20(_targetToken).safeTransfer(IController(_controller).protocolFeeReceiver(), _platformFee);
}
if (_token != iFARM) {
IERC20(_token).safeApprove(liquidator, 0);
IERC20(_token).safeApprove(liquidator, _profitSharingFee.add(_strategistFee));
uint amountOutMin = 1;
if (_profitSharingFee > 0) {
IUniversalLiquidator(liquidator).swap(
_token,
iFARM,
_profitSharingFee,
amountOutMin,
IController(_controller).profitSharingReceiver()
);
}
if (_strategistFee > 0) {
IUniversalLiquidator(liquidator).swap(
_token,
iFARM,
_strategistFee,
amountOutMin,
IStrategy(msg.sender).strategist()
);
}
} else {
if (_strategistFee > 0) {
IERC20(iFARM).safeTransfer(IStrategy(msg.sender).strategist(), _strategistFee);
}
IERC20(iFARM).safeTransfer(IController(_controller).profitSharingReceiver(), _profitSharingFee);
}
}
Within the _liquidateReward()
function, the code calls the Universal Liquidator with minAmountOut
set to 1
:
IUniversalLiquidator(_universalLiquidator).swap(
_rewardToken,
_underlying,
remainingRewardBalance,
1,
address(this)
);
A minAmountOut
of 1
leaves no meaningful protection against large price slippage or sandwich attacks. In a hostile environment, a manipulated or malicious swap could cause the strategy to receive far fewer underlying tokens than expected, resulting in lost yield for strategy users.
Seen In:
CompoundStrategy (in _liquidateReward()
)
AerodromeStableStrategy / AerodromeVolatileStrategy (in _liquidateReward()
)
ExtraFiLendStrategy (in _liquidateRewards()
)
FluidLendStrategy (in _liquidateRewards()
)
MoonwellFoldStrategyV2 (in _liquidateRewards()
)
To solve issues related to missing slippage checks, it is recommended to:
Replace amountOutMin = 1
with a slippage-aware calculation. For example, pass in a minimum expected output from off-chain price checks or keep track of a tolerance limit.
If dynamic slippage is not practical, at least allow the caller (strategy or governance) to specify a suitable amountOutMin
to protect against front-running or illiquid markets.
//
In the PotPool
contract, pushAllRewards(address recipient)
calls updateRewards(recipient)
and transfers rewards to recipient
. Unlike getAllRewards
or withdraw
, pushAllRewards
does not have the nonReentrant
or defense
modifiers. Although it is restricted to governance only, if recipient
is a malicious contract, it could potentially re-enter via token callbacks (depending on the token used):
function pushAllRewards(address recipient) external updateRewards(recipient) onlyGovernance {
bool rewardPayout = (!smartContractStakers[recipient] || !IController(controller()).greyList(recipient));
for(uint256 i = 0 ; i < rewardTokens.length; i++ ){
uint256 reward = earned(rewardTokens[i], recipient);
if (reward > 0) {
rewardsForToken[rewardTokens[i]][recipient] = 0;
if (rewardPayout) {
IERC20(rewardTokens[i]).safeTransfer(recipient, reward);
emit RewardPaid(recipient, rewardTokens[i], reward);
} else {
emit RewardDenied(recipient, rewardTokens[i], reward);
}
}
}
Moving forward, most strategies do not implement a nonReentrant
guard. Although many DeFi protocols do not expose arbitrary callbacks, if a reward token or external lending/pool contract allowed reentrant calls, the strategy’s state-changing logic could be re-invoked unexpectedly.
Seen in:
CompoundStrategy (internal calls to IComet
, no nonReentrant
)
Aerodrome*Strategy (calls IGauge
, IPool
, no nonReentrant
)
ExtraFiLendStrategy, FluidLendStrategy (calls external vaults)
MoonwellFoldStrategyV2 (flash loan from Balancer bVault
, no standard reentrancy guard)
In order to solve this issue, it is recommended to:
If liquidator
is trusted and never calls back into RewardForwarder
, re-entrancy is likely not an issue. However, if future changes might allow untrusted or user-supplied liquidators, consider adding a nonReentrant
modifier (from OpenZeppelin’s ReentrancyGuard
) in all functions across the code-base performing external calls to potentially untrusted contracts.
Keep documentation indicating that only a trusted liquidator is intended.
If external protocols are trusted and have no callbacks, the risk is low. Otherwise, consider adding ReentrancyGuard
to state-changing functions.
Keep track of potential tokens or new protocol features that could introduce callback functions in the future.
//
The VaultV1
contract, which inherits or acts as an upgradeable parent contract, does not reserve a storage gap at the end of its contract variables. In the context of upgradeable contracts, a storage gap is critical to preventing storage layout conflicts when future state variables are added in subsequent contract versions. Without a storage gap, any future modifications to the contract’s storage layout could overwrite existing state variables, leading to unpredictable behavior or loss of state.
In order to resolve this issue, it is recommended to:
Add a reserved storage gap array (e.g., uint256[50] private __gap;
) at the end of the contract to preserve the current storage layout.
Ensure all future variables are added above this gap array, maintaining the existing layout for previously deployed contract instances.
//
Several contracts utilize the older Solidity 0.6.x compiler version, which lacks the newest language features, improvements in error handling, and security enhancements introduced in later releases. Continuing to use a legacy compiler version may expose these contracts to known bugs and limit compatibility with modern tooling and best practices.
To solve this issue, it is recommended to:
Migrate the codebase to a more recent Solidity version (e.g., 0.8.x), after confirming no breaking changes in dependencies.
Leverage updated compiler checks, safe math built-ins (unchecked
blocks in Solidity 0.8+), and other improvements to enhance the contracts’ reliability and maintainability.
//
Several strategies do not emit events when critical parameters change (e.g., changing gauges, adding reward tokens, toggling folding, or pausing investments). This hinders off-chain analytics and transparency.
Seen In:
CompoundStrategy (no events for certain changes like market()
updates)
AerodromeStable/VolatileStrategy (no events for setGauge
, addRewardToken
, etc.)
ExtraFiLendStrategy (no events for adjusting fees or market
updates)
FluidLendStrategy (no events for addRewardToken
, emergency exit, etc.)
MoonwellFoldStrategyV2 (no events on factor updates, toggling folding)
To solve this issue, it is recommended to:
Emit events (e.g., GaugeChanged
, RewardTokenAdded
, FoldingToggled
, etc.) with relevant parameters (old/new values).
Include indexed
parameters to facilitate off-chain querying.
Halborn used automated testing techniques to enhance the coverage of certain areas of the smart contracts in scope. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified the smart contracts in the repository and was able to compile them correctly into their ABIs and binary format, Slither was run against the contracts. This tool can statically verify mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs across the entire code-base.
The security team conducted a comprehensive review of all findings generated by the Slither static analysis tool.
After careful examination and consideration of the flagged issues, it was determined that within the project's specific context and scope, all were false positives or irrelevant.
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
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed