MorpheusAI

MorpheusAI
Foundry
22,500 USDC
View results
Submission Details
Severity: low
Valid

Quality Assurance Report

[L-01] PUSH0 opcode is not supported on Arbitrum when using solc v0.8.0 or higher

This would cause deployment errors and transaction reverts on the Arbitrum L2.

File: MOR.sol
2: pragma solidity ^0.8.20;

[L-02] Use Ownable2Step in MOR contract

Currently the MOR token contract uses Ownable.sol. This would cause issues when ownership is transferred to another address since an incorrect input would lead to permanent loss of the owner role. Due to this, it is advised to use a two step mechanism to avoid this issue.

File: MOR.sol
6: import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

[L-03] Missing event emission in setter functions

The setter functions below should include event emissions to ensure any offchain tracking mechanism is alerted of critical changes by the owner role.

File: L1Sender.sol
46: function setDistribution(address distribution_) public onlyOwner {
47: distribution = distribution_;
48: }
49:
50: function setRewardTokenConfig(RewardTokenConfig calldata newConfig_) public onlyOwner {
51: rewardTokenConfig = newConfig_;
52: }
53:
54: function setDepositTokenConfig(DepositTokenConfig calldata newConfig_) public onlyOwner {
55: require(newConfig_.receiver != address(0), "L1S: invalid receiver");
56:
57: DepositTokenConfig storage oldConfig = depositTokenConfig;
58:
59: _replaceDepositToken(oldConfig.token, newConfig_.token);
60: _replaceDepositTokenGateway(oldConfig.gateway, newConfig_.gateway, oldConfig.token, newConfig_.token);
61:
62: depositTokenConfig = newConfig_;
63: }

[L-04] Pass adapterParams as a configurable bytes parameter instead of using bytes("")

See 7th point in the LayerZero integration list. Hardcoding adapterParams to bytes("") is not recommended since it limits the protocol from future configurability or added features.

File: L1Sender.sol
144: ILayerZeroEndpoint(config.gateway).send{value: msg.value}(
145: config.receiverChainId, // communicator LayerZero chainId
146: receiverAndSenderAddresses_, // send to this address to the communicator
147: payload_, // bytes payload
148: payable(refundTo_), // refund address
149: address(0x0), // future parameter
150: bytes("") // adapterParams (see "Advanced Features")
151: );
152: }

[L-05] Do not hardcode zroPaymentAddress to address(0)

On Line 149, we can see that the zroPaymentAddress field is hardcoded to address(0). This makes the protocol devoid of using the future ZRO token (confirmed) as a payment option for the cross-chain fees. See 5th point in the LayerZero integration list

File: L1Sender.sol
144: ILayerZeroEndpoint(config.gateway).send{value: msg.value}(
145: config.receiverChainId, // communicator LayerZero chainId
146: receiverAndSenderAddresses_, // send to this address to the communicator
147: payload_, // bytes payload
148: payable(refundTo_), // refund address
149: address(0x0), // future parameter
150: bytes("") // adapterParams (see "Advanced Features")
151: );
152: }

[L-06] Consider using mapping over array to avoid DOS

Whenever a pool is created, it is pushed to the pools array. The issue is that if this pools array grows too large, it could cause DOS or excessive gas consumption for other contract trying to retrieve all the pools. Although this does not occur in the current protocol, it is an issue to know about in case external protocols or additional contracts use the TCM model to expand on some functionality.

File: Distribution.sol
72: /**********************************************************************************************/
73: function createPool(Pool calldata pool_) public onlyOwner {
74: require(pool_.payoutStart > block.timestamp, "DS: invalid payout start value");
75:
76: _validatePool(pool_);
77: pools.push(pool_);
78:
79: emit PoolCreated(pools.length - 1, pool_);
80: }

[L-07] Use CEI pattern in function retryMessage()

Similar to how the retryPayload() function in the Endpoint contract deletes the stored payload before making a call, consider deleting the failedMessage payload stored before making a call to _nonBlockingLzReceive() on Line 64. This would adhere to the best practice to using the CEI pattern as well as prevent any reentrancy risks that could arise (though there aren't any currently).

File: L2MessageReceiver.sol
54: function retryMessage(
55: uint16 senderChainId_,
56: bytes memory senderAndReceiverAddresses_,
57: uint64 nonce_,
58: bytes memory payload_
59: ) external {
60: bytes32 payloadHash_ = failedMessages[senderChainId_][senderAndReceiverAddresses_][nonce_];
61: require(payloadHash_ != bytes32(0), "L2MR: no stored message");
62: require(keccak256(payload_) == payloadHash_, "L2MR: invalid payload");
63:
64: _nonblockingLzReceive(senderChainId_, senderAndReceiverAddresses_, payload_);
65:
66: delete failedMessages[senderChainId_][senderAndReceiverAddresses_][nonce_];
67:
68: emit RetryMessageSuccess(senderChainId_, senderAndReceiverAddresses_, nonce_, payload_);
69: }

[L-08] Return value of approve() not checked

The unwrappedToken (StETH) returns a bool in its approve() function. This value is not checked in the current code. Although it returns true, it is best practice to check these values as true.

File: L1Sender.sol
78: IERC20(unwrappedToken_).approve(newToken_, type(uint256).max);

[L-09] Use outboundTransferCustomRefund() instead of outboundTransfer() as recommended by Arbitrum documentation

On Line 116, we can see that function outboundTransfer() is used to make the cross-chain call from Ethereum L1 to Arbitrum L2. The Arbitrum documentation though recommends using outboundTransferCustomRefund() when bridging from Ethereum to Arbitrum. (See first point at the bottom here).

File: L1Sender.sol
100: function sendDepositToken(
101: uint256 gasLimit_,
102: uint256 maxFeePerGas_,
103: uint256 maxSubmissionCost_
104: ) external payable onlyDistribution returns (bytes memory) {
105: DepositTokenConfig storage config = depositTokenConfig;
106:
107: // Get current stETH balance
108: uint256 amountUnwrappedToken_ = IERC20(unwrappedDepositToken).balanceOf(address(this));
109: // Wrap all stETH to wstETH
110: uint256 amount_ = IWStETH(config.token).wrap(amountUnwrappedToken_);
111:
112: bytes memory data_ = abi.encode(maxSubmissionCost_, "");
113:
114:
115: return
116: IGatewayRouter(config.gateway).outboundTransfer{value: msg.value}(
117: config.token,
118: config.receiver,
119: amount_,
120: gasLimit_,
121: maxFeePerGas_,
122: data_
123: );
124: }

[L-10] Anyone can call claim() function on behalf of any other user

Currently, the claim() function in the Distribution.sol contract takes in user_ as a parameter instead of using msg.sender directly. This allows anyone to claim on behalf of the user_, which might not be what the user wanted. The user_ may have wanted to accumulate more rewards and then finally bridge to mint MOR tokens on Arbitrum. But since an attacker or anyone has already bridged the amount, this would affect the user's strategy. This does not affect the user in any way at first glance since the attacker pays the bridging fees but external applications or strategies would be affected due to this issue.

Solution: Ensure user_ is msg.sender and/or implement an approval mapping which stores approvals the user_ has given to certain external applications/contracts to call the claim() function on behalf.

File: Distribution.sol
155: function claim(uint256 poolId_, address user_) external payable poolExists(poolId_) {
156: Pool storage pool = pools[poolId_];
157: PoolData storage poolData = poolsData[poolId_];
158: UserData storage userData = usersData[user_][poolId_];
159:
160: require(block.timestamp > pool.payoutStart + pool.claimLockPeriod, "DS: pool claim is locked");
161:
162: uint256 currentPoolRate_ = _getCurrentPoolRate(poolId_);
163: uint256 pendingRewards_ = _getCurrentUserReward(currentPoolRate_, userData);
164: require(pendingRewards_ > 0, "DS: nothing to claim");
165:
166: // Update pool data
167: poolData.lastUpdate = uint128(block.timestamp);
168: poolData.rate = currentPoolRate_;
169:
170: // Update user data
171: userData.rate = currentPoolRate_;
172: userData.pendingRewards = 0;
173:
174: // Transfer rewards
175: L1Sender(l1Sender).sendMintMessage{value: msg.value}(user_, pendingRewards_, _msgSender());
176:
177: emit UserClaimed(poolId_, user_, pendingRewards_);
178: }
Updates

Lead Judging Commences

inallhonesty Lead Judge
over 1 year ago
inallhonesty Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

LayerZero Integration: Do not hardcode address zero (address(0)) as zroPaymentAddress

LayerZero Integration: Do not hardcode zero bytes (bytes(0)) as adapterParamers.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.