Interop and unit tests using Hardhat
Most parts of the contracts can be tested normally.
This tutorial teaches you how to verify that a message has been sent successfully and to simulate receiving a message, the two functions that tie directly into interop.
To show how this works, we test the Greeter
and GreetingSender
contracts.
Setup
This script creates a Foundry project (in testing/forge
) and a Hardhat project (in testing/hardhat
) with the necessary files.
Execute it in a UNIX or Linux environment.
The results of this step are similar to what the message passing tutorial produces.
#! /bin/sh
rm -rf testing
mkdir -p testing/forge
cd testing/forge
forge init
find . -name 'Counter*' -exec rm {} \;
cd lib
npm install @eth-optimism/contracts-bedrock
cd ..
echo @eth-optimism/=lib/node_modules/@eth-optimism/ >> remappings.txt
cat > src/Greeter.sol <<EOF
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import { Predeploys } from "@eth-optimism/contracts-bedrock/src/libraries/Predeploys.sol";
interface IL2ToL2CrossDomainMessenger {
function crossDomainMessageContext() external view returns (address sender_, uint256 source_);
}
contract Greeter {
IL2ToL2CrossDomainMessenger public immutable messenger =
IL2ToL2CrossDomainMessenger(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER);
string greeting;
event SetGreeting(
address indexed sender, // msg.sender
string greeting
);
event CrossDomainSetGreeting(
address indexed sender, // Sender on the other side
uint256 indexed chainId, // ChainID of the other side
string greeting
);
function greet() public view returns (string memory) {
return greeting;
}
function setGreeting(string memory _greeting) public {
greeting = _greeting;
emit SetGreeting(msg.sender, _greeting);
if (msg.sender == Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER) {
(address sender, uint256 chainId) =
messenger.crossDomainMessageContext();
emit CrossDomainSetGreeting(sender, chainId, _greeting);
}
}
}
EOF
cat > src/GreetingSender.sol <<EOF
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import { Predeploys } from "@eth-optimism/contracts-bedrock/src/libraries/Predeploys.sol";
import { IL2ToL2CrossDomainMessenger } from "@eth-optimism/contracts-bedrock/src/L2/IL2ToL2CrossDomainMessenger.sol";
import { Greeter } from "src/Greeter.sol";
contract GreetingSender {
IL2ToL2CrossDomainMessenger public immutable messenger =
IL2ToL2CrossDomainMessenger(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER);
address immutable greeterAddress;
uint256 immutable greeterChainId;
constructor(address _greeterAddress, uint256 _greeterChainId) {
greeterAddress = _greeterAddress;
greeterChainId = _greeterChainId;
}
function setGreeting(string calldata greeting) public {
bytes memory message = abi.encodeCall(
Greeter.setGreeting,
(greeting)
);
messenger.sendMessage(greeterChainId, greeterAddress, message);
}
}
EOF
cat > test/GreetingSender.t.sol <<EOF
pragma solidity ^0.8.0;
import { Test } from "forge-std/Test.sol";
import { Predeploys } from "@eth-optimism/contracts-bedrock/src/libraries/Predeploys.sol";
import { IL2ToL2CrossDomainMessenger } from "@eth-optimism/contracts-bedrock/src/L2/IL2ToL2CrossDomainMessenger.sol";
import { GreetingSender } from "src/GreetingSender.sol";
import { Greeter } from "src/Greeter.sol";
contract GreetingSenderTest is Test {
address constant targetGreeter = address(0x0123456789012345678901234567890123456789);
uint256 constant targetChain = 902;
GreetingSender greetingSender;
/// @notice Emitted whenever a message is sent to a destination
/// @param destination Chain ID of the destination chain.
/// @param target Target contract or wallet address.
/// @param messageNonce Nonce associated with the message sent
/// @param sender Address initiating this message call
/// @param message Message payload to call target with.
event SentMessage(
uint256 indexed destination, address indexed target, uint256 indexed messageNonce, address sender, bytes message
);
function setUp() public {
greetingSender = new GreetingSender(targetGreeter, targetChain);
}
function test_SendGreeting() public {
string memory greeting = "Hello";
bytes memory message = abi.encodeCall(
Greeter.setGreeting,
(greeting)
);
// Ignore the nonce
vm.expectEmit(true, true, false, true);
emit SentMessage(targetChain, targetGreeter, 0, address(greetingSender), message);
greetingSender.setGreeting(greeting);
}
}
EOF
cat > test/Greeter.t.sol <<EOF
pragma solidity ^0.8.0;
import { Test } from "forge-std/Test.sol";
import { Predeploys } from "@eth-optimism/contracts-bedrock/src/libraries/Predeploys.sol";
import { IL2ToL2CrossDomainMessenger } from "@eth-optimism/contracts-bedrock/src/L2/IL2ToL2CrossDomainMessenger.sol";
import { Greeter } from "src/Greeter.sol";
contract GreeterTest is Test {
uint256 constant fakeSourceChain = 901;
address constant fakeSender = address(0x0123456789012345678901234567890123456789);
event SetGreeting(
address indexed sender, // msg.sender
string greeting
);
event CrossDomainSetGreeting(
address indexed sender, // Sender on the other side
uint256 indexed chainId, // ChainID of the other side
string greeting
);
Greeter greeter;
function setUp() public {
greeter = new Greeter();
}
function prepareRemoteCall() private {
// Mock calls
vm.mockCall(
Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER,
bytes(abi.encodePacked(IL2ToL2CrossDomainMessenger.crossDomainMessageSender.selector)),
bytes(abi.encode(fakeSender))
);
vm.mockCall(
Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER,
bytes(abi.encodePacked(IL2ToL2CrossDomainMessenger.crossDomainMessageSource.selector)),
bytes(abi.encode(fakeSourceChain))
);
vm.mockCall(
Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER,
bytes(abi.encodePacked(bytes4(keccak256("crossDomainMessageContext()")))),
bytes(abi.encode(fakeSender, fakeSourceChain))
);
vm.prank(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER);
}
function test_InteropSetGreeting() public {
string memory greeting = "Hello";
prepareRemoteCall();
vm.expectEmit(true, false, false, true);
emit SetGreeting(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER, greeting);
vm.expectEmit(true, true, false, true);
emit CrossDomainSetGreeting(fakeSender, fakeSourceChain, greeting);
greeter.setGreeting(greeting);
}
function test_LocalSetGreeting() public {
string memory greeting = "Hello";
vm.expectEmit(true, false, false, true);
emit SetGreeting(address(this), greeting);
greeter.setGreeting(greeting);
}
}
EOF
cd ..
mkdir hardhat
cd hardhat
npm init -y
npm install --save-dev hardhat
export HARDHAT_CREATE_JAVASCRIPT_PROJECT_WITH_DEFAULTS=1
export HARDHAT_DISABLE_TELEMETRY_PROMPT=true
npx hardhat init --yes
cp ../forge/src/Greeter.sol contracts
cat ../forge/src/GreetingSender.sol | sed 's/src\/Greeter.sol/contracts\/Greeter.sol/' > contracts/GreetingSender.sol
find . -name 'Lock*' -exec rm {} \;
npm install @eth-optimism/contracts-bedrock dotenv @eth-optimism/viem
cat > hardhat.config.js <<EOF
require("@nomicfoundation/hardhat-toolbox");
/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
solidity: "0.8.28",
networks: {
hardhat: {
forking: {
url: "https://interop-alpha-0.optimism.io",
},
},
},
};
EOF
cat > test/GreetingSender.js <<EOF
/* eslint-disable node/no-unsupported-es-syntax */
const { expect } = require("chai")
const { ethers } = require("hardhat")
const { anyValue } = require(
"@nomicfoundation/hardhat-chai-matchers/withArgs"
)
const { contracts, l2ToL2CrossDomainMessengerAbi } = require("@eth-optimism/viem")
describe("GreetingSender", function () {
const targetGreeter = "0x0123456789012345678901234567890123456789"
const targetChain = 902
const deployFixture = async () => {
const GreetingSender = await ethers.getContractFactory(
"GreetingSender"
)
const greetingSender = await GreetingSender.deploy(
targetGreeter,
targetChain
)
const messenger = new ethers.Contract(
contracts.l2ToL2CrossDomainMessenger.address,
l2ToL2CrossDomainMessengerAbi,
ethers.provider
);
return { greetingSender, messenger };
}
it("emits SentMessage with the right arguments", async () => {
const { greetingSender, messenger } = await deployFixture()
const greeting = "Hello"
// build the exact calldata the test expects
const iface = new ethers.Interface([
"function setGreeting(string)",
])
const calldata = iface.encodeFunctionData("setGreeting", [
greeting,
])
await expect(greetingSender.setGreeting(greeting))
.to.emit(messenger, "SentMessage")
.withArgs(
targetChain,
targetGreeter,
anyValue,
greetingSender.target,
calldata
)
})
})
EOF
cat > test/Greeter.js <<EOF
const { expect } = require("chai");
const { ethers, network } = require("hardhat");
const { contracts } = require("@eth-optimism/viem")
describe("Greeter", () => {
const fakeSender = "0x0123456789012345678901234567890123456789";
const fakeSourceChain = 901;
async function deployFixture() {
const MockMessenger = await ethers.getContractFactory("MockL2ToL2Messenger");
const mock = await MockMessenger.deploy(fakeSender, fakeSourceChain);
// overwrite predeploy with mock code
await network.provider.send("hardhat_setCode", [
contracts.l2ToL2CrossDomainMessenger.address,
await ethers.provider.getCode(mock.target),
]);
const Greeter = await ethers.getContractFactory("Greeter");
const greeter = await Greeter.deploy();
const messenger = new ethers.Contract(
contracts.l2ToL2CrossDomainMessenger.address,
MockMessenger.interface,
ethers.provider
);
return { greeter, messenger, mockMessenger: mock };
}
it("emits SetGreeting with the right arguments when called locally", async () => {
const { greeter } = await deployFixture();
const greeting = "Hello";
await expect(greeter.setGreeting(greeting))
.to.emit(greeter, "SetGreeting")
.withArgs((await ethers.getSigners())[0].address, greeting);
});
it("emits SetGreeting and CrossDomainSetGreeting with the right arguments when called remotely",
async () => {
const { greeter } = await deployFixture();
const greeting = "Hello";
const impersonatedMessenger =
await ethers.getImpersonatedSigner(contracts.l2ToL2CrossDomainMessenger.address);
const tx = await (await ethers.getSigners())[0].sendTransaction({
to: contracts.l2ToL2CrossDomainMessenger.address,
value: ethers.parseEther("1.0")
});
await expect(
greeter.connect(impersonatedMessenger).setGreeting(greeting)
)
.to.emit(greeter, "SetGreeting")
.withArgs(contracts.l2ToL2CrossDomainMessenger.address, greeting);
await expect(
greeter.connect(impersonatedMessenger).setGreeting(greeting)
)
.to.emit(greeter, "CrossDomainSetGreeting")
.withArgs(fakeSender, fakeSourceChain, greeting);
});
});
EOF
cat > contracts/MockL2ToL2Messenger.sol <<EOF
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract MockL2ToL2Messenger {
address immutable public fakeSender;
uint256 immutable public fakeSource;
constructor(address _fakeSender, uint256 _fakeSource) {
fakeSender = _fakeSender;
fakeSource = _fakeSource;
}
function crossDomainMessageSender() external view returns (address) {
return fakeSender;
}
function crossDomainMessageSource() external view returns (uint256) {
return fakeSource;
}
function crossDomainMessageContext() external view returns (address, uint256) {
return (fakeSender, fakeSource);
}
// Taken from https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/L2/L2ToL2CrossDomainMessenger.sol
event SentMessage(
uint256 indexed destination, address indexed target, uint256 indexed messageNonce, address sender, bytes message
);
function sendMessage(
uint256 _destination,
address _target,
bytes calldata _message
)
external
{
uint256 nonce = 0xdead60a7; // nonoce
emit SentMessage(_destination, _target, nonce, msg.sender, _message);
}
// We need to receive ETH to be able to call Greeter.
receive() external payable {
// Accept ETH. No logic needed.
}
}
EOF
Test sending a message
The easiest way to test sending a message is to see the event emitted by L2ToL2CrossDomainMessenger.sendMessage
(opens in a new tab).
To see this in action, run these commands:
cd testing/hardhat
npx hardhat test test/GreetingSender.js
Of course, Hardhat does not include the interop contracts by default.
To have L2ToL2CrossDomainMessenger.sendMessage
we fork a blockchain that does have it.
This is specified in hardhat.config.js
.
The test is implemented by test/GreetingSender.js
.
Explanation
const GreetingSender = await ethers.getContractFactory(
"GreetingSender"
)
const greetingSender = await GreetingSender.deploy(
targetGreeter,
targetChain
)
Deploy a GreetingSender
.
const messenger = new ethers.Contract(
contracts.l2ToL2CrossDomainMessenger.address,
l2ToL2CrossDomainMessengerAbi,
ethers.provider
);
Create a contract object for L2ToL2CrossDomainMessenger
.
// build the exact calldata the test expects
const iface = new ethers.Interface([
"function setGreeting(string)",
])
const calldata = iface.encodeFunctionData("setGreeting", [
greeting,
])
Calculate the calldata that will be sent by L2ToL2CrossDomainMessenger
.
await expect(greetingSender.setGreeting(greeting))
The operation we're testing, greetingSender.setGreeting(greeting)
.
.to.emit(messenger, "SentMessage")
The expected result, to have the messenger
contract emit a SentMessage
log entry.
.withArgs(
targetChain,
targetGreeter,
anyValue,
greetingSender.target,
calldata
)
The parameters in the log entry.
We use anyValue
for the nonce because we do not know in advance what value it would be.
Test receiving a message
To simulate receiving a message, we need to ensure two conditions are fulfilled:
- The tested code is called by
L2ToL2CrossDomainMessenger
(opens in a new tab). - The tested code can receive additional information through simulations of:
To see this in action, run these commands:
cd testing/hardhat
npx hardhat test test/Greeter.js
This test is implemented by two files, contracts/MockL2ToL2Messenger.sol
which replaces L2ToL2CrossDomainMessenger
with a contract we control, and test/Greeter.js
which actually has the tests.
Explanation of contracts/MockL2ToL2Messenger.sol
address immutable public fakeSender;
uint256 immutable public fakeSource;
constructor(address _fakeSender, uint256 _fakeSource) {
fakeSender = _fakeSender;
fakeSource = _fakeSource;
}
When the tested code receive a message, these are the source chain and address where it originates.
function crossDomainMessageSender() external view returns (address) {
return fakeSender;
}
function crossDomainMessageSource() external view returns (uint256) {
return fakeSource;
}
function crossDomainMessageContext() external view returns (address, uint256) {
return (fakeSender, fakeSource);
}
These are the three functions we need to implement for tested code, such as Greeter
, to call.
// Taken from https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/L2/L2ToL2CrossDomainMessenger.sol
event SentMessage(
uint256 indexed destination, address indexed target, uint256 indexed messageNonce, address sender, bytes message
);
function sendMessage(
uint256 _destination,
address _target,
bytes calldata _message
)
external
{
uint256 nonce = 0xdead60a7; // nonoce
emit SentMessage(_destination, _target, nonce, msg.sender, _message);
}
Because we are replacing the L2ToL2CrossDomainMessenger
with this contract, and the order in which tests are executed is not deterministic, we need this contract to work for the GreetingSender
test.
The code here is copied from L2ToL2CrossDomainMessenger.sendMessage
(opens in a new tab), with the irrelevant parts removed.
// We need to receive ETH to be able to call Greeter.
receive() external payable {
// Accept ETH. No logic needed.
}
The interop message Greeter
gets has to come from the L2ToL2CrossDomainMessenger
address.
We can either send a transaction into MockL2ToL2Messenger
that then calls Greeter
, or give the L2ToL2CrossDomainMessenger
address some ETH and send the transaction directly from it.
This function enables us to give the contract ETH.
Explanation of test/Greeter.js
async function deployFixture() {
This function deploys the fixtures we need to run the tests.
const MockMessenger = await ethers.getContractFactory("MockL2ToL2Messenger");
const mock = await MockMessenger.deploy(fakeSender, fakeSourceChain);
// overwrite predeploy with mock code
await network.provider.send("hardhat_setCode", [
contracts.l2ToL2CrossDomainMessenger.address,
await ethers.provider.getCode(mock.target),
]);
This is the code that first deploys MockL2ToL2Messenger
and then tells Hardhat to direct calls to the L2ToL2CrossDomainMessenger
contract to it instead.
const Greeter = await ethers.getContractFactory("Greeter");
const greeter = await Greeter.deploy();
Deploy a Greeter
to test.
const messenger = new ethers.Contract(
contracts.l2ToL2CrossDomainMessenger.address,
MockMessenger.interface,
ethers.provider
);
Create a contract object for L2ToL2CrossDomainMessenger
.
const { greeter } = await deployFixture();
const greeting = "Hello";
await expect(greeter.setGreeting(greeting))
.to.emit(greeter, "SetGreeting")
.withArgs((await ethers.getSigners())[0].address, greeting);
});
Run the test for local messages.
const impersonatedMessenger =
await ethers.getImpersonatedSigner(contracts.l2ToL2CrossDomainMessenger.address);
const tx = await (await ethers.getSigners())[0].sendTransaction({
to: contracts.l2ToL2CrossDomainMessenger.address,
value: ethers.parseEther("1.0")
});
Create an object for MockL2ToL2Messenger
and give it some ETH to be able to send transactions.
await expect(
greeter.connect(impersonatedMessenger).setGreeting(greeting)
)
.to.emit(greeter, "SetGreeting")
.withArgs(contracts.l2ToL2CrossDomainMessenger.address, greeting);
Test that when receiving an interop message, Greeter
still emits SetGreeting
correctly.
await expect(
greeter.connect(impersonatedMessenger).setGreeting(greeting)
)
.to.emit(greeter, "CrossDomainSetGreeting")
.withArgs(fakeSender, fakeSourceChain, greeting);
Test that when receiving an interop message, Greeter
also emits CrossDomainSetGreeting
.
Next steps
- Write a revolutionary app that uses multiple blockchains within the Superchain.
- Write tests to make sure it works correctly.