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,
146: receiverAndSenderAddresses_,
147: payload_,
148: payable(refundTo_),
149: address(0x0),
150: bytes("")
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,
146: receiverAndSenderAddresses_,
147: payload_,
148: payable(refundTo_),
149: address(0x0),
150: bytes("")
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:
108: uint256 amountUnwrappedToken_ = IERC20(unwrappedDepositToken).balanceOf(address(this));
109:
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:
167: poolData.lastUpdate = uint128(block.timestamp);
168: poolData.rate = currentPoolRate_;
169:
170:
171: userData.rate = currentPoolRate_;
172: userData.pendingRewards = 0;
173:
174:
175: L1Sender(l1Sender).sendMintMessage{value: msg.value}(user_, pendingRewards_, _msgSender());
176:
177: emit UserClaimed(poolId_, user_, pendingRewards_);
178: }