Prepared by:
HALBORN
Last Updated 10/10/2025
Date of Engagement: September 23rd, 2025 - September 30th, 2025
100% of all REPORTED Findings have been addressed
All findings
11
Critical
0
High
0
Medium
0
Low
1
Informational
10
Blueprint Finance
engaged Halborn
to perform a security assessment of their smart contracts from September 23rd, 2025 to September 30th, 2025. The assessment scope was limited to the smart contracts provided to Halborn. Commit hashes and additional details are available in the Scope section of this report.
The Blueprint Finance
codebase in scope consists of smart contracts implementing an upgradeable vault system with asynchronous withdrawal queuing, multi-strategy asset allocation, fee splitting, and privileged strategy management.
Halborn
was allocated 6 days for this engagement and assigned 1 full-time security engineer to conduct a comprehensive review of the smart contracts within scope. The engineer is an expert in blockchain and smart contract security, with advanced skills in penetration testing and smart contract exploitation, as well as extensive knowledge of multiple blockchain protocols.
The objectives of this assessment are to:
Identify potential security vulnerabilities within the smart contracts.
Verify that the smart contract functionality operates as intended.
In summary, Halborn
identified several areas for improvement to reduce the likelihood and impact of security risks, which were partially addressed by the Blueprint Finance team
. The primary recommendations were:
Reorder the logic in claimUsersBatch() to clear the user's claimable state before performing the external transfer.
Enforce a minimum margin between accountingValidityPeriod and cooldownPeriod in both setter functions to ensure a robust and predictable update window.
Restrict unpauseAndAdjustTotalAssets() to only allow adjustments that pass the same validation as adjustTotalAssets(), or remove the function if not strictly necessary.
Halborn
conducted a combination of manual code review and automated security testing to balance efficiency, timeliness, practicality, and accuracy within the scope of this assessment. While manual testing is crucial for identifying flaws in logic, processes, and implementation, automated testing enhances coverage of smart contracts and quickly detects deviations from established security best practices.
The following phases and associated tools were employed throughout the term of the assessment:
Research into the platform's architecture, purpose and use.
Manual code review and walkthrough of smart contracts to identify any logical issues.
Comprehensive assessment of the safety and usage of critical Solidity variables and functions within scope that could lead to arithmetic-related vulnerabilities.
Local testing using custom scripts (Foundry
).
Fork testing against main networks (Foundry
).
Static security analysis of scoped contracts, and imported functions (Slither
).
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 (C:N) Low (C:L) Medium (C:M) High (C:H) Critical (C: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
0
Low
1
Informational
10
Security analysis | Risk level | Remediation Date |
---|---|---|
Unfollowed checks-effects-interactions pattern | Low | Solved - 10/03/2025 |
Missing input validation | Informational | Partially Solved - 10/03/2025 |
Insufficient margin between accounting validity and cooldown periods | Informational | Solved - 10/02/2025 |
Admin can bypass accounting guard via unpauseAndAdjustTotalAssets | Informational | Acknowledged - 10/03/2025 |
Rounding mode mismatch in epoch asset reservation logic | Informational | Solved - 10/03/2025 |
Incorrect storage slot constant declaration | Informational | Solved - 10/03/2025 |
Lack of recipient validation in fee splitter enables misleading accounting | Informational | Solved - 10/03/2025 |
Stale per-epoch totalRequestedShares value after processing | Informational | Acknowledged - 10/03/2025 |
ReentrancyGuardUpgradeable initializer not invoked | Informational | Solved - 10/03/2025 |
Typo in the code | Informational | Solved - 10/03/2025 |
Redundant code | Informational | Solved - 10/03/2025 |
//
The claimUsersBatch()
function in AsyncVaultHelperLib
processes batch claims for users in a given epoch. However, it performs the external asset transfer (IERC20(asset).safeTransfer(user, assets)
) before clearing the user's claimable state (userEpochRequests[user][epochID] = 0
). This ordering does not follow the checks-effects-interactions pattern, which is a best practice to prevent reentrancy vulnerabilities.
While the current implementation is protected by role-based access and the underlying asset is assumed to be a standard ERC20, future upgrades or integration with tokens supporting hooks (e.g., ERC777) could expose the contract to reentrancy risks, potentially allowing a user to double-claim assets.
Reorder the logic in claimUsersBatch()
to clear the user's claimable state before performing the external transfer.
SOLVED: The Blueprint Finance team solved this finding in the specified commit by following the mentioned recommendation.
//
Throughout the codebase, there are several instances where input values are assigned without proper validation. Failing to validate inputs before assigning them to state variables or using them in protocol logic can lead to unexpected system behavior, weakened safety guarantees, or even complete failure.
Instances of this issue include:
In PositionAccountingLib.setMaxAccountingChangeThreshold()
, the maxAccountingChangeThreshold_
parameter is assigned directly without checking that it is less than or equal to BASIS_POINTS
(10,000).
In PositionAccountingLib.setCooldownPeriod()
, the cooldownPeriod_
parameter is not validated for a sensible minimum value (e.g., greater than zero), which could allow disabling cooldown protection.
In ConcreteAsyncVaultImpl.toggleQueueActive()
, there is no validation to prevent disabling the queue while there are pending requests, which could lead to user confusion or inconsistent withdrawal behavior.
In management and performance fee setters (updateManagementFee()
, updatePerformanceFee()
), there are no hard-coded upper bounds on fee rates, allowing privileged roles to set excessive fees and dilute user shares.
In PositionAccountingStorageLib.initialize()
, the maxAccountingChangeThreshold_
parameter is assigned directly without checking that it is less than or equal to BASIS_POINTS
(10,000).
In PositionAccountingStorageLib.initialize()
, the accountingValidityPeriod_
parameter is not validated to ensure it is greater than cooldownPeriod_
, which can lead to immediate expiry or liveness issues.
In TwoWayFeeSplitter.initialize()
, the feeType
parameter is not validated or restricted to a known set of values, which can lead to inconsistent or meaningless fee type labeling across deployments.
Add proper validation to ensure that input values are within expected ranges and that addresses are not the zero address.
PARTIALLY SOLVED: The Blueprint Finance team partially solved this finding in the specified commit by adding input validation to several of the aforementioned instances.
//
The setAccountingValidityPeriod()
and setCooldownPeriod()
functions in PositionAccountingLib
enforce that accountingValidityPeriod
must be strictly greater than cooldownPeriod
. However, the contract does not enforce a minimum margin between these two values.
If accountingValidityPeriod
is set only slightly greater than cooldownPeriod
(e.g., by 1 second), there will be an extremely narrow window to perform accounting updates after the cooldown expires. This fragility can lead to missed updates due to block time variance or transaction delays, potentially causing the protocol to revert with AccountingValidityPeriodExpired()
even when the cooldown has passed.
For example, if cooldownPeriod
is set to 100 seconds and accountingValidityPeriod
to 101 seconds, there is only a 1-second window to perform the next update after cooldown, which is impractical and unreliable.
Enforce a minimum margin between accountingValidityPeriod
and cooldownPeriod
in both setter functions to ensure a robust and predictable update window.
SOLVED: The Blueprint Finance team solved this finding in the specified commit by following the mentioned recommendation.
//
The MultisigStrategy.unpauseAndAdjustTotalAssets()
function allows an address with the STRATEGY_ADMIN role to unpause the strategy and directly adjust the reported total assets by any amount, without passing through the accounting validation logic enforced by PositionAccountingLib.isValidAccountingChange()
. This bypasses the intended controls on asset reporting and nonce/timestamp advancement.
Restrict unpauseAndAdjustTotalAssets()
to only allow adjustments that pass the same validation as adjustTotalAssets()
, or remove the function if not strictly necessary.
ACKNOWLEDGED: The Blueprint Finance team made a business decision to acknowledge this finding and not alter the contracts.
//
The ConcreteAsyncVaultImpl
contract is designed to reserve assets during epoch processing to guarantee that all user withdrawal claims can be fulfilled. According to the technical documentation (ConcreteAsyncVaultImpl-doc.md
, section 7.2), the calculation for reserving assets should use rounding up (Math.Rounding.Ceil
) when converting shares to assets.
However, the actual implementation in the contract uses rounding down (Math.Rounding.Floor
) when calculating the share price and, by extension, the reserved assets.
This discrepancy means that, for each epoch, a small amount of value may be left unreserved, causing users to receive slightly less than their fair share when claiming withdrawals. Over time, this dust can accumulate in the vault, diverging from the intended behavior described in the documentation.
Update the implementation to use Math.Rounding.Ceil
when reserving assets for processed epochs, aligning the code with the documented specification.
Alternatively, if the current behavior is preferred, update the documentation to reflect the use of rounding down.
SOLVED: The Blueprint Finance team solved this finding in the specified commit by following the mentioned recommendation and updating the documentation accordingly.
//
The ConcreteAsyncVaultImplStorageLib
library defines the storage slot constant ConcreteAsyncVaultImplStorageLocation
as:
/// @dev keccak256(abi.encode(uint256(keccak256("concrete.storage.ConcreteAsyncVaultImplStorage")) - 1)) & ~bytes32(uint256(0xff))
bytes32 private constant ConcreteAsyncVaultImplStorageLocation =
0xd3b5f67b5a9bb5c5a5b5c5a5b5c5a5b5c5a5b5c5a5b5c5a5b5c5a5b5c5a5b500;
However, the correct value, as computed by the documented formulakeccak256(abi.encode(uint256(keccak256("concrete.storage.ConcreteAsyncVaultImplStorage")) - 1)) & ~bytes32(uint256(0xff))
using Foundry's Chisel tool is 0xada5b606f7944319310c49c0f9f30d6272793a991bd2b9c3db8049867746700
:
While this does not currently break storage access if the incorrect slot is used consistently throughout the codebase, it may cause confusion for future maintainers or integrators who expect the slot to match the documented formula. This inconsistency could lead to integration issues or errors if other contracts or tools rely on the documented calculation.
Update the ConcreteAsyncVaultImplStorageLocation
constant to match the documented formula.
SOLVED: The Blueprint Finance team solved this finding in the specified commit by following the mentioned recommendation.
//
The TwoWayFeeSplitter
contract allows either the mainRecipient
or secondaryRecipient
to be set to the splitter contract’s own address (address(this)
), or for both recipients to be set to the same address.
When this occurs, calling distributeFees()
will transfer vault tokens to the splitter itself or to the same address twice, leaving the contract’s balance unchanged and/or inflating the feesDistributed
metric. This can mislead off-chain systems or dashboards that rely on these metrics.
Add checks to prevent either recipient from being set to address(this)
and to ensure that mainRecipient
and secondaryRecipient
are not identical.
SOLVED: The Blueprint Finance team solved this finding in the specified commit by following the mentioned recommendation.
//
AsyncVaultHelperLib.processEpoch()
burns requestingShares
and sets epochPricePerShare
but leaves totalRequestedSharesPerEpoch[epochID]
untouched. Post-processing logic never uses it, yet off-chain indexers may misinterpret the non-zero value as still-queued requests.
Set totalRequestedSharesPerEpoch[epochID] = 0
.
ACKNOWLEDGED: The Blueprint Finance
team made a business decision to acknowledge this finding and not alter the contracts.
//
The TwoWayFeeSplitter
contract inherits from ReentrancyGuardUpgradeable
but does not call __ReentrancyGuard_init()
in its initialize()
function.
While this omission does not currently impact the contract's security or functionality, it deviates from OpenZeppelin's recommended upgradeable contract initialization pattern.
Add a call to __ReentrancyGuard_init()
in the initialize()
function.
SOLVED: The Blueprint Finance team solved this finding in the specified commit by following the mentioned recommendation.
//
In the ConcreteAsyncVaultImplStorage
struct of the ConcreteAsyncVaultImplStorageLib
library, there is a typo, where the word Unclaimed is misspelled as Unlcaimed. The same case can be found in the pastEpochsUnlcaimedAssets()
function of the ConcreteAsyncVaultImpl
contract.
While this typo does not affect the functionality of the code, it can make the codebase harder to read and understand.
It is recommended to fix all typos to improve the readability of the codebase.
SOLVED: The Blueprint Finance team solved this finding in the specified commit by following the mentioned recommendation.
//
The ConcreteAsyncVaultImpl._executeWithdraw()
function contains two identical checks: require(shares > 0, ZeroShares());
. One at the start and another inside the if ($.isQueueActive)
block. Since the first check already reverts if shares == 0
, the second is redundant and unreachable.
Remove the second require(shares > 0, ZeroShares());
inside the if ($.isQueueActive)
block to simplify the code.
SOLVED: The Blueprint Finance team solved this finding in the specified commit by following the mentioned recommendation.
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
Earn V2 Core - Async Implementation
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed