Prepared by:
HALBORN
Last Updated 06/26/2025
Date of Engagement: June 18th, 2025 - June 18th, 2025
100% of all REPORTED Findings have been addressed
All findings
9
Critical
0
High
0
Medium
1
Low
1
Informational
7
Moonwell
engaged Halborn
to conduct a security assessment on their smart contracts beginning on June 18th, 2025 and ending on June 19th, 2025. The security assessment was scoped to the smart contracts provided to Halborn. Commit hash and further details can be found in the Scope section of this report.
The smart contracts under review consisted of StrategyFactory
, a Factory contract for creating new strategy instances with configurable parameters, and StrategyMulticall
, a contract to allow efficient batch updates and generic multicalls to strategies. Furthermore, the security review also added the upgrade of the SlippagePriceChecker
contract to make sure no risks were introduced nor storage collisions.
Halborn
was provided 2 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 objectives of this assessment were 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 were completely addressed by the Moonwell team
. The main ones were the following:
Consider refunding excess funds in genericMulticall() or add a withdraw functionallity.
Add a reentrancy protection to the genericMulticall() function in StrategyMulticall.
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
).
Storage collision study was performed as part of this security review. The original SlippagePriceChecker
only used Slot 0
and Slot 1
:
forge inspect src/SlippagePriceChecker.sol:SlippagePriceChecker storage ╭-------------------+---------------------------------------------------------------------------+------+--------+-------+---------------------------------------------------╮ | Name | Type | Slot | Offset | Bytes | Contract | +===========================================================================================================================================================================+ | tokenOracleData | mapping(address => struct ISlippagePriceChecker.TokenFeedConfiguration[]) | 0 | 0 | 32 | src/SlippagePriceChecker.sol:SlippagePriceChecker | |-------------------+---------------------------------------------------------------------------+------+--------+-------+---------------------------------------------------| | maxTimePriceValid | mapping(address => uint256) | 1 | 0 | 32 | src/SlippagePriceChecker.sol:SlippagePriceChecker | ╰-------------------+---------------------------------------------------------------------------+------+--------+-------+---------------------------------------------------╯
The new SlippagePriceChecker
uses Slot 0
, Slot 1
and Slot 2
:
forge inspect src/SlippagePriceChecker.sol:SlippagePriceChecker storage ╭---------------------+-----------------------------------------------------------------------------------------------+------+--------+-------+---------------------------------------------------╮ | Name | Type | Slot | Offset | Bytes | Contract | +=================================================================================================================================================================================================+ | tokenOracleData | mapping(address => struct ISlippagePriceChecker.TokenFeedConfiguration[]) | 0 | 0 | 32 | src/SlippagePriceChecker.sol:SlippagePriceChecker | |---------------------+-----------------------------------------------------------------------------------------------+------+--------+-------+---------------------------------------------------| | maxTimePriceValid | mapping(address => uint256) | 1 | 0 | 32 | src/SlippagePriceChecker.sol:SlippagePriceChecker | |---------------------+-----------------------------------------------------------------------------------------------+------+--------+-------+---------------------------------------------------| | tokenPairOracleData | mapping(address => mapping(address => struct ISlippagePriceChecker.TokenFeedConfiguration[])) | 2 | 0 | 32 | src/SlippagePriceChecker.sol:SlippagePriceChecker | ╰---------------------+-----------------------------------------------------------------------------------------------+------+--------+-------+---------------------------------------------------╯
Therefore, no potential collisions were observed.
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
1
Low
1
Informational
7
Security analysis | Risk level | Remediation Date |
---|---|---|
Excess Value Becomes Irrecoverable | Medium | Solved - 06/20/2025 |
Missing Reentrancy Protection in StrategyMulticall | Low | Solved - 06/23/2025 |
Single-step Ownership Transfer Process | Informational | Acknowledged - 06/25/2025 |
Owner Can Renounce Ownership | Informational | Acknowledged - 06/25/2025 |
Inaccurate NatSpec Comments | Informational | Acknowledged - 06/25/2025 |
Unused Imports | Informational | Acknowledged - 06/25/2025 |
Use of Revert Strings Instead of Custom Errors | Informational | Acknowledged - 06/25/2025 |
Redundant Named Return | Informational | Acknowledged - 06/25/2025 |
Style Guide Optimizations | Informational | Acknowledged - 06/25/2025 |
//
The genericMulticall()
function does not verify that the combined call.value
values across all batched calls are less than or equal to msg.value
, nor does it provide any mechanism to automatically refund surplus native assets to the caller. Any native funds overpaid by the owner remain locked within the contract balance without a recovery mechanism. Over time, these unrecoverable balances can accumulate, leading to unnecessary capital inefficiency and potential confusion for the owner when reconciling contract holdings.
Consider one or both of the following mitigations to ensure any excess native funds can be safely returned or recovered:
Automatic Refund of Overpayments: Before executing the batched calls, determine the total value required and enforce that it does not exceed msg.value
. After the calls complete, any surplus should be immediately sent back to the caller in a single refund transaction.
Owner-Only Rescue Function: Implement a dedicated function protected by onlyOwner
that allows the contract owner to withdraw any excess native funds held by the contract.
SOLVED: The Moonwell team fixed this finding in commit 37bc9da
by verifying the amount sent to genericMulticall()
as recommended.
//
The StrategyMulticall
contract does not have any protection against reentrancy risks. For the genericMulticall()
function, an attacker (or a mistaken owner transaction) can supply the StrategyMulticall
contract's own address as a call target, causing it to reenter itself before the first invocation completes. Because genericMulticall()
uses a low-level .call{value:…}
without any nonReentrant
guard, the nested invocation would execute the same loop logic recursively.
Although the function is restricted by onlyOwner
and requires sufficient msg.value
for all calls (including the nested ones), a malicious or misconfigured batch could still trigger unexpected behavior or resource exhaustion under edge conditions.
Consider inheriting and applying OpenZeppelin’s ReentrancyGuard
to StrategyMulticall
and mark genericMulticall()
with the nonReentrant
modifier. This will block any attempt to call genericMulticall()
(directly or indirectly) while it is already executing, fully eliminating the self-targeted reentrancy vector.
SOLVED: The Moonwell team fixed this finding in commit 24bb086
by adding a reentrancy protection as recommended.
//
It was identified that the StrategyMulticall
and SlippagePriceChecker
contracts in scope inherit from OpenZeppelin's OwnableUpgradeable
library. Ownership of the contracts that are inherited from the OwnableUpgradeable
module can be lost, as the ownership is transferred in a single-step process.
The address that the ownership is changed to should be verified to be active or willing to act as the owner
. Ownable2StepUpgradeable
is safer than OwnableUpgradeable
for smart contracts because the owner cannot accidentally transfer smart contract ownership to a mistyped address. Rather than directly transferring to the new owner, the transfer only completes when the new owner accepts ownership.
To mitigate the risks associated with single-step ownership transitions and enhance contract security, it is recommended to adopt a two-step ownership transition mechanism, such as OpenZeppelin's Ownable2StepUpgradeable
. This approach introduces an additional step in the ownership transfer process, requiring the new owner to accept ownership before the transition is finalized. The process typically involves the current owner calling a function to nominate a new owner, and the nominee then calling another function to accept ownership.
Implementing Ownable2StepUpgradeable
provides several benefits:
1. Reduces Risk of Accidental Loss of Ownership: By requiring explicit acceptance of ownership, the risk of accidentally transferring ownership to an incorrect or zero address is significantly reduced.
2. Enhanced Security: It adds another layer of security by ensuring that the new owner is prepared and willing to take over the responsibilities associated with contract ownership.
3. Flexibility in Ownership Transitions: Allows for a smoother transition of ownership, as the nominee has the opportunity to prepare for the acceptance of their new role.
By adopting Ownable2StepUpgradeable
, contract administrators can ensure a more secure and controlled process for transferring ownership, safeguarding against the risks associated with accidental or unauthorized ownership changes.
ACKNOWLEDGED: The Moonwell team acknowledged this finding.
//
It was identified that the StrategyMulticall
and SlippagePriceChecker
contracts in scope inherit from OpenZeppelin's OwnableUpgradeable
library. In the OwnableUpgradeable
contracts, the renounceOwnership()
function is used to renounce the Owner
permission. Renouncing ownership before transferring would result in the contract having no owner, eliminating the ability to call privileged functions.
/**
* @dev Leaves the contract without owner. It will not be possible to call
* `onlyOwner` functions. Can only be called by the current owner.
*
* NOTE: Renouncing ownership will leave the contract without an owner,
* thereby disabling any functionality that is only available to the owner.
*/
function renounceOwnership() public virtual onlyOwner {
_transferOwnership(address(0));
}
It is recommended that the Owner cannot call renounceOwnership()
without first transferring ownership to another address. In addition, if a multi-signature wallet is used, the call to the renounceOwnership()
function should be confirmed for two or more users.
ACKNOWLEDGED: The Moonwell team acknowledged this finding.
//
In the StrategyFactory
contract, the @dev
comment says Only callable by accounts with the BACKEND_ROLE in the MamoStrategyRegistry
, but it is actually allowing the user
to call createStrategyForUser()
for itself:
/**
* @notice Creates a new strategy for a specified user
* @dev Only callable by accounts with the BACKEND_ROLE in the MamoStrategyRegistry
* @param user The address of the user to create the strategy for
* @return strategy The address of the newly created strategy
*/
function createStrategyForUser(address user) external returns (address strategy) {
require(user != address(0), "Invalid user address");
require(msg.sender == mamoBackend || msg.sender == user, "Only backend or user can create strategy");
Also, in the SlippagePriceChecker.sol
file, the @title
says PriceChecker
, but the actual name of the contract is SlippagePriceChecker
:
/**
* @title PriceChecker
* @notice Checks swap prices using Chainlink price feeds and applies slippage tolerance
* @dev Implements the ISlippagePriceChecker interface with UUPS upgradeability
*/
contract SlippagePriceChecker is ISlippagePriceChecker, Initializable, UUPSUpgradeable, OwnableUpgradeable {
It is recommended to update the NatSpec comments to accurately reflect the actual implementation.
ACKNOWLEDGED: The Moonwell team acknowledged this finding.
//
The following imports are not used and could be removed:
In StrategyMulticall.sol
:
import {IStrategy} from "./interfaces/IStrategy.sol";
In SlippagePriceChecker.sol
:
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
Consider removing any redundant import to simplify the codebase.
ACKNOWLEDGED: The Moonwell team acknowledged this finding.
//
In Solidity smart contract development, replacing hard-coded revert message strings with the Error()
syntax is an optimization strategy that can significantly reduce gas costs. Hard-coded strings, stored on the blockchain, increase the size and cost of deploying and executing contracts.
The Error()
syntax allows for the definition of reusable, parameterized custom errors, leading to a more efficient use of storage and reduced gas consumption. This approach not only optimizes gas usage during deployment and interaction with the contract but also enhances code maintainability and readability by providing clearer, context-specific error information.
It is recommended to replace hard-coded revert strings in require statements for custom errors, which can be done following the logic below:
1. Standard require statement (to be replaced):
require(condition, "Condition not met");
2. Declare the error definition to state:
error ConditionNotMet();
3. As currently is not possible to use custom errors in combination with require statements, the standard syntax is:
if (!condition) revert ConditionNotMet();
More information about this topic in the official Solidity documentation.
ACKNOWLEDGED: The Moonwell team acknowledged this finding.
//
In the function createStrategyForUser()
, a named return variable strategy
is declared in the function signature and then it is explicitly (and redundantly) returned:
function createStrategyForUser(address user) external returns (address strategy) {
...
return strategy;
}
Consider simplifying the code so it does not explicitly return named returns to avoid redundancies.
ACKNOWLEDGED: The Moonwell team acknowledged this finding.
//
The project codebase contains some stylistic inconsistencies and deviations from Solidity best practices, which, while not directly impacting functionality, reduce code readability, maintainability, and adherence to standard conventions. Addressing these inconsistencies can enhance the clarity and professionalism of the code.
Use Underscores or Scientific Notation for Number Literals: Instead of using 10_000
or 1_000
, or 1e4
and 1e3
, the StrategyFactory
contract is using 10000
and 1000
as shown below.
uint256 public constant SPLIT_TOTAL = 10000; // 100% in basis points
uint256 public constant MAX_SLIPPAGE_IN_BPS = 1000; // 10% in basis points
uint256 public constant MAX_COMPOUND_FEE = 1000; // 10% in basis points
require(_splitMToken + _splitVault == SPLIT_TOTAL, "Split parameters must add up to 10000");
Internal variables, constants and functions should start with an underscore (_): Notice the internal constant MAX_BPS
does not start with an underscore (_) contrary to the style guide.
uint256 internal constant MAX_BPS = 10_000;
Interface declaration inside a contract file: The SlippagePriceChecker.sol
file is not only declaring the SlippagePriceChecker
contract as the style guide recommends, but also an interface named IERC20MetaData
. One file per declaration is preferred.
interface IERC20MetaData {
function decimals() external view returns (uint8);
}
/**
* @title PriceChecker
* @notice Checks swap prices using Chainlink price feeds and applies slippage tolerance
* @dev Implements the ISlippagePriceChecker interface with UUPS upgradeability
*/
contract SlippagePriceChecker is ISlippagePriceChecker, Initializable, UUPSUpgradeable, OwnableUpgradeable {
...
Consider implementing the style improvements highlighted above to enhance the readability and consistency of the project.
ACKNOWLEDGED: The Moonwell team acknowledged this finding.
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.
All issues identified by Slither were proved to be false positives or have been added to the issue list in this report.
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
Mamo PR
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed