Earn (Diff) - Blueprint Finance


Prepared by:

Halborn Logo

HALBORN

Last Updated Unknown date

Date of Engagement: June 10th, 2024 - June 17th, 2024

Summary

100% of all REPORTED Findings have been addressed

All findings

7

Critical

0

High

0

Medium

1

Low

3

Informational

3


1. Introduction

Concrete engaged Halborn to conduct a security assessment on their smart contract beginning on June 10th, 2024 and ending on June 17th, 2024. A security assessment on smart contracts was performed on the scoped smart contracts provided to the Halborn team.

2. Assessment Summary

The team at Halborn was provided one week for the engagement and assigned a full-time security engineer to verify the security of the smart contract. The security engineer is a blockchain and smart-contract security expert with advanced penetration testing, smart-contract hacking, and deep knowledge of multiple blockchain protocols.

The purpose of this assessment is to:

    • Ensure that smart contract functions operate as intended.

    • Identify potential security issues with the smart contracts.

In summary, Halborn identified some security risks that, which were mostly addressed by the Concrete team.

3. Test Approach and Methodology

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.

    • Testnet deployment (Foundry).

3.1 Out-of-scope

    • External libraries and financial-related attacks.

    • Files located under src/examples/* folder.

4. RISK METHODOLOGY

Every vulnerability and issue observed by Halborn is ranked based on two sets of Metrics and a Severity Coefficient. This system is inspired by the industry standard Common Vulnerability Scoring System.
The two Metric sets are: Exploitability and Impact. Exploitability captures the ease and technical means by which vulnerabilities can be exploited and Impact describes the consequences of a successful exploit.
The Severity Coefficients is designed to further refine the accuracy of the ranking with two factors: Reversibility and Scope. These capture the impact of the vulnerability on the environment as well as the number of users and smart contracts affected.
The final score is a value between 0-10 rounded up to 1 decimal place and 10 corresponding to the highest security risk. This provides an objective and accurate rating of the severity of security vulnerabilities in smart contracts.
The system is designed to assist in identifying and prioritizing vulnerabilities based on their level of risk to address the most critical issues in a timely manner.

4.1 EXPLOITABILITY

Attack Origin (AO):
Captures whether the attack requires compromising a specific account.
Attack Cost (AC):
Captures the cost of exploiting the vulnerability incurred by the attacker relative to sending a single transaction on the relevant blockchain. Includes but is not limited to financial and computational cost.
Attack Complexity (AX):
Describes the conditions beyond the attacker’s control that must exist in order to exploit the vulnerability. Includes but is not limited to macro situation, available third-party liquidity and regulatory challenges.
Metrics:
EXPLOITABILITY METRIC (mem_e)METRIC VALUENUMERICAL 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
Exploitability EE is calculated using the following formula:

E=meE = \prod m_e

4.2 IMPACT

Confidentiality (C):
Measures the impact to the confidentiality of the information resources managed by the contract due to a successfully exploited vulnerability. Confidentiality refers to limiting access to authorized users only.
Integrity (I):
Measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of data stored and/or processed on-chain. Integrity impact directly affecting Deposit or Yield records is excluded.
Availability (A):
Measures the impact to the availability of the impacted component resulting from a successfully exploited vulnerability. This metric refers to smart contract features and functionality, not state. Availability impact directly affecting Deposit or Yield is excluded.
Deposit (D):
Measures the impact to the deposits made to the contract by either users or owners.
Yield (Y):
Measures the impact to the yield generated by the contract for either users or owners.
Metrics:
IMPACT METRIC (mIm_I)METRIC VALUENUMERICAL 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
Impact II is calculated using the following formula:

I=max(mI)+mImax(mI)4I = max(m_I) + \frac{\sum{m_I} - max(m_I)}{4}

4.3 SEVERITY COEFFICIENT

Reversibility (R):
Describes the share of the exploited vulnerability effects that can be reversed. For upgradeable contracts, assume the contract private key is available.
Scope (S):
Captures whether a vulnerability in one vulnerable contract impacts resources in other contracts.
Metrics:
SEVERITY COEFFICIENT (CC)COEFFICIENT VALUENUMERICAL VALUE
Reversibility (rr)None (R:N)
Partial (R:P)
Full (R:F)
1
0.5
0.25
Scope (ss)Changed (S:C)
Unchanged (S:U)
1.25
1
Severity Coefficient CC is obtained by the following product:

C=rsC = rs

The Vulnerability Severity Score SS is obtained by:

S=min(10,EIC10)S = min(10, EIC * 10)

The score is rounded up to 1 decimal places.
SeverityScore Value Range
Critical9 - 10
High7 - 8.9
Medium4.5 - 6.9
Low2 - 4.4
Informational0 - 1.9

5. SCOPE

REPOSITORY
(a) Repository: money-printer
(b) Assessed Commit ID: b285ec0
(c) Items in scope:
  • src/vault/ConcreteMultiStrategyVault.sol
  • src/swapper/Swapper.sol
  • src/swapper/OraclePlug.sol
↓ Expand ↓
Out-of-Scope: New features/implementations after the remediation commit IDs.

6. Assessment Summary & Findings Overview

Critical

0

High

0

Medium

1

Low

3

Informational

3

Security analysisRisk levelRemediation Date
Logic issue on vault removalMediumSolved - 07/29/2024
Potential Denial of Service on removeVault with block gas limitLowSolved - 06/09/2024
Potential Denial of Service on prepare withdrawal functionLowRisk Accepted
Single step ownership transfer processLowRisk Accepted
Duplicated ImportsInformationalSolved - 07/30/2024
Events for Function CallsInformationalAcknowledged
Centralization risk: Lack of Access Control in requestFundsInformationalAcknowledged

7. Findings & Tech Details

7.1 Logic issue on vault removal

//

Medium

Description

There's a logic issue on removeVault on VaultRegistry.sol. If the admin of vault manager chooses a wrong address vault and a correct vault ID to be removed (removeVault(address vault_, bytes32 vaultId_)), will be removed the wrong ones. This issue could lead a wrong vault removal due to this logic issue. Consider checking if the vault ID that will be removed corresponds with the vault address passed as first parameter.
Moreover, the value of vaultIdToAddressArray[vaultId_] will never be removed from the mapping since the vault is not equal to the value of the mapping on the check inside _handleRemoveVault function.

function removeVault(address vault_, bytes32 vaultId_) external onlyOwner {
        console.log("VAULT ID TO ADDR ARRAY: ",vaultIdToAddressArray[vaultId_][0]);
        vaultExists[vault_] = false;
        _handleRemoveVault(vault_, allVaultsCreated);
        _handleRemoveVault(vault_, vaultIdToAddressArray[vaultId_]);
        assert(vaultsByToken[IERC4626(vault_).asset()].remove(vault_));
    }
Proof of Concept

The following Foundry test was used in order to prove the aforementioned issue:

function test_removeVaultAndRedeployLogicIssue() public {
        
        for (uint256 index = 0; index < 10; index++) { //_deployVault(false)
            ImplementationData memory data =
            ImplementationData({implementationAddress: implementation, initDataRequired: true});

        
            vm.prank(admin);
            vaultManager.registerNewImplementation(keccak256(abi.encode("Test", index)), data);
        
            (bytes memory initData, Strategy[] memory strategies) = _getInitData();
            vm.prank(admin);
            vaultAddress.push(vaultManager.deployNewVault(keccak256(abi.encode("Test", index)), initData));
        }
        for (uint256 index = 0; index < 10; index++) {
            address[] memory vaults = vaultRegistry.getAllVaults();
            console.log("VAULT: ",vaults[index]);
        }
        //(address vault,) = _deployVault(false);

        vm.prank(admin);
        vaultManager.removeVault(vaultAddress[9], keccak256(abi.encode("Test", 8))); //admin wanted to remove the vault ID 8, however the vault removed was vault 9. 

        for (uint256 index = 0; index < 9; index++) {
            address[] memory vaults = vaultRegistry.getAllVaults();
            console.log("VAULT: ",vaults[index]);
        }

        address[] memory vaults = vaultRegistry.getAllVaults();
        console.log("LEN OLD VAULT: ",vaults.length);
        //assertEq(vaults.length, 0, "Length");
        vm.warp(block.timestamp + 1);

        ImplementationData memory data =
        ImplementationData({implementationAddress: implementation, initDataRequired: true});
        (bytes memory initData, Strategy[] memory strategies) = _getInitData();
        vm.prank(admin);
        address vaultAddress2 = vaultManager.deployNewVault(keccak256(abi.encode("Test", 9)), initData);

        address[] memory newVaults = vaultRegistry.getAllVaults();
        console.log("LEN NEW VAULT: ",newVaults.length);

        for (uint256 index = 0; index < 10; index++) {
            address[] memory vaults = vaultRegistry.getAllVaults();
            console.log("VAULT: ",vaults[index]);
        }

        vm.prank(admin);
        vaultManager.removeVault(vaultAddress2, keccak256(abi.encode("Test", 9)));
        for (uint256 index = 0; index < 9; index++) {
            address[] memory vaults = vaultRegistry.getAllVaults();
            console.log("VAULT: ",vaults[index]);
        }
        //assertEq(newVaults.length, 1, "Length");
        
    }

Evidence:

BVSS
Recommendation

Consider checking that the address exists in both arrays, to make sure that the id and the address are correct.

Remediation Plan

SOLVED : The Concrete team solved the issue. The issue is already fixed in the commit id sent. We are checking if the vault address exists for the vaultId. The fix is at the end of the function _handleRemoveVault.

7.2 Potential Denial of Service on removeVault with block gas limit

//

Low

Description

It has been observed that if a particular vault wanted to be removed, the _handleRemoveVault function iterates over all vaultArray consuming a lot of gas in case there's a huge amount of vault created on the array vaultArray_. If the array is big enough, the block gas limit will be reached and the transaction will never get processed. For that reason, it is recommended to add a require statement for limiting adding, for example, more than n different vault.

 function _handleRemoveVault(address vault_, address[] storage vaultArray_) internal {
        uint256 length = vaultArray_.length;
        for (uint256 i = 0; i < length;) {
            if (vaultArray_[i] == vault_) {
                if (i < length - 1) {
                    vaultArray_[i] = vaultArray_[length - 1];
                }
                vaultArray_.pop();
                break;
            }
            unchecked {
                i++;
            }
        }
    }
Proof of Concept

The following Foundry test was used in order to prove the aforementioned issue:

function test_removeVaultAndRedeployDOS() public {
        
        for (uint256 index = 0; index < 10000; index++) { //_deployVault(false)
            ImplementationData memory data =
            ImplementationData({implementationAddress: implementation, initDataRequired: true});

        
            vm.prank(admin);
            vaultManager.registerNewImplementation(keccak256(abi.encode("Test", index)), data);
        
            (bytes memory initData, Strategy[] memory strategies) = _getInitData();
            vm.prank(admin);
            vaultAddress.push(vaultManager.deployNewVault(keccak256(abi.encode("Test", index)), initData));
        }
        /*for (uint256 index = 0; index < 10; index++) {
            address[] memory vaults = vaultRegistry.getAllVaults();
            console.log("VAULT: ",vaults[index]);
        }*/
        //(address vault,) = _deployVault(false);

        vm.prank(admin);
        vaultManager.removeVault(vaultAddress[9999], keccak256(abi.encode("Test", 9999)));

        /*for (uint256 index = 0; index < 9; index++) {
            address[] memory vaults = vaultRegistry.getAllVaults();
            console.log("VAULT: ",vaults[index]);
        }*/

        address[] memory vaults = vaultRegistry.getAllVaults();
        console.log("LEN OLD VAULT: ",vaults.length);
        //assertEq(vaults.length, 0, "Length");
        vm.warp(block.timestamp + 1);
        _deployVault(true);
        address[] memory newVaults = vaultRegistry.getAllVaults();
        console.log("LEN NEW VAULT: ",newVaults.length);
        //assertEq(newVaults.length, 1, "Length");
        
    }

Evidence, test failed consumed a lot of gas:

BVSS
Recommendation

Consider ensuring a fixed created vaults in order to avoid potential denial of service on _handleRemoveVault function.

Remediation Plan

SOLVED : The Concrete team solved the issue.


7.3 Potential Denial of Service on prepare withdrawal function

//

Low

Description

There's a logic issue on the WithdrawaQueue contract. The problem arises when the vault wanted to finalize a request withdrawal ID before preparing a withdrawal with a request ID-1.

/// @dev preapares a request to be transferred
    ///  Emits WithdrawalClaimed event
    //TODO test this function
    //-next-line naming-convention
    function prepareWithdrawal(uint256 _requestId, uint256 _avaliableAssets)
        external
        onlyOwner
        returns (address recipient, uint256 amount, uint256 avaliableAssets)
    {
        console.log("LAST FINALIZED REQUEST ID: ",lastFinalizedRequestId);
        console.log("_requestId: ",_requestId);
        if (_requestId == 0) revert InvalidRequestId(_requestId);
        if (_requestId < lastFinalizedRequestId) revert RequestNotFoundOrNotFinalized(_requestId);

        WithdrawalRequest storage request = _requests[_requestId];

        if (request.claimed) revert RequestAlreadyClaimed(_requestId);

        recipient = request.recipient;

        WithdrawalRequest storage prevRequest = _requests[_requestId - 1];

        amount = request.cumulativeAmount - prevRequest.cumulativeAmount;
        console.log("amount: prepare withdrawal: ",amount);
        console.log("_avaliableAssets: ",_avaliableAssets);

        if (_avaliableAssets > amount) {
            assert(_requestsByOwner[recipient].remove(_requestId));
            avaliableAssets = _avaliableAssets - amount;
            request.claimed = true;
            //This is commented to fit the requirements of the vault
            //instead of this we will call _withdrawStrategyFunds
            //IERC20(TOKEN).safeTransfer(recipient, realAmount);

            emit WithdrawalClaimed(_requestId, recipient, amount);
        }
    }

Since the owner is the vault and the only way the vault can call  _finalize is through  batchClaimWithdrawal, the issue has been downgraded from high to low due to the protocol design. However, be sure before production or future releases that the _finalize external function is not called by the vault for security reasons.

The batchClaimWithdrawal function, is calling first the prepareWithdrawal (claimWithdrawal private function) instead of _finalize.

/**
     * @notice Claims multiple withdrawal requests starting from the lasFinalizedRequestId.
     * @dev This function allows the contract owner to claim multiple withdrawal requests in batches.
     * @param maxRequests The maximum number of withdrawal requests to be processed in this batch.
     */
    function batchClaimWithdrawal(uint256 maxRequests) external onlyOwner nonReentrant {
        if (address(withdrawalQueue) == address(0)) revert QueueNotSet();
        uint256 availableAssets = getAvailableAssetsForWithdrawal();

        uint256 lastFinalizedId = withdrawalQueue.getLastFinalizedRequestId();
        uint256 lastCreatedId = withdrawalQueue.getLastRequestId();
        uint256 newLastFinalized = lastFinalizedId; 

        uint256 max = lastCreatedId < lastFinalizedId + maxRequests ? lastCreatedId : lastFinalizedId + maxRequests;
        console.log("MAX: ",max);

        for (uint256 i = lastFinalizedId + 1; i <= max;) {
            uint256 newAvailiableAssets = claimWithdrawal(i, availableAssets);
            // -next-line incorrect-equality
            if (newAvailiableAssets == availableAssets) break;

            availableAssets = newAvailiableAssets;
            newLastFinalized = i;
            unchecked {
                i++;
            }
        }

        if (newLastFinalized != lastFinalizedId) {
            withdrawalQueue._finalize(newLastFinalized);
        }
    }
Proof of Concept

The following Foundry test was used in order to prove the aforementioned issue:

function testFinalizeIssue() public {
        vm.startPrank(owner);
        withdrawalQueue.requestWithdrawal(recipient, 100); //1st request 
        withdrawalQueue.requestWithdrawal(recipient, 200); //2nd request

        withdrawalQueue._finalize(2); //finishing the 2nd request
        //withdrawalQueue._finalize(1);
        withdrawalQueue.prepareWithdrawal(1, 150); //revert RequestNotFoundOrNotFinalized


        uint256 unfinalizedAmount = withdrawalQueue.unfinalizedAmount();
        assertEq(unfinalizedAmount, 200);
        vm.stopPrank();
    }

PoC steps:

  1. Two withdrawal requests (request ID 1 and 2.)

  2. Finishing the request ID 2.

  3. Preparing the withdrawal request ID 1 (n-1). // DoS. It won't be possible to prepare this withdrawal due to a revert error.

  4. Even if the vault prepared the request ID 2, after this, it won't be possible to finish the request ID 1 since it InvalidRequestId revert on _finalize function.

Impact: The request ID 1 it won't be possible prepare the withdrawal and claimed it.

Evidence:

BVSS
Recommendation

Consider ensuring as internal the _finalize function.

Remediation Plan

RISK ACCEPTED: The Concrete team accepted the risk of this finding. They will not make the function internal. The vault doesn't have any way to directly call that function. The only way to do it is through impersonation using Foundry or Hardhat.

7.4 Single step ownership transfer process

//

Low

Description

It was identified that the WithdrawalQueue contract inherited from OpenZeppelin's Ownable library. Ownership of the contracts that are inherited from the Ownable 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. Ownable2Step is safer than Ownable 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.

function transferOwnership(address newOwner) public virtual onlyOwner {
    require(newOwner != address(0), "Ownable: new owner is the zero address");
    _transferOwnership(newOwner);
}

function _transferOwnership(address newOwner) internal virtual {
    address oldOwner = _owner;
    _owner = newOwner;
    emit OwnershipTransferred(oldOwner, newOwner);
}
Proof of Concept

The following Foundry test was used in order to prove the aforementioned issue:

function test_transferOwnership()
        public
    {
      
        (ConcreteMultiStrategyVault newVault,) = _createNewVault(true, false, true);

        WithdrawalQueue queue = new WithdrawalQueue(address(newVault));
        vm.prank(address(newVault));
        queue.transferOwnership(hazel);
       
    }

It was possible to transfer the ownership. Evidence (test passed):

BVSS
Recommendation

Consider using the Ownable2Step https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/access/Ownable2Step.sol library over the Ownable library.

Remediation Plan

RISK ACCEPTED: The Concrete team accepted the risk of this finding.

7.5 Duplicated Imports

//

Informational

Description

The SafeERC20 and Math libraries are imported twice on the StrategyBase contract. This should be cleaned up to avoid confusion.

import {
    ERC4626Upgradeable,
    ERC20Upgradeable as ERC20,
    IERC20,
    IERC20Metadata,
    IERC4626
} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol";
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Context} from "@openzeppelin/contracts/utils/Context.sol";
import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {Errors} from "../interfaces/Errors.sol";
import {ReturnedRewards} from "../interfaces/IStrategy.sol";
import {
    VaultFees,
    VaultInitParams,
    Strategy,
    IConcreteMultiStrategyVault
} from "../interfaces/IConcreteMultiStrategyVault.sol";

BVSS
Recommendation

Consider removing the duplicated imports.

Remediation Plan

SOLVED: The Concrete team solved the issue by applying recommendations.

7.6 Events for Function Calls

//

Informational

Description

Consider to add events for critical state-changing functions to improve traceability and debugging.

/**
     * @notice Sets the recipient address for protocol fees.
     * @dev Can only be called by the contract owner.
     * @param feeRecipient_ The address to which protocol fees will be sent.
     */
    function setFeeRecipient(address feeRecipient_) external onlyOwner {
        if (feeRecipient_ == address(0)) revert InvalidFeeRecipient();
        feeRecipient = feeRecipient_;
    }

    /**
     * @notice Sets the maximum limit for deposits into the strategy.
     * @dev Can only be called by the contract owner.
     * @param depositLimit_ The maximum amount that can be deposited.
     */
    //TODO: Add events for these functions
    //-next-line events-maths
    function setDepositLimit(uint256 depositLimit_) external onlyOwner {
        if (depositLimit_ == 0) revert InvalidDepositLimit();
        depositLimit = depositLimit_;
    }
BVSS
Recommendation

Consider adding events:

event FeeRecipientUpdated(address indexed newFeeRecipient);
event DepositLimitUpdated(uint256 newDepositLimit);

function setFeeRecipient(address feeRecipient_) external onlyOwner {
    if (feeRecipient_ == address(0)) revert InvalidFeeRecipient();
    feeRecipient = feeRecipient_;
    emit FeeRecipientUpdated(feeRecipient_);
}

function setDepositLimit(uint256 depositLimit_) external onlyOwner {
    if (depositLimit_ == 0) revert InvalidDepositLimit();
    depositLimit = depositLimit_;
    emit DepositLimitUpdated(depositLimit_);
}

Remediation Plan

ACKNOWLEDGED: The Concrete team acknowledged this finding.

7.7 Centralization risk: Lack of Access Control in requestFunds

//

Informational

Description

The requestFunds function is marked with onlyProtect, but there's no mechanism to change or update the protectStrategy address once it is set. This could be a problem if the protectStrategy needs to be updated or changed due to unforeseen issues or upgrades.

function requestFunds(uint256 amount) external onlyProtect {
        //tries toi send from balance
        uint256 availableAssets = IERC20(asset()).balanceOf(address(this));
        uint256 acumulated = availableAssets;
        if (availableAssets < amount) {
            acumulated = availableAssets;
            uint256 len = strategies.length;
            for (uint256 i; i < len; i++) {
                IStrategy currentStrategy = strategies[i].strategy;

                if (address(strategies[i].strategy) == protectStrategy) {
                    continue;
                }

                uint256 pending = amount - acumulated;

                //calculate the max we can get from the strategy
                uint256 avaliableInStrat = currentStrategy.getAvailableAssetsForWithdrawal();

                if (avaliableInStrat == 0) {
                    continue;
                }

                uint256 toWithdraw = pending;
                if (avaliableInStrat < pending) {
                    toWithdraw = avaliableInStrat;
                }

                acumulated += toWithdraw;

                //We control both the length of the array and the external call
                //-next-line unused-return,calls-loop
                currentStrategy.withdraw(toWithdraw, address(this), address(this));
                if (acumulated >= amount) {
                    break;
                }
            }
        }

        //after requesting funds deposits them into the protect strategy
        if (acumulated < amount) {
            revert InsufficientFunds(IStrategy(address(this)), amount, acumulated);
        }
        //-next-line unused-return
        IStrategy(protectStrategy).deposit(amount, address(this));
        emit RequestedFunds(protectStrategy, amount);
    }
BVSS
Recommendation

Consider addressing the limitation of not being able to update the protectStrategy address, it is recommended to introduce a function that allows for the modification of the protectStrategy address. This function should be restricted to a privileged role, such as the contract owner or an admin, to ensure security and prevent unauthorized changes.

Remediation Plan

ACKNOWLEDGED: The Concrete team acknowledged this finding.

8. Automated Testing

Halborn used automated testing techniques to enhance the coverage of certain areas of the scoped contracts. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified all the contracts in the repository and was able to compile them correctly into their ABI and binary formats, Slither was run on the all-scoped 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. Some High and Medium issues have been found using automated testing that may be it's important to be considered:

Output:

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.

© Halborn 2025. All rights reserved.