Interop Contracts
Prerequisites
Upgrade or install your version of node to at least 22.10.0 as required by the latest version of Hardhat.
Contracts Setup
Before writing our contracts, clone the template repo that has the UI for the frontend already setup:
git clone https://github.com/sarahschwartz/interop-messages-defi-demo.git
cd interop-messages-defi-demo
Create another folder called contracts and move inside this folder:
mkdir contracts
cd contracts
Initialize a new hardhat project in the contracts folder:
npx hardhat --init
Select the options to use Hardhat 3 and a
TypeScript project using Mocha and Ethers.js.
Select yes to install all of the default dependencies.
Once the project is initialized,
you can delete the template files in the contracts, ignition/modules, scripts, and tests folders.
rm -rf ./contracts/*
rm -rf ./ignition/modules/*
rm -rf ./scripts/*
rm -rf ./test/*
To make the interop script more straightforward,
we can use the zksync-ethers and zksync-contracts SDKs.
Inside the hardhat project, run the command below to install these dependencies:
npm install -D zksync-ethers @matterlabs/zksync-contracts
Hardhat config
Next, update the hardhat.config.ts file to configure our local or testnet networks:
import type { HardhatUserConfig } from 'hardhat/config';
import hardhatToolboxMochaEthersPlugin from '@nomicfoundation/hardhat-toolbox-mocha-ethers';
import hardhatKeystore from '@nomicfoundation/hardhat-keystore';
import { configVariable } from 'hardhat/config';
const config: HardhatUserConfig = {
plugins: [hardhatToolboxMochaEthersPlugin, hardhatKeystore],
solidity: {
profiles: {
default: {
version: '0.8.28',
},
production: {
version: '0.8.28',
settings: {
optimizer: {
enabled: true,
runs: 200,
},
},
},
},
},
ignition: {
requiredConfirmations: 1,
},
networks: {
hardhatMainnet: {
type: 'edr-simulated',
chainType: 'l1',
},
zksyncGateway: {
type: 'http',
url: 'http://localhost:3150',
chainType: 'generic',
},
stakingChain1: {
type: 'http',
url: 'http://localhost:3050',
chainType: 'generic',
accounts: [configVariable('WALLET_PRIVATE_KEY')],
},
stakingChain2: {
type: 'http',
url: 'http://localhost:3250',
chainType: 'generic',
accounts: [configVariable('WALLET_PRIVATE_KEY')],
},
rewardsChain: {
type: 'http',
url: 'http://localhost:3350',
chainType: 'generic',
accounts: [configVariable('WALLET_PRIVATE_KEY')],
},
},
};
export default config;
For each chain we are using configVariable('WALLET_PRIVATE_KEY') for the deployer account.
To configure your private key using Hardhat keystore, run the command below:
npx hardhat keystore set WALLET_PRIVATE_KEY
If you have previously used Hardhat keystore, it will ask you to enter your password. If you haven't you will be prompted to set up a new password.
Then, input the private key for the pre-configured rich wallet we bridged funds to in ecosystem setup tutorial. If you bridged funds to another wallet, use the private key for that wallet here.
0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110
Staking contract
Create a new file in the contracts/contracts folder called Staking.sol.
touch contracts/Staking.sol
Then copy and paste the staking contract:
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.21;
import { IL1Messenger } from '@matterlabs/zksync-contracts/contracts/system-contracts/interfaces/IL1Messenger.sol';
contract Staking {
address constant L2_TO_L1_MESSENGER_SYSTEM_CONTRACT_ADDR = 0x0000000000000000000000000000000000008008;
IL1Messenger public L1Messenger = IL1Messenger(L2_TO_L1_MESSENGER_SYSTEM_CONTRACT_ADDR);
struct Deposit {
uint256 amount;
bool madeFirstDeposit;
}
// mapping to address to deposit amount and timestamp
mapping(address => Deposit) public deposits;
function deposit() external payable {
require(msg.value > 0, 'no amount deposited');
Deposit memory lastDeposit = deposits[msg.sender];
deposits[msg.sender].amount = lastDeposit.amount + msg.value;
if (lastDeposit.madeFirstDeposit != true) {
deposits[msg.sender].madeFirstDeposit = true;
bytes memory message = abi.encode(msg.sender);
L1Messenger.sendToL1(message);
}
}
function withdraw() external {
Deposit memory lastDeposit = deposits[msg.sender];
require(lastDeposit.amount > 0, 'no amount deposited');
deposits[msg.sender].amount = 0;
(bool sent, ) = msg.sender.call{ value: lastDeposit.amount }('');
require(sent, 'send failed');
}
}
There are two functions in the staking contract: deposit and withdraw.
When a user first calls the deposit method, the contract sends an interop message with their encoded address.
To send the message, we use the sendToL1 function on the L1Messenger contract, which is pre-deployed on every ZKsync chain at address 0x00..008008.
The withdraw method simply refunds the user their deposited funds.
This contract will be deployed to multiple chains, allowing users to choose which chain they want to stake their ETH in order to access the token reward.
Token contract
Create a new file in the contracts/contracts folder called InteropToken.sol.
touch contracts/InteropToken.sol
Then copy and paste the token contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
import { ERC20 } from '@openzeppelin/contracts/token/ERC20/ERC20.sol';
import { Ownable } from '@openzeppelin/contracts/access/Ownable.sol';
import { IMessageVerification } from '@matterlabs/zksync-contracts/contracts/l1-contracts/state-transition/chain-interfaces/IMessageVerification.sol';
import { L2Message } from '@matterlabs/zksync-contracts/contracts/l1-contracts/common/Messaging.sol';
contract InteropToken is ERC20, Ownable {
// mapping of chain IDs to the approved staking contract address
mapping(uint256 => address) public approvedStakingContracts;
// mapping of addresses to if they have minted tokens or not
mapping(address => bool) public addressesThatMinted;
// mapping of chain IDs to number of adresses that have minted some tokens
mapping(uint256 => uint256) public rewardsByChain;
// the chain ID with the most rewards
uint256 public winningChainId;
address constant L2_MESSAGE_VERIFICATION_ADDRESS = 0x0000000000000000000000000000000000010009;
IMessageVerification public l2MessageVerifier = IMessageVerification(L2_MESSAGE_VERIFICATION_ADDRESS);
constructor(
uint256[] memory _chainIds,
address[] memory _stakingContracts
) ERC20('InteropToken', 'INTR') Ownable(msg.sender) {
require(_chainIds.length == _stakingContracts.length, 'Length mismatch');
for (uint256 i = 0; i < _chainIds.length; ++i) {
require(_stakingContracts[i] != address(0), 'Zero address used');
require(approvedStakingContracts[_chainIds[i]] == address(0), 'Staking contract address already set');
approvedStakingContracts[_chainIds[i]] = _stakingContracts[i];
}
}
function mint(
uint256 _sourceChainId,
uint256 _l1BatchNumber,
uint256 _l2MessageIndex,
L2Message calldata _l2MessageData,
bytes32[] calldata _proof
) external {
// token can only be minted once per address
require(addressesThatMinted[msg.sender] == false, 'Token already minted');
require(approvedStakingContracts[_sourceChainId] == _l2MessageData.sender, 'Message origin not approved');
bool result = checkMessageProof(_sourceChainId, _l1BatchNumber, _l2MessageIndex, _l2MessageData, _proof);
require(result == true, 'Message not verified');
address sender = abi.decode(_l2MessageData.data, (address));
require(sender == msg.sender, 'Message sender is not depositor');
rewardsByChain[_sourceChainId]++;
if (rewardsByChain[_sourceChainId] > rewardsByChain[winningChainId]) {
winningChainId = _sourceChainId;
}
addressesThatMinted[msg.sender] = true;
_mint(msg.sender, 1);
}
function checkMessageProof(
uint256 _sourceChainId,
uint256 _l1BatchNumber,
uint256 _l2MessageIndex,
L2Message calldata _l2MessageData,
bytes32[] calldata _proof
) public view returns (bool) {
bool result = l2MessageVerifier.proveL2MessageInclusionShared(
_sourceChainId,
_l1BatchNumber,
_l2MessageIndex,
_l2MessageData,
_proof
);
return result;
}
}
This token contract is used to reward users for staking ETH on any of the staking contracts deployed to different chains.
In the token contract's constructor function, a list of approved staking contract addresses and their chain IDs must be defined. We use this in the contract to make sure interop messages were sent from the correct contracts.
This contract has a mint function that takes the arguments needed for message verification.
We use the proveL2MessageInclusionShared method in the L2 message verification contract for verification.
This contract is deployed at 0x..10009 on each ZKsync chain.
There are a few different checks happening in the mint function:
- The user has not previously claimed a reward token.
- The interop message is verifiable.
- The interop message was sent from an approved staking contract.
- The interop message matches the user's address.
Ignition scripts
Now that we have the contracts ready, let's add some ignition scripts for deploying.
Staking ignition script
Create a new file in the ignition/modules folder called Staking.ts:
touch ignition/modules/Staking.ts
Then copy and paste the module below:
import { buildModule } from '@nomicfoundation/hardhat-ignition/modules';
export default buildModule('StakingModule', (m) => {
const counter = m.contract('Staking');
return { counter };
});
Token ignition script
Create another file in the ignition/modules folder called InteropToken.ts:
touch ignition/modules/InteropToken.ts
Then copy and paste the module below:
import { buildModule } from '@nomicfoundation/hardhat-ignition/modules';
const STAKING_CHAIN_1_CONTRACT_ADDRESS = '0x...';
const STAKING_CHAIN_2_CONTRACT_ADDRESS = '0x...';
// make sure chain ids match the chains you created in the ecosystem setup
const STAKING_CHAIN_1_ID = '34234';
const STAKING_CHAIN_2_ID = '5328';
export default buildModule('InteropTokenModule', (m) => {
const approvedChainIds = [STAKING_CHAIN_1_ID, STAKING_CHAIN_2_ID];
const approvedStakingContracts = [STAKING_CHAIN_1_CONTRACT_ADDRESS, STAKING_CHAIN_2_CONTRACT_ADDRESS];
console.log('Approved staking contracts:', approvedStakingContracts);
const counter = m.contract('InteropToken', [approvedChainIds, approvedStakingContracts]);
return { counter };
});
Deploying the contracts
Add some scripts to package.json to make it easier to compile and deploy the contracts:
"scripts": {
"compile": "hardhat compile",
"deploy:stakingChain1": "hardhat ignition deploy ignition/modules/Staking.ts --network stakingChain1",
"deploy:stakingChain2": "hardhat ignition deploy ignition/modules/Staking.ts --network stakingChain2",
"deploy:token": "hardhat ignition deploy ignition/modules/InteropToken.ts --network rewardsChain"
},
Now you can compile the contracts with:
npm run compile
Then deploy the staking contract to the staking chains:
npm run deploy:stakingChain1
npm run deploy:stakingChain2