Prepared by:
HALBORN
Last Updated 05/27/2024
Date of Engagement by: April 1st, 2024 - April 8th, 2024
100% of all REPORTED Findings have been addressed
All findings
21
Critical
0
High
0
Medium
1
Low
5
Informational
15
Mesh Connect engaged Halborn
to conduct a security assessment on their smart contracts beginning on 2024-04-01 and ending on 2024-04-08. The security assessment was scoped to the smart contracts provided in the https://github.com/FrontFin/smart-contracts GitHub repository. Commit hashes and further details can be found in the Scope section of this report. The contracts in scope allow for users to transfer native assets and ERC20 tokens, and are managed by a defined Operator role.
In a follow-up engagement requested by the Mesh Connect team, Halborn
reviewed the updates introduced to the protocol up to commit 88b3982
. This security assessment began on 2024-05-20 and ended on 2024-05-20.
Halborn was provided 1 week for the first engagement, and 1 day for the second engagement, and assigned 1 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 that were mostly addressed by the Mesh Connect team. The main identified issues were the following:
Missing internal accounting implementation could lead to misappropriation of funds. (RISK ACCEPTED)
Lack of input validation in native assets transfers function might lead to loss of funds. (SOLVED)
Operator could front-run a transfer and modify the NativeTransfer data with unexpected fields. (SOLVED)
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 contracts' solidity code and can quickly identify items that do not follow security best practices. The following phases and associated tools were used throughout the term of the assessment:
Research into architecture and purpose.
Smart contract manual code review and walk-through.
Manual assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic-related vulnerability classes.
Manual testing with custom scripts (Foundry
).
Static Analysis of security for scoped contracts, and imported functions.
External libraries and financial-related attacks.
New features/implementations after/within the remediation commit IDs.
EXPLOITABILIY 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
5
Informational
15
Security analysis | Risk level | Remediation Date |
---|---|---|
Missing internal accounting implementation could lead to misappropriation of funds | Medium | Risk Accepted |
High privileged role from Operators and Admin could put ERC20 tokens at risk | Low | Not Applicable |
Lack of input validation in native assets transfers function might lead to loss of funds | Low | Solved - 04/30/2024 |
Lack of input validation for amount and fee values | Low | Solved - 04/30/2024 |
Operator could front-run a transfer and modify the NativeTransfer data with unexpected fields | Low | Solved - 04/30/2024 |
Non-compliant tokens might be permanently locked in contracts | Low | Solved - 04/30/2024 |
Id fields for transfers are not unique | Informational | Acknowledged |
Check-effects-interaction pattern is not followed | Informational | Solved - 05/06/2024 |
Transfer identifier could be stored with a cheaper value type | Informational | Solved - 05/06/2024 |
Redundant code | Informational | Solved - 05/23/2024 |
Unoptimized for loop declaration | Informational | Solved - 05/06/2024 |
Public functions not called within the contract can be made external to save gas | Informational | Solved - 05/06/2024 |
Unlocked pragma compilers | Informational | Solved - 05/06/2024 |
Use of custom errors instead of revert strings may help reduce gas usage | Informational | Solved - 05/06/2024 |
Events are missing the indexed attribute | Informational | Acknowledged |
Unused struct fields when calling executeNativeTransfer function | Informational | Solved - 05/06/2024 |
Explicit imports | Informational | Solved - 05/06/2024 |
Transfer with hardcoded gas amount | Informational | Solved - 05/06/2024 |
PUSH0 is not supported by all chains | Informational | Acknowledged |
Lack of input validation for self-transfers | Informational | Acknowledged |
Variable declaration consistency | Informational | Solved - 05/23/2024 |
// Medium
The documentation provided for the audit states the following:
Due to the sensitive native of transferring Funds, we want to make sure we track the current state of any Deposit. And in the case of an unexpected failure, we want to ensure that no Funds are left stuck in transit; instead they should be returned to the original sender.
Despite this affirmation, there is no current functionality in the CeFiSmartTransfer
contract that allows for returning funds to the original sender.
Consider the following scenario:
Alice transfers 100 USDT
tokens to the CeFiSmartTransfer
contract.
Operator A calls executeErc20Transfer
attempting to transfer the 100
tokens in the contract to Alice, but it fails or the transaction takes too long.
Bob transfers 500 USDC
tokens to the CeFiSmartTransfer
contract.
Carla transfers 1500 USDC
tokens to the CeFiSmartTransfer
contract.
Operator B calls executeErc20Transfer
and transfers the 300
tokens in the contract to Carla.
The lack of internal accounting allows for the misallocation of funds and does not support the tracking of individual deposits, making it impossible to refund specific deposits automatically in the event of a transfer failure or delay of transactions.
function testMisappropiationOfFunds(string memory _id, address payable _feeReceiver, address payable _receiver) public {
vm.assume(_feeReceiver != address(0) && _feeReceiver != deployer);
vm.assume(_receiver != address(0) && _receiver != deployer);
vm.assume(_receiver != _feeReceiver);
/* ---------------------------------- ALICE --------------------------------- */
// Sends 100 tokens
deal(address(m3shToken), alice, 100);
vm.prank(alice);
m3shToken.transfer(address(ceFiSmartTransfer), 100);
/* ---------------------------------- BOB --------------------------------- */
// Sends 500 tokens
deal(address(m3shToken), bob, 500);
vm.prank(bob);
m3shToken.transfer(address(ceFiSmartTransfer), 500);
/* ---------------------------------- CARLA --------------------------------- */
// Sends 1500 tokens
deal(address(m3shToken), bob, 1500);
vm.prank(bob);
m3shToken.transfer(address(ceFiSmartTransfer), 1500);
assertEq(m3shToken.balanceOf(address(ceFiSmartTransfer)), 2100);
/* -------------------------------- OPERATOR -------------------------------- */
vm.startPrank(operator2);
CeFi.Erc20Transfer memory _erc20Transfer = CeFi.Erc20Transfer({
id: _id,
amount: 2100,
fee: 0,
feeReceiver: payable(_feeReceiver),
receiver: payable(_receiver),
token: address(m3shToken)
});
ceFiSmartTransfer.executeErc20Transfer(_erc20Transfer);
assertEq(m3shToken.balanceOf(address(ceFiSmartTransfer)), 0);
assertEq(m3shToken.balanceOf(address(_receiver)), 2100);
vm.stopPrank();
}
Consider implementing an internal accounting system for ERC20 tokens to be able to manage deposits locally and be able to return the corresponding amount to its original sender in case of delays or failures.
RISK ACCEPTED: The Mesh Connect team accepted the risk of this finding, with a thorough explanation of the approach and the rationale behind their decision not to modify the contract:
1. Off-Chain Monitoring and Handling Mechanisms:
We employ a comprehensive off-chain monitoring system that tracks the state of every deposit and transaction related to the CeFiSmartTransfer
contract. This system is designed to detect failures or irregularities in real-time.
In cases of transaction failures, our off-chain mechanism is configured to initiate corrective actions, which include orchestrating the return of funds to the original sender. This approach allows for greater flexibility and responsiveness compared to handling such events solely through smart contract logic.
2. Limitations of On-Chain Error Handling for ERC20 Transfers:
ERC20 token transfers do not inherently trigger fallback functions in smart contracts, which limits the ability to automatically handle errors and revert transactions within the contract code itself.
Given this technical constraint, our solution leverages off-chain processes to manage failures effectively, ensuring that tokens are not left in a limbo state and are returned to the sender promptly.
Constraints with Exchange Account Transfers: In our specific operational setup, the entity initiating the transfers (the payer) is often an exchange account. This type of account typically executes direct token transfers, so we cannot utilize methods like transferFrom
, which would require prior approval and subsequent action from our smart contract. This constraint further complicates the implementation of on-chain error handling, as we do not have control over the initial token transfer approval process, limiting our ability to programmatically intervene or revert transactions.
3. Security and Efficiency Considerations:
Managing error handling and fund returns off-chain allows us to minimize on-chain transaction costs and avoid complicating the smart contract with additional logic that could introduce new risks or vulnerabilities.
This approach also enhances security by reducing the attack surface within the contract and relying on controlled, auditable off-chain processes to manage critical failure scenarios.
// Low
The DEFAULT_ADMIN_ROLE role is the only one allowed to add and remove operators for the system. The operator
role-plays an integral part in managing ERC20 assets in CeFiSmartTransfer
and DeFiSmartTransfer
. The executeErc20Transfer
function on both contracts allows operators to transfer ERC20 tokens from the contract to any address. This function is restricted to operators only, ensuring that only authorized addresses with this specified role entities can execute ERC20 transfers.
However, this design introduces a substantial risk regarding the safety and integrity of ERC20 tokens held by the CeFiSmartTransfer
contract and approved to the DeFiSmartTransfer
contract. Since operators have the exclusive authority to initiate transfers, the system's security heavily relies on the trustworthiness and security of these operator accounts. If an operator's account is compromised or if an operator acts maliciously, they could potentially redirect ERC20 tokens to unauthorized addresses, leading to a loss of assets.
Additionally, executeErc20Transfer
functions on both contracts do not implement checks on the destination addresses or limits on the amount of tokens that can be transferred, providing operators with unrestricted access to move tokens.
Several remedial strategies can be employed, including but not limited to:
Implement multi-signature control: Requiring multiple operators to approve a transfer before it can be executed adds a layer of security by distributing trust. This approach makes it much harder for a single compromised or malicious actor to execute unauthorized transfers.
Whitelist/blacklist mechanisms: Implement mechanisms to restrict token transfers to known, safe addresses, or to prevent transfers to addresses identified as risky.
Transfer limits: Introduce daily or transactional limits on the amount of ERC20 tokens that can be transferred. These limits could be configurable and subject to multi-signature approval for adjustments.
Introduce a time-lock mechanism: Implement a time-lock feature that delays the execution of token transfer requests by a certain period (e.g., 24-48 hours). This delay may allow operators and administrators of the contracts to review and potentially handle suspicious transfer requests before they are executed.
NOT APPLICABLE: According to the Mesh Connect team, this finding was addressed in their off-chain logic:
As recommended in the audit, we have implemented a time-lock mechanism to mitigate the risks associated with immediate token transfer executions by operators too. This time-lock feature in our off-chain logic, delays the execution of token transfer requests by a certain period. [...]. This changes will apply in off-chain logic.
// Low
The prepareNativeTransfer()
function from the CeFiSmartTransfer
contract allows for operators to set the NativeTransfer
struct, which contains all the crucial information to the native asset transfer that will be executed when the contract receives assets via the receive()
method. Nevertheless, this function lacks input validation for the input parameters.
Similarly, the executeNativeTransfer()
function from the DeFiSmartTransfer
contract allows any address to transfer native tokens to a receiver
and a feeReceiver
but lacks input validation for these parameters.
Such oversight on both functions can result in a potential loss of funds if either the receiver
or feeReceiver
are inadvertently set to address(0)
.
function testReceiveNativeTransferWhenFeeReceiverIsZeroAddress(
address ethSender,
string memory _id,
uint256 _amount,
uint256 _fee,
address payable _receiver
) public {
/* ------------------------------- preparation ------------------------------ */
_notFoundryReservedAddress(_receiver);
vm.assume(_receiver != address(0));
vm.assume(_receiver != address(ceFiSmartTransfer));
vm.assume(_amount != 0);
_fee = bound(_fee, 0, _amount);
/* -------------------------------- execution ------------------------------- */
testPrepareNativeTransfer(_id, _amount, _fee, payable(address(0)), _receiver);
hoax(ethSender, _amount);
(bool ok, ) = (address(ceFiSmartTransfer)).call{value: _amount}("");
assert(ok);
assertEq((address(0)).balance, _fee);
}
function testReceiveNativeTransferWhenReceiverIsZeroAddress(
address ethSender,
string memory _id,
uint256 _amount,
uint256 _fee,
address payable _feeReceiver
) public {
/* ------------------------------- preparation ------------------------------ */
_notFoundryReservedAddress(_feeReceiver);
vm.assume(_feeReceiver != address(0));
vm.assume(_feeReceiver != address(ceFiSmartTransfer));
vm.assume(_amount != 0);
_fee = bound(_fee, 0, _amount);
/* -------------------------------- execution ------------------------------- */
testPrepareNativeTransfer(_id, _amount, _fee, _feeReceiver, payable(address(0)));
hoax(ethSender, _amount);
(bool ok, ) = (address(ceFiSmartTransfer)).call{value: _amount}("");
assert(ok);
assertEq((address(0)).balance, _amount - _fee);
}
function testExecuteNativeTransferToReceiverZeroAddress(
address ethSender,
string memory _id,
uint256 _amount,
uint256 _fee,
address payable _feeReceiver
) public {
/* ------------------------------- preparation ------------------------------ */
vm.assume(_feeReceiver != address(deFiSmartTransfer));
vm.assume(_feeReceiver != address(ethSender));
vm.assume(_feeReceiver != address(0));
vm.assume(ethSender != address(0));
vm.assume(_amount != 0);
_fee = bound(_fee, 0, _amount);
_notFoundryReservedAddress(_feeReceiver);
uint256 balanceReceiverBefore = address(0).balance;
uint256 balanceFeeReceiverBefore = address(_feeReceiver).balance;
uint256 amountForReceiver = _amount - _fee;
deFi.Transfer memory _transfer = deFi.Transfer({
id: _id,
amount: _amount,
fee: _fee,
feeReceiver: _feeReceiver,
receiver: payable(address(0)),
payer: ethSender,
token: address(0)
});
/* -------------------------------- execution ------------------------------- */
hoax(ethSender, _amount);
assertEq(ethSender.balance, _amount);
deFiSmartTransfer.executeNativeTransfer{value: _amount}(_transfer);
assertEq(ethSender.balance, 0);
/* ------------------------------ verification ------------------------------ */
uint256 balanceReceiverAfter = address(0).balance;
uint256 balanceFeeReceiverAfter = address(_feeReceiver).balance;
assertEq(balanceReceiverAfter, balanceReceiverBefore + amountForReceiver);
assertEq(balanceFeeReceiverAfter, balanceFeeReceiverBefore + _fee);
}
function testExecuteNativeTransferToFeeReceiverZeroAddress(
address ethSender,
string memory _id,
uint256 _amount,
uint256 _fee,
address payable _receiver
) public {
/* ------------------------------- preparation ------------------------------ */
vm.assume(_receiver != address(deFiSmartTransfer));
vm.assume(_receiver != address(ethSender));
vm.assume(_receiver != address(0));
vm.assume(ethSender != address(0));
vm.assume(_amount != 0);
_fee = bound(_fee, 0, _amount);
_notFoundryReservedAddress(_receiver);
uint256 balanceReceiverBefore = address(_receiver).balance;
uint256 balanceFeeReceiverBefore = address(0).balance;
uint256 amountForReceiver = _amount - _fee;
deFi.Transfer memory _transfer = deFi.Transfer({
id: _id,
amount: _amount,
fee: _fee,
feeReceiver: payable(address(0)),
receiver: _receiver,
payer: ethSender,
token: address(0)
});
/* -------------------------------- execution ------------------------------- */
hoax(ethSender, _amount);
assertEq(ethSender.balance, _amount);
deFiSmartTransfer.executeNativeTransfer{value: _amount}(_transfer);
assertEq(ethSender.balance, 0);
/* ------------------------------ verification ------------------------------ */
uint256 balanceReceiverAfter = address(_receiver).balance;
uint256 balanceFeeReceiverAfter = address(0).balance;
assertEq(balanceReceiverAfter, balanceReceiverBefore + amountForReceiver);
assertEq(balanceFeeReceiverAfter, balanceFeeReceiverBefore + _fee);
}
Add a check to ensure that the receiver
and the feeReceiver
are not address(0)
.
SOLVED: The Mesh Connect team solved this finding in commits 76c823f
and cea7f6f
by following the mentioned recommendation and adding a check to verify that the receiver
and the feeReceiver
are not address(0)
.
// Low
Both CeFiSmartTransfer
and DeFiSmartTransfer
smart contracts allow users to transfer a specific amount
of assets to a receiver
and a fee
amount to a feeReceiver
.
Nevertheless, these contracts lack input validation for the amount
and fee
parameters. Such oversight can result in the submission of transactions with invalid amounts, such as an amount set to 0
, or a fee
being greater than the amount
to transfer, which would revert the transactions.
The affected functions are:
CeFiSmartTransfer: prepareNativeTransfer()
, executeErc20Transfer()
and receive()
.
DeFiSmartTransfer: executeErc20Transfer()
and executeNativeTransfer()
.
Additionally, there is no threshold for minimum and/or maximum amounts, which can allow malicious actors to congest the network with 0
amounts of dust transactions, particularly via DeFiSmartTransfer::executeErc20Transfer(),
given that any user could initiate any number of transactions with msg.value = 0
.
function testReceiveNativeTransferFailsWithFeeIsHigherThanAmount(
address ethSender,
string memory _id,
uint256 _amount,
uint256 _fee,
address payable _feeReceiver,
address payable _receiver
) public {
vm.assume(_feeReceiver != address(ceFiSmartTransfer));
vm.assume(_receiver != address(ceFiSmartTransfer));
vm.assume(_amount != 0);
vm.assume(_amount != type(uint256).max);
_fee = bound(_fee, _amount + 1, type(uint256).max);
_notFoundryReservedAddress(_receiver);
_notFoundryReservedAddress(_feeReceiver);
assert(_fee > _amount);
testPrepareNativeTransfer(_id, _amount, _fee, _feeReceiver, _receiver);
hoax(ethSender, _fee);
vm.expectRevert();
(bool ok, ) = address(ceFiSmartTransfer).call{value: _amount}("");
ok;
}
function testPrepareNativeTransfer(
string memory _id,
uint256 _amount,
uint256 _fee,
address payable _feeReceiver,
address payable _receiver
) public {
vm.startPrank(operator1);
assertEq(uint8(ceFiSmartTransfer.nativeTransferLock()), uint8(CeFi.Lock.Unlocked));
CeFi.NativeTransfer memory _nativeTransfer = CeFi.NativeTransfer({
id: _id,
amount: _amount,
fee: _fee,
feeReceiver: payable(_feeReceiver),
receiver: payable(_receiver)
});
ceFiSmartTransfer.prepareNativeTransfer(_nativeTransfer);
(string memory id_, uint amount_, uint fee_, address feeReceiver_, address receiver_) = ceFiSmartTransfer.nativeTransfer();
assertEq(_id, id_);
assertEq(_amount, amount_);
assertEq(_fee, fee_);
assertEq(_feeReceiver, feeReceiver_);
assertEq(_receiver, receiver_);
assertEq(uint8(ceFiSmartTransfer.nativeTransferLock()), uint8(CeFi.Lock.Locked));
vm.stopPrank();
}
Add checks to ensure that the fee
is lower than the amount to transfer
, and that the amount
to transfer is greater than 0
.
Also, consider disallowing transactions below a certain threshold to maintain efficiency and prevent denial of service through dust spamming.
SOLVED: The Mesh Connect team solved this finding in commits a790ac9
and cea7f6f
by following the mentioned recommendations.
// Low
The receive()
function within the CeFiSmartTransfer
contract is designed to automatically forward all received native tokens to addresses specified in the nativeTransfer
struct. This process is initiated when an address sends native assets to the contract, thereby triggering the receive()
function. Crucially, this function relies on the prior execution of prepareNativeTransfer()
by an operator to set up the transfer parameters.
The contract's current design exposes it to a risk of front-running. An operator could strategically (by monitoring pending transactions in the mempool) or accidentally execute prepareNativeTransfer()
before the receive
function has been triggered. This would allow the operator to modify the nativeTransfer
data, potentially redirecting funds to unauthorized addresses and resulting in a direct financial loss for the legitimate transaction sender. Consider the following scenario:
Alice sends ETH to the contract to execute a transfer based on current nativeTransfer
values.
Operator A calls prepareNativeTransfer()
with a higher gas fee to execute the transaction prior to Alice's, modifying the of the receiver
and feeReceiver
to its address.
All ETH sent by Alice goes to the malicious operator address.
function testFrontrunReceiveNativeTransfer(
string memory _id,
uint256 _amount,
uint256 _fee,
address payable _feeReceiver,
address payable _receiver
) public {
/* ------------------------------- preparation ------------------------------ */
vm.assume(_feeReceiver != address(ceFiSmartTransfer));
vm.assume(_receiver != address(ceFiSmartTransfer));
vm.assume(_feeReceiver != address(operator1));
vm.assume(_receiver != address(operator1));
vm.assume(_amount != 0);
_fee = bound(_fee, 0, _amount);
_notFoundryReservedAddress(_receiver);
_notFoundryReservedAddress(_feeReceiver);
uint256 balanceReceiverBefore = address(_receiver).balance;
uint256 balanceFeeReceiverBefore = address(_feeReceiver).balance;
/* -------------------------------- execution ------------------------------- */
testPrepareNativeTransfer(_id, _amount, _fee, _feeReceiver, _receiver); // Original native transfer
_operator1FrontrunAlice(_amount); // Operator1 modifies native transfer
hoax(alice, _amount);
(bool ok, ) = (address(ceFiSmartTransfer)).call{value: _amount}("");
assert(ok);
/* ------------------------------ verification ------------------------------ */
uint256 balanceReceiverAfter = address(_receiver).balance;
uint256 balanceFeeReceiverAfter = address(_feeReceiver).balance;
assertEq(balanceReceiverAfter, balanceReceiverBefore);
assertEq(balanceFeeReceiverAfter, balanceFeeReceiverBefore);
assertEq(address(operator1).balance, _amount);
}
function _operator1FrontrunAlice(uint256 _amount) internal {
testPrepareNativeTransfer("Frontrunning", _amount, 0, payable(operator1), payable(operator1));
}
function testPrepareNativeTransfer(
string memory _id,
uint256 _amount,
uint256 _fee,
address payable _feeReceiver,
address payable _receiver
) public {
vm.startPrank(operator1);
CeFi.NativeTransfer memory _nativeTransfer = CeFi.NativeTransfer({
id: _id,
amount: _amount,
fee: _fee,
feeReceiver: payable(_feeReceiver),
receiver: payable(_receiver)
});
ceFiSmartTransfer.prepareNativeTransfer(_nativeTransfer);
(string memory id_, uint amount_, uint fee_, address feeReceiver_, address receiver_) = ceFiSmartTransfer.nativeTransfer();
assertEq(_id, id_);
assertEq(_amount, amount_);
assertEq(_fee, fee_);
assertEq(_feeReceiver, feeReceiver_);
assertEq(_receiver, receiver_);
assertEq(uint8(ceFiSmartTransfer.nativeTransferLock()), uint8(CeFi.Lock.Locked));
vm.stopPrank();
}
A possible recommendation to solve this issue would be to introduce a time lock mechanism that enforces a minimum delay between the execution of prepareNativeTransfer()
and its subsequent transactions. This delay would provide a window for any irregularities to be identified and addressed before the transfer is executed.
SOLVED: The Mesh Connect team solved this finding in commit eda3810
by creating a timelock mechanism that waits for at least 1 block between preparation and execution of native transfers.
// Low
The CeFiSmartTransfer
and the DeFiSmartTransfer
contracts assume that all ERC20 token transfers are compliant with the IERC20 standard, which is not accurate. For example, USDT's transfer
and transferFrom
functions do not return a bool
:
/**
* @dev transfer token for a specified address
* @param _to The address to transfer to.
* @param _value The amount to be transferred.
*/
function transfer(address to, uint value) public onlyPayloadSize(2 * 32) {
uint fee = (_value.mul(basisPointsRate)).div(10000);
if (fee > maximumFee) {
fee = maximumFee;
}
uint sendAmount = _value.sub(fee);
balances[msg.sender] = balances[msg.sender].sub(_value);
balances[_to] = balances[_to].add(sendAmount);
if (fee > 0) {
balances[owner] = balances[owner].add(fee);
Transfer(msg.sender, owner, fee);
}
Transfer(msg.sender, _to, sendAmount);
}
/**
* @dev Transfer tokens from one address to another
* @param _from address The address which you want to send tokens from
* @param _to address The address which you want to transfer to
* @param _value uint the amount of tokens to be transferred
*/
function transferFrom(address _from, address _to, uint _value) public onlyPayloadSize(3 * 32) {
var _allowance = allowed[_from][msg.sender];
uint fee = (_value.mul(basisPointsRate)).div(10000);
if (fee > maximumFee) {
fee = maximumFee;
}
if (_allowance < MAX_UINT) {
allowed[_from][msg.sender] = _allowance.sub(_value);
}
uint sendAmount = _value.sub(fee);
balances[_from] = balances[_from].sub(_value);
balances[_to] = balances[_to].add(sendAmount);
if (fee > 0) {
balances[owner] = balances[owner].add(fee);
Transfer(_from, owner, fee);
}
Transfer(_from, _to, sendAmount);
}
This and any other non-compliant tokens may become locked, as any transfer attempt via the executeErc20Transfer()
function will revert the transaction because of the missing return value.
Besides this common example, there are tokens that revert on transfers with 0
amounts, and fee-on-transfer tokens, that causes the received amount to be lesser than the accounted amount. For example, DGX (Digix Gold Token) and CGT (CACHE Gold) tokens apply transfer fees, and the USDT (Tether) token also has a currently disabled fee feature. For more reference, see here.
contract USDTSimplified {
mapping(address => uint) public balances;
constructor(uint256 _amount) {
balances[msg.sender] = _amount;
}
function balanceOf(address _owner) public view returns (uint balance) {
return balances[_owner];
}
function transfer(address _to, uint _value) public {
balances[msg.sender] -= _value;
balances[_to] += _value;
}
}
function testExecuteErc20TransferWithNoBooleanReturnFails(
string memory _id,
uint256 _amount,
uint256 _fee,
address payable _receiver,
address payable _feeReceiver
) external {
vm.assume(_amount != 0);
vm.assume(_receiver != address(0));
vm.assume(_feeReceiver != address(0));
_fee = bound(_fee, 0, _amount);
_notFoundryReservedAddress(_receiver);
_notFoundryReservedAddress(_feeReceiver);
vm.startPrank(deployer);
USDTSimplified usdt = new USDTSimplified(_amount);
assertEq(usdt.balanceOf(deployer), _amount);
usdt.transfer(address(ceFiSmartTransfer), _amount);
assertEq(usdt.balanceOf(deployer), 0);
assertEq(usdt.balanceOf(address(ceFiSmartTransfer)), _amount);
CeFi.Erc20Transfer memory _erc20Transfer = CeFi.Erc20Transfer({
id: _id,
amount: _amount,
fee: _fee,
feeReceiver: payable(alice),
receiver: payable(bob),
token: address(usdt)
});
vm.expectRevert();
ceFiSmartTransfer.executeErc20Transfer(_erc20Transfer);
}
It is recommended to use OpenZeppelin’s SafeERC20
library to handle most edge cases among ERC20 tokens, including the return value check, to avoid silently failing transfers that could result in a partial or total loss of the users' investment.
SOLVED: The Mesh Connect team solved this finding in commit 284e2ab
by using OpenZeppelin’s SafeERC20
library and its proper implementation.
// Informational
Comments in the code inside the CeFiSmartTransfer
and DeFiSmartTransfer
contracts declare that the id
field in the NativeTransfer
, Erc20Transfer
and Transfer
structs represents a unique identifier for the transfer.
string id; // A unique identifier for the transfer.
This is not accurate, as the contents of the nativeTransfer
variable can be replaced anytime by an operator calling the prepareNativeTransfer
function, effectively making it possible to have two different sets of nativeTransfer
data sets with the same id
.
Similarly, the contents of Erc20Transfer
in CeFiSmartTransfer
and Transfer
in DeFiSmartTransfer
are declared via input when calling executeErc20Transfer
, making it possible to re-use a previously used id
.
This could affect the data collected when monitoring the aforementioned contracts, since the id
field is used in the emission of SmartTransferReplaced
, SmartTransferComplete
and SmartTransferReady
events.
Keep a record of declared and/or used IDs and place a check to ensure that an ID cannot be reused in different transfers.
ACKNOWLEDGED: The Mesh Connect team acknowledged this finding, stating: We will apply off-chain considerations to ensure that transfer IDs are unique.
// Informational
The receive()
function within the smart contract allows users to transfer to forward the native asset to the designated receiver
address and handling the transfer of the fee
to the feeReceiver
. Following these operations, the contract sets the nativeTransferLock
state variable to Unlocked
, to allow for a new native transfer to be initiated. However, the nativeTransferLock
value is set to Unlocked
after the transfer has been made, which is a state-changing operation.
This implementation does not follow the recommended Check-Effects-Interactions pattern. According to this pattern, any modifications to the contract's state should precede calls to external contracts or addresses. While the current usage of the native transfer
method mitigates the immediate risk of reentrancy attacks, substituting transfer
with the lower-level call
could introduce such vulnerabilities. Hence, adherence to the Check-Effects-Interactions pattern remains a best practice, ensuring enhanced security and future-proofing the contract against potential reentrancy threats.
Update the nativeTransferLock
to an Unlocked
state before executing any transfers. This ensures compliance with the Check-Effects-Interactions pattern, enhancing contract security.
SOLVED: The Mesh Connect team solved this finding in commit 2e5eef7
by following the recommendation and updating the state prior to executing transfers.
// Informational
In Solidity, using strings is not the most gas efficient way to handle data, since they are stored as a dynamic array.
If the length is 32 bytes or longer, the slot in which they are defined stores the length of the string * 2 + 1
, while their actual data is stored elsewhere (the keccak256 hash of that slot).
However, if a string is less than 32 bytes, the length * 2
is stored at the least significant byte of it’s storage slot and the actual data of the string is stored starting from the most significant byte in the slot in which it is defined.
Consider replacing the id
field of the NativeTransfer
struct to a bytes32
type.
Storing and manipulating data in bytes32
is more gas-efficient than using string, which involves dynamic storage allocation.
Alternatively, the id
field could be stored as a uint96
type value, to be able to pack the value with an address of the same struct (feeReceiver
or receiver
) and reduce the usage of a storage slot.
Nevertheless, limit the size of the string
input to keep it under 32 bytes.
SOLVED: The Mesh Connect team solved this finding in commit 2e5eef7
by following the recommendation and storing the id
field into a bytese32
type.
// Informational
In the constructor of CeFiSmartTransfer.sol
, there is an initialization attempt for the nativeTransfer
variable. This initialization is unneeded and impractical, since this NativeTransfer
values will not and can not be used until the prepareNativeTransfer()
function is called, where the operators will be able to set the NativeTransfer
values.
Additionally, the values of the nativeTransfer
variable initialized in the constructor should not be valid params, having the amount
and fee
be 1 wei and both the feeReceiver
and receiver
being the address(1)
.
Follow-up assessment:
In the follow-up assessment, it was identified that there is a redundant initialization in the constructor of the CeFiSmartTransfer
contract, where the newly declared isLocked
variable is initialized to false
. This is unnecessary, as boolean
type variable are initialized as false
by default in Solidity.
Additionally, in the executeNativeTransfer
function from the DefiSmartTransfer
smart contract, there is a check to ensure the fee is valid:
if ((transfer.fee < 0) || (transfer.fee > transfer.amount)) revert InvalidFeeAmount({fee: transfer.fee, amount: transfer.amount});
Here, the (transfer.fee < 0)
code fragment can be omitted, given that the this condition will never be true
because uint256
type variables can't store negative values.
Remove the aforementioned fragments of code.
ACKNOWLEDGED: The Mesh Connect team acknowledged this finding and stated that: Maintaining nativeTransfer initialization for gas use consistency will reduce gas consumption across different test scenarios.
ACKNOWLEDGED: The Mesh Connect team has solved the finding by following the mentioned recommendations.
// Informational
There is an instance of an unoptimized for
loop that may incur in higher gas costs than necessary.
Optimize the instance of a for
loop, by not initializing i
to its default value of 0
, using the pre-increment operator and unchecked
code blocks for the loop counter.
for (uint256 i; i < loopAmount;) {
// code logic
unchecked { ++i; }
}
SOLVED: The Mesh Connect team solved this finding in commit 2e5eef7
by following the mentioned recommendation.
// Informational
The Operator.sol
contract currently defines the addOperator()
and removeOperator()
functions with public
visibility, even though they are not called from within the smart contract, resulting in higher gas costs than necessary.
Modify the aforementioned functions with the external
visibility modifier.
SOLVED: The Mesh Connect team solved this finding in commit 2e5eef7
by following the mentioned recommendation.
// Informational
Contracts should be deployed with the same compiler version and flags used during development and testing. Locking the pragma helps to ensure that contracts do not accidentally get deployed using another pragma. For example, an outdated pragma version might introduce bugs that affect the contract system negatively.
Lock the pragma version to the same version used during development and testing.
SOLVED: The Mesh Connect team solved this finding in commit 2e5eef7
by following the mentioned recommendation.
// Informational
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.
Consider replacing all revert strings with custom errors. For more reference, see here.
SOLVED: The Mesh Connect team solved this finding in commit 2e5eef7
by following the mentioned recommendation.
// Informational
Indexed event fields make the data more quickly accessible to off-chain tools that parse events, and adds them to a special data structure known as "topics" instead of the data part of the log.
However, indexing more fields increases the gas cost for each event emitted. Therefore, extensive indexing is recommended when the advantages of easier data retrieval outweigh the higher gas expenses, particularly in cases where gas efficiency is less of a concern.
Additionally, it's worth noting that when you attempt to index dynamic data types like string
in Solidity, which is the case for the contracts in scope, they don't get stored in their original form. Instead, it is stored the Keccak-256 hash of these data types, so to search for a specific string in the logs, developers would have to hash their desired string using Keccak-256 and then search for that resultant hash among the indexed parameters.
If the value to emit in an event is fix-sized, it is recommended to add the indexed
keyword when declaring events. For more reference, see the Solidity documentation.
ACKNOWLEDGED: The Mesh Connect team has made a business decision to acknowledge this finding and not alter the contracts.
// Informational
The payer
field in the Transfer
struct is not used when calling the executeNativeTransfer()
function. Additionally the token
field is required to be the address(0)
to ensures that the transfer is for a native token.
Implement a different struct type for native token transfers that does not require a token
field or a payer
field.
SOLVED: The Mesh Connect team solved this finding in commit 2e5eef7
by following the mentioned recommendation.
// Informational
Throughout the codebase, the approach to integrating external code involved importing full files, eg:
import "./Operator.sol";
It's considered best practice to follow named imports instead of importing full files, for example:
import {Operator} from "./Operator.sol";
SOLVED: The Mesh Connect team solved this finding in commit 1685f45
by following the mentioned recommendation.
// Informational
The receive()
function from CeFiSmartTransfer
and the executeNativeTransfer()
function from DeFiSmartTransfer
use the native transfer()
method to send native assets (e.g. ETH) calling from the smart contract.
The transfer()
and send()
functions forward a fixed amount of 2300 gas. Historically, it has often been recommended to use these functions for value transfers to guard against reentrancy attacks. However, the gas cost of EVM instructions may change significantly during hard forks which may break already deployed contract systems that make fixed assumptions about gas costs. For example, EIP 1884 broke several existing smart contracts due to a cost increase of the SLOAD instruction.
Avoid the use of transfer()
and send()
and do not otherwise specify a fixed amount of gas when performing calls. Use .call.value(...)("")
instead. Use the checks-effects-interactions pattern and/or reentrancy locks to prevent reentrancy attacks to the system when executing these calls. For more reference see here.
SOLVED: The Mesh Connect team solved this finding in commit 2e5eef7
by following the mentioned recommendation.
// Informational
The compiler for Solidity 0.8.20 switches the default target EVM version to Shanghai, which means that the generated bytecode will include PUSH0 opcodes. Be sure to select the appropriate EVM version in case you intend to deploy on a chain other than mainnet like L2 chains that may not support PUSH0, otherwise deployment of your contracts will fail.
Make sure to specify the target EVM version when using Solidity 0.8.20, especially if deploying to L2 chains that may not support the PUSH0
opcode. Stay informed about the opcode support of different chains to ensure smooth deployment and compatibility.
ACKNOWLEDGED: The Mesh Connect team acknowledged this finding stating: We will apply off-chain considerations to ensure the target EVM supports PUSH0.
// Informational
The receive()
function of CeFiSmartTransfer
and the executeNativeTransfer()
function of DeFiSmartTransfer
lack checks to verify that the sender of the asset and the receiver
and/or feeReceiver
are not the same.
Add a check to verify that the address of the transaction sender is not the same as receiver
and feeReceiver
.
ACKNOWLEDGED: The Mesh Connect team has made a business decision to acknowledge this finding and not alter the contracts.
// Informational
It has been identified that there is inconsistency in the uint256
type declaration in DeFiSmartTransfer
and CeFiSmartTransfer
contracts. Some of these variables are declared using the uint
alias, while others are declared using uint256
. While this won't affect the functionality of the contracts, it is a good practice to use the same explicit type declaration for all variables.
Change all uint
type declarations to uint256
type declarations.
SOLVED: The Mesh Connect team solved this finding in commit 54d9498
by following the mentioned recommendation.
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 assessed all findings identified by the Slither software, however, findings with related to external dependencies are not included in the below results for the sake of report readability.
The findings obtained as a result of the Slither scan were reviewed, and taken into consideration for the report. Some were not included because they were determined as false positives.
The original repository used the Hardhat environment to develop and test the smart contracts. All tests were executed successfully. Additionally, the project in scope was cloned to a Foundry
environment, to allow for additional testing and fuzz testing that covered ~50,000 runs per test. These additional tests were ran successfully.
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