feat: implement `OPL2ToL1Ism` contracts (#4319)

### Description

Implementing the `OPL2ToL1Hook` and OPL2ToL1Ism` contracts for using the
Optimism native bridge for sending messages from the L2 to L1.
- Optimism needs double call encoding for the direct portal call because
the portal itself calls the l1Messenger which then directs it to the
ism.

### Drive-by changes

None

### Related issues

- fixes https://github.com/hyperlane-xyz/issues/issues/1346

### Backward compatibility

Yes

### Testing

Unit testing with mocks for OP contracts
pull/4335/head
Kunal Arora 3 months ago committed by GitHub
parent cc8624d687
commit 6f377f1743
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/light-poets-teach.md
  2. 84
      solidity/contracts/hooks/OPL2ToL1Hook.sol
  3. 3
      solidity/contracts/interfaces/IInterchainSecurityModule.sol
  4. 3
      solidity/contracts/interfaces/hooks/IPostDispatchHook.sol
  5. 2
      solidity/contracts/interfaces/optimism/ICrossDomainMessenger.sol
  6. 28
      solidity/contracts/interfaces/optimism/IOptimismPortal.sol
  7. 109
      solidity/contracts/isms/hook/OPL2ToL1Ism.sol
  8. 62
      solidity/contracts/libs/OPL2ToL1Metadata.sol
  9. 61
      solidity/contracts/mock/MockOptimism.sol
  10. 320
      solidity/test/isms/OPL2ToL1Ism.t.sol

@ -0,0 +1,5 @@
---
'@hyperlane-xyz/core': minor
---
Added hook/ism for using the Optimism native bridge for L2->L1 calls

@ -0,0 +1,84 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity >=0.8.0;
/*@@@@@@@ @@@@@@@@@
@@@@@@@@@ @@@@@@@@@
@@@@@@@@@ @@@@@@@@@
@@@@@@@@@ @@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@ HYPERLANE @@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@ @@@@@@@@@
@@@@@@@@@ @@@@@@@@@
@@@@@@@@@ @@@@@@@@@
@@@@@@@@@ @@@@@@@@*/
// ============ Internal Imports ============
import {AbstractPostDispatchHook, AbstractMessageIdAuthHook} from "./libs/AbstractMessageIdAuthHook.sol";
import {StandardHookMetadata} from "./libs/StandardHookMetadata.sol";
import {TypeCasts} from "../libs/TypeCasts.sol";
import {IPostDispatchHook} from "../interfaces/hooks/IPostDispatchHook.sol";
// ============ External Imports ============
import {ICrossDomainMessenger} from "../interfaces/optimism/ICrossDomainMessenger.sol";
/**
* @title OPL2ToL1Hook
* @notice Message hook to inform the OPL2ToL1Ism of messages published through
* the native Optimism bridge.
* @notice This works only for L2 -> L1 messages and has the 7 day delay as specified by the OptimismPortal contract.
*/
contract OPL2ToL1Hook is AbstractMessageIdAuthHook {
using StandardHookMetadata for bytes;
// ============ Constants ============
// precompile contract on L2 for sending messages to L1
ICrossDomainMessenger public immutable l2Messenger;
// Immutable quote amount
uint32 public immutable GAS_QUOTE;
// ============ Constructor ============
constructor(
address _mailbox,
uint32 _destinationDomain,
bytes32 _ism,
address _l2Messenger,
uint32 _gasQuote
) AbstractMessageIdAuthHook(_mailbox, _destinationDomain, _ism) {
GAS_QUOTE = _gasQuote;
l2Messenger = ICrossDomainMessenger(_l2Messenger);
}
/// @inheritdoc IPostDispatchHook
function hookType() external pure override returns (uint8) {
return uint8(IPostDispatchHook.Types.OP_L2_TO_L1);
}
/// @inheritdoc AbstractPostDispatchHook
function _quoteDispatch(
bytes calldata,
bytes calldata
) internal view override returns (uint256) {
return GAS_QUOTE;
}
// ============ Internal functions ============
/// @inheritdoc AbstractMessageIdAuthHook
function _sendMessageId(
bytes calldata metadata,
bytes memory payload
) internal override {
require(
msg.value >= metadata.msgValue(0) + GAS_QUOTE,
"OPL2ToL1Hook: insufficient msg.value"
);
l2Messenger.sendMessage{value: metadata.msgValue(0)}(
TypeCasts.bytes32ToAddress(ism),
payload,
GAS_QUOTE
);
}
}

@ -13,7 +13,8 @@ interface IInterchainSecurityModule {
CCIP_READ,
ARB_L2_TO_L1,
WEIGHT_MERKLE_ROOT_MULTISIG,
WEIGHT_MESSAGE_ID_MULTISIG
WEIGHT_MESSAGE_ID_MULTISIG,
OP_L2_TO_L1
}
/**

@ -26,7 +26,8 @@ interface IPostDispatchHook {
PROTOCOL_FEE,
LAYER_ZERO_V1,
RATE_LIMITED,
ARB_L2_TO_L1
ARB_L2_TO_L1,
OP_L2_TO_L1
}
/**

@ -30,6 +30,8 @@ interface ICrossDomainMessenger {
function xDomainMessageSender() external view returns (address);
function OTHER_MESSENGER() external view returns (address);
function PORTAL() external view returns (address);
}
interface IL1CrossDomainMessenger is ICrossDomainMessenger {}

@ -0,0 +1,28 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// author: OP Labs
// copied from https://github.com/ethereum-optimism/optimism/tree/develop/packages/contracts-bedrock
interface IOptimismPortal {
/// @notice Struct representing a withdrawal transaction.
/// @custom:field nonce Nonce of the withdrawal transaction
/// @custom:field sender Address of the sender of the transaction.
/// @custom:field target Address of the recipient of the transaction.
/// @custom:field value Value to send to the recipient.
/// @custom:field gasLimit Gas limit of the transaction.
/// @custom:field data Data of the transaction.
struct WithdrawalTransaction {
uint256 nonce;
address sender;
address target;
uint256 value;
uint256 gasLimit;
bytes data;
}
/// @notice Finalizes a withdrawal transaction.
/// @param _tx Withdrawal transaction to finalize.
function finalizeWithdrawalTransaction(
WithdrawalTransaction memory _tx
) external;
}

@ -0,0 +1,109 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity >=0.8.0;
/*@@@@@@@ @@@@@@@@@
@@@@@@@@@ @@@@@@@@@
@@@@@@@@@ @@@@@@@@@
@@@@@@@@@ @@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@ HYPERLANE @@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@ @@@@@@@@@
@@@@@@@@@ @@@@@@@@@
@@@@@@@@@ @@@@@@@@@
@@@@@@@@@ @@@@@@@@*/
// ============ Internal Imports ============
import {IInterchainSecurityModule} from "../../interfaces/IInterchainSecurityModule.sol";
import {TypeCasts} from "../../libs/TypeCasts.sol";
import {Message} from "../../libs/Message.sol";
import {OPL2ToL1Metadata} from "../../libs/OPL2ToL1Metadata.sol";
import {AbstractMessageIdAuthorizedIsm} from "./AbstractMessageIdAuthorizedIsm.sol";
// ============ External Imports ============
import {ICrossDomainMessenger} from "../../interfaces/optimism/ICrossDomainMessenger.sol";
import {IOptimismPortal} from "../../interfaces/optimism/IOptimismPortal.sol";
import {CrossChainEnabledOptimism} from "@openzeppelin/contracts/crosschain/optimism/CrossChainEnabledOptimism.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
/**
* @title OPL2ToL1Ism
* @notice Uses the native Optimism bridge to verify interchain messages from L2 to L1.
*/
contract OPL2ToL1Ism is
CrossChainEnabledOptimism,
AbstractMessageIdAuthorizedIsm
{
using TypeCasts for address;
using Message for bytes;
using OPL2ToL1Metadata for bytes;
// ============ Constants ============
// module type for the ISM
uint8 public constant moduleType =
uint8(IInterchainSecurityModule.Types.OP_L2_TO_L1);
// OptimismPortal contract on L1 to finalize withdrawal from L1
IOptimismPortal public immutable portal;
// ============ Constructor ============
constructor(address _messenger) CrossChainEnabledOptimism(_messenger) {
address _portal = ICrossDomainMessenger(_messenger).PORTAL();
require(
Address.isContract(_portal),
"OPL2ToL1Ism: invalid OptimismPortal contract"
);
portal = IOptimismPortal(_portal);
}
// ============ External Functions ============
/// @inheritdoc IInterchainSecurityModule
function verify(
bytes calldata metadata,
bytes calldata message
) external override returns (bool) {
bool verified = isVerified(message);
if (!verified) {
_verifyWithPortalCall(metadata, message);
}
releaseValueToRecipient(message);
return true;
}
// ============ Internal function ============
/**
* @notice Verify message directly using the portal.finalizeWithdrawal function.
* @dev This is a fallback in case the message is not verified by the stateful verify function first.
*/
function _verifyWithPortalCall(
bytes calldata metadata,
bytes calldata message
) internal {
require(
metadata.checkCalldataLength(),
"OPL2ToL1Ism: invalid data length"
);
require(
metadata.messageId() == message.id(),
"OPL2ToL1Ism: invalid message id"
);
IOptimismPortal.WithdrawalTransaction memory withdrawal = abi.decode(
metadata,
(IOptimismPortal.WithdrawalTransaction)
);
// if the finalizeWithdrawalTransaction call is successful, the message is verified
portal.finalizeWithdrawalTransaction(withdrawal);
}
/// @inheritdoc AbstractMessageIdAuthorizedIsm
function _isAuthorized() internal view override returns (bool) {
return
_crossChainSender() == TypeCasts.bytes32ToAddress(authorizedHook);
}
}

@ -0,0 +1,62 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity >=0.8.0;
/**
* @title Hyperlane OPL2ToL1Metadata Library
* @notice Library for formatted metadata used by OPL2ToL1Ism
*/
library OPL2ToL1Metadata {
// bottom offset to the start of message id in the metadata
uint256 private constant MESSAGE_ID_OFFSET = 88;
// from IOptimismPortal.WithdrawalTransaction
// Σ {
// nonce = 32 bytes
// PADDING + sender = 32 bytes
// PADDING + target = 32 bytes
// value = 32 bytes
// gasLimit = 32 bytes
// _data
// OFFSET = 32 bytes
// LENGTH = 32 bytes
// } = 252 bytes
uint256 private constant FIXED_METADATA_LENGTH = 252;
// metadata here is double encoded call relayMessage(..., verifyMessageId)
// Σ {
// _selector = 4 bytes
// _nonce = 32 bytes
// PADDING + _sender = 32 bytes
// PADDING + _target = 32 bytes
// _value = 32 bytes
// _minGasLimit = 32 bytes
// _data
// OFFSET = 32 bytes
// LENGTH = 32 bytes
// PADDING + verifyMessageId = 64 bytes
// } = 292 bytes
uint256 private constant MESSENGER_CALLDATA_LENGTH = 292;
/**
* @notice Returns the message ID.
* @param _metadata OptimismPortal.WithdrawalTransaction encoded calldata
* @return ID of `_metadata`
*/
function messageId(
bytes calldata _metadata
) internal pure returns (bytes32) {
uint256 metadataLength = _metadata.length;
return
bytes32(
_metadata[metadataLength - MESSAGE_ID_OFFSET:metadataLength -
MESSAGE_ID_OFFSET +
32]
);
}
function checkCalldataLength(
bytes calldata _metadata
) internal pure returns (bool) {
return
_metadata.length ==
MESSENGER_CALLDATA_LENGTH + FIXED_METADATA_LENGTH;
}
}

@ -0,0 +1,61 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {CallLib} from "../middleware/libs/Call.sol";
import {TypeCasts} from "../libs/TypeCasts.sol";
import {ICrossDomainMessenger} from "../interfaces/optimism/ICrossDomainMessenger.sol";
import {IOptimismPortal} from "../interfaces/optimism/IOptimismPortal.sol";
// for both L1 and L2
contract MockOptimismMessenger is ICrossDomainMessenger {
address public xDomainMessageSender;
address public PORTAL;
function sendMessage(
address _target,
bytes calldata _message,
uint32 _gasLimit
) external payable {}
function relayMessage(
uint256 /*_nonce*/,
address /*_sender*/,
address _target,
uint256 _value,
uint256 /*_minGasLimit*/,
bytes calldata _message
) external payable {
CallLib.Call memory call = CallLib.Call(
TypeCasts.addressToBytes32(_target),
_value,
_message
);
CallLib.call(call);
}
function OTHER_MESSENGER() external view returns (address) {}
function setXDomainMessageSender(address _sender) external {
xDomainMessageSender = _sender;
}
function setPORTAL(address _portal) external {
PORTAL = _portal;
}
}
// mock deployment on L1
contract MockOptimismPortal is IOptimismPortal {
error WithdrawalTransactionFailed();
function finalizeWithdrawalTransaction(
WithdrawalTransaction memory _tx
) external {
CallLib.Call memory call = CallLib.Call(
TypeCasts.addressToBytes32(_tx.target),
_tx.value,
_tx.data
);
CallLib.call(call);
}
}

@ -0,0 +1,320 @@
// SPDX-License-Identifier: MIT or Apache-2.0
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
import {TypeCasts} from "../../contracts/libs/TypeCasts.sol";
import {Message} from "../../contracts/libs/Message.sol";
import {MessageUtils} from "./IsmTestUtils.sol";
import {StandardHookMetadata} from "../../contracts/hooks/libs/StandardHookMetadata.sol";
import {IOptimismPortal} from "../../contracts/interfaces/optimism/IOptimismPortal.sol";
import {ICrossDomainMessenger} from "../../contracts/interfaces/optimism/ICrossDomainMessenger.sol";
import {AbstractMessageIdAuthorizedIsm} from "../../contracts/isms/hook/AbstractMessageIdAuthorizedIsm.sol";
import {TestMailbox} from "../../contracts/test/TestMailbox.sol";
import {TestRecipient} from "../../contracts/test/TestRecipient.sol";
import {MockOptimismMessenger, MockOptimismPortal} from "../../contracts/mock/MockOptimism.sol";
import {OPL2ToL1Hook} from "../../contracts/hooks/OPL2ToL1Hook.sol";
import {OPL2ToL1Ism} from "../../contracts/isms/hook/OPL2ToL1Ism.sol";
contract OPL2ToL1IsmTest is Test {
uint8 internal constant HYPERLANE_VERSION = 1;
uint32 internal constant MAINNET_DOMAIN = 1;
uint32 internal constant OPTIMISM_DOMAIN = 10;
uint32 internal constant GAS_QUOTE = 120_000;
address internal constant L2_MESSENGER_ADDRESS =
0x4200000000000000000000000000000000000007;
uint256 internal constant MOCK_NONCE = 0;
TestMailbox public l2Mailbox;
TestRecipient internal testRecipient;
bytes internal testMessage =
abi.encodePacked("Hello from the other chain!");
bytes internal encodedMessage;
bytes internal testMetadata =
StandardHookMetadata.overrideRefundAddress(address(this));
bytes32 internal messageId;
MockOptimismPortal internal portal;
MockOptimismMessenger internal l1Messenger;
OPL2ToL1Hook public hook;
OPL2ToL1Ism public ism;
///////////////////////////////////////////////////////////////////
/// SETUP ///
///////////////////////////////////////////////////////////////////
function setUp() public {
// Optimism messenger mock setup
vm.etch(
L2_MESSENGER_ADDRESS,
address(new MockOptimismMessenger()).code
);
testRecipient = new TestRecipient();
encodedMessage = _encodeTestMessage();
messageId = Message.id(encodedMessage);
}
function deployHook() public {
l2Mailbox = new TestMailbox(OPTIMISM_DOMAIN);
hook = new OPL2ToL1Hook(
address(l2Mailbox),
MAINNET_DOMAIN,
TypeCasts.addressToBytes32(address(ism)),
L2_MESSENGER_ADDRESS,
GAS_QUOTE
);
}
function deployIsm() public {
l1Messenger = new MockOptimismMessenger();
portal = new MockOptimismPortal();
l1Messenger.setPORTAL(address(portal));
ism = new OPL2ToL1Ism(address(l1Messenger));
}
function deployAll() public {
deployIsm();
deployHook();
l1Messenger.setXDomainMessageSender(address(hook));
ism.setAuthorizedHook(TypeCasts.addressToBytes32(address(hook)));
}
function test_postDispatch() public {
deployAll();
bytes memory encodedHookData = abi.encodeCall(
AbstractMessageIdAuthorizedIsm.verifyMessageId,
(messageId)
);
l2Mailbox.updateLatestDispatchedId(messageId);
vm.expectCall(
L2_MESSENGER_ADDRESS,
abi.encodeCall(
ICrossDomainMessenger.sendMessage,
(address(ism), encodedHookData, GAS_QUOTE)
)
);
hook.postDispatch{value: GAS_QUOTE}(testMetadata, encodedMessage);
}
function testFork_postDispatch_revertWhen_chainIDNotSupported() public {
deployAll();
bytes memory message = MessageUtils.formatMessage(
0,
uint32(0),
OPTIMISM_DOMAIN,
TypeCasts.addressToBytes32(address(this)),
2, // wrong domain
TypeCasts.addressToBytes32(address(testRecipient)),
testMessage
);
l2Mailbox.updateLatestDispatchedId(Message.id(message));
vm.expectRevert(
"AbstractMessageIdAuthHook: invalid destination domain"
);
hook.postDispatch(testMetadata, message);
}
function test_postDispatch_revertWhen_notLastDispatchedMessage() public {
deployAll();
vm.expectRevert(
"AbstractMessageIdAuthHook: message not latest dispatched"
);
hook.postDispatch(testMetadata, encodedMessage);
}
function test_verify_directWithdrawalCall() public {
deployAll();
bytes memory encodedWithdrawalTx = _encodeFinalizeWithdrawalTx(
address(ism),
0,
messageId
);
assertTrue(ism.verify(encodedWithdrawalTx, encodedMessage));
}
function test_verify_directWithdrawalCall_revertsWhen_invalidSender()
public
{
deployAll();
l1Messenger.setXDomainMessageSender(address(this));
bytes memory encodedWithdrawalTx = _encodeFinalizeWithdrawalTx(
address(ism),
0,
messageId
);
vm.expectRevert(); // evmRevert in MockOptimismPortal
ism.verify(encodedWithdrawalTx, encodedMessage);
}
function test_verify_statefulVerify() public {
deployAll();
vm.deal(address(portal), 1 ether);
IOptimismPortal.WithdrawalTransaction
memory withdrawal = IOptimismPortal.WithdrawalTransaction({
nonce: MOCK_NONCE,
sender: L2_MESSENGER_ADDRESS,
target: address(l1Messenger),
value: 1 ether,
gasLimit: uint256(GAS_QUOTE),
data: _encodeMessengerCalldata(address(ism), 1 ether, messageId)
});
portal.finalizeWithdrawalTransaction(withdrawal);
vm.etch(address(portal), new bytes(0)); // this is a way to test that the portal isn't called again
assertTrue(ism.verify(new bytes(0), encodedMessage));
assertEq(address(testRecipient).balance, 1 ether); // testing msg.value
}
function test_verify_statefulAndDirectWithdrawal() public {
deployAll();
IOptimismPortal.WithdrawalTransaction
memory withdrawal = IOptimismPortal.WithdrawalTransaction({
nonce: MOCK_NONCE,
sender: L2_MESSENGER_ADDRESS,
target: address(l1Messenger),
value: 0,
gasLimit: uint256(GAS_QUOTE),
data: _encodeMessengerCalldata(address(ism), 0, messageId)
});
portal.finalizeWithdrawalTransaction(withdrawal);
bytes memory encodedWithdrawalTx = _encodeFinalizeWithdrawalTx(
address(ism),
0,
messageId
);
vm.etch(address(portal), new bytes(0)); // this is a way to test that the portal isn't called again
assertTrue(ism.verify(encodedWithdrawalTx, encodedMessage));
}
function test_verify_revertsWhen_noStatefulAndDirectWithdrawal() public {
deployAll();
vm.expectRevert();
ism.verify(new bytes(0), encodedMessage);
}
function test_verify_revertsWhen_invalidIsm() public {
deployAll();
bytes memory encodedWithdrawalTx = _encodeFinalizeWithdrawalTx(
address(this),
0,
messageId
);
vm.expectRevert(); // evmRevert in MockOptimismPortal
ism.verify(encodedWithdrawalTx, encodedMessage);
}
function test_verify_revertsWhen_incorrectMessageId() public {
deployAll();
bytes32 incorrectMessageId = keccak256("incorrect message id");
bytes memory encodedWithdrawalTx = _encodeFinalizeWithdrawalTx(
address(this),
0,
incorrectMessageId
);
// through portal call
vm.expectRevert("OPL2ToL1Ism: invalid message id");
ism.verify(encodedWithdrawalTx, encodedMessage);
// through statefulVerify
IOptimismPortal.WithdrawalTransaction
memory withdrawal = IOptimismPortal.WithdrawalTransaction({
nonce: MOCK_NONCE,
sender: L2_MESSENGER_ADDRESS,
target: address(l1Messenger),
value: 0,
gasLimit: uint256(GAS_QUOTE),
data: _encodeMessengerCalldata(
address(ism),
0,
incorrectMessageId
)
});
portal.finalizeWithdrawalTransaction(withdrawal);
vm.etch(address(portal), new bytes(0)); // to stop the portal route
vm.expectRevert(); // evmRevert()
assertFalse(ism.verify(new bytes(0), encodedMessage));
}
/* ============ helper functions ============ */
function _encodeTestMessage() internal view returns (bytes memory) {
return
MessageUtils.formatMessage(
HYPERLANE_VERSION,
uint32(0),
OPTIMISM_DOMAIN,
TypeCasts.addressToBytes32(address(this)),
MAINNET_DOMAIN,
TypeCasts.addressToBytes32(address(testRecipient)),
testMessage
);
}
function _encodeMessengerCalldata(
address _ism,
uint256 _value,
bytes32 _messageId
) internal view returns (bytes memory) {
bytes memory encodedHookData = abi.encodeCall(
AbstractMessageIdAuthorizedIsm.verifyMessageId,
(_messageId)
);
return
abi.encodeCall(
ICrossDomainMessenger.relayMessage,
(
MOCK_NONCE,
address(hook),
_ism,
_value,
uint256(GAS_QUOTE),
encodedHookData
)
);
}
function _encodeFinalizeWithdrawalTx(
address _ism,
uint256 _value,
bytes32 _messageId
) internal view returns (bytes memory) {
return
abi.encode(
MOCK_NONCE,
L2_MESSENGER_ADDRESS,
l1Messenger,
_value,
uint256(GAS_QUOTE),
_encodeMessengerCalldata(_ism, _value, _messageId)
);
}
}
Loading…
Cancel
Save