Build
Tutorials
Staking

In this tutorial you will create an omnichain contract that will be capable of receiving tokens from connected chains and staking them on ZetaChain. Native tokens deposited to ZetaChain as ZRC-20s will be locked in the contract until withdrawn by the staker. Rewards will be accrued at a fixed rate proportionally to amount of tokens staked.

The staker is the one depositing tokens to the contract. The staker is required to provide a beneficiary address to which the rewards will be sent (a staker is allowed to be its own beneficiary). Only the staker can withdraw the staked tokens from the contract and withdraw to the chain from which they originate.

Only the beneficiary can claim the rewards from the contract.

For simplicity this contract will be compatible with one of the connected chains at a time. The chain ID of the connected chain will be passed to the contract constructor and will be used to check that the contract is called from a compatible chain.

This tutorial shows how to:

  • create an omnichain contract that can receive tokens from connected chains
  • use the parameters of the onCrossChainCall function to:
    • decode the staker address from the context.origin parameter
    • decode the contents of the message parameter
    • determine the chain ID from context.chainID
  • dispatch different logic using an action code
  • how to withdraw tokens correctly both to EVM-based chains and to Bitcoin

Clone the Hardhat contract template:

git clone https://github.com/zeta-chain/template

Install dependencies:

cd template/contracts
yarn

To create a new omnichain contract you will use the omnichain Hardhat task and pass a contract name (Staking) to the task:

npx hardhat omnichain Staking

Design the Omnichain Contract

To implement the functionality described in the introduction, you will need to handle the following actions.

Called from a connected chain by the staker:

  • Staking tokens by depositing them into the staking omnichain contract on ZetaChain
  • Unstaking tokens by withdrawing them to the chain from which they originate
  • Setting the beneficiary address

Called on ZetaChain:

  • Claiming rewards by the beneficiary
  • Querying the pending rewards

Since the omnichain contract has only one function that gets called when the contract is triggered from a connected chain (onCrossChainCall), and you need to be able to execute different logic depending on the action, you will need to encode the action code into the message parameter of the onCrossChainCall.

Even though the message value will be encoded differently in EVM-based chains and Bitcoin, the first bytes of the message will always be the action code encoded as uint8.

Handle the Omnichain Contract Call

contracts/Staking.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;
 
import "@zetachain/protocol-contracts/contracts/zevm/SystemContract.sol";
import "@zetachain/protocol-contracts/contracts/zevm/interfaces/zContract.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@zetachain/toolkit/contracts/BytesHelperLib.sol";
import "@zetachain/toolkit/contracts/OnlySystem.sol";
 
contract Staking is ERC20, zContract, OnlySystem {
    SystemContract public systemContract;
    uint256 public immutable chainID;
    uint256 constant BITCOIN = 18332;
 
    uint256 public rewardRate = 1;
 
    error WrongChain(uint256 chainID);
    error UnknownAction(uint8 action);
    error Overflow();
    error Underflow();
    error WrongAmount();
    error NotAuthorized();
    error NoRewardsToClaim();
 
    mapping(bytes => uint256) public stakes;
    mapping(bytes => address) public beneficiary;
    mapping(bytes => uint256) public lastStakeTime;
 
    constructor(
        string memory name_,
        string memory symbol_,
        uint256 chainID_,
        address systemContractAddress
    ) ERC20(name_, symbol_) {
        systemContract = SystemContract(systemContractAddress);
        chainID = chainID_;
    }
 
    function onCrossChainCall(
        zContext calldata context,
        address zrc20,
        uint256 amount,
        bytes calldata message
    ) external virtual override onlySystem(systemContract) {
        if (chainID != context.chainID) {
            revert WrongChain(context.chainID);
        }
 
        uint8 action = chainID == BITCOIN
            ? uint8(message[0])
            : abi.decode(message, (uint8));
 
        if (action == 1) {
            stake(context.origin, amount, message);
        } else if (action == 2) {
            unstake(context.origin);
        } else if (action == 3) {
            updateBeneficiary(context.origin, message);
        } else {
            revert UnknownAction(action);
        }
    }
}

First, import the ERC20 contract from OpenZeppelin to manage our ERC20 staking reward token. Import BytesHelperLib from ZetaChain's toolkit for utility functions to convert bytes into addresses and vice versa.

Add the chainID variable to store the ID of the connected chain. This variable will be set in the constructor and will be used to check that the contract is called from the correct chain.

Add the BITCOIN constant to store the chain ID of Bitcoin. ZetaChain uses 18332 to represent Bitcoin's chain ID.

The contract needs to store the following mappings:

  • stakes - stores the staker's staked balance
  • beneficiary - stores the staker's beneficiary address
  • lastStakeTime - stores the timestamp of the last staking action

Modify the constructor to accept three additional arguments: name_, symbol_, and chainID_. The name_ and symbol_ arguments will be used to initialize the ERC20 contract. The chainID_ argument will be used to set the chainID variable.

onCrossChainCall is the function that will be called by the system contract when a user triggers the omnichain contract from a connected chain.

First, check that the omnichain contract is called from the same connected chain as the one specified in the constructor. You can use context.chainID to get the chain ID of the connected chain from which the omnichain contract was called.

context.origin contains information about the address from which the transaction that triggered the omnichain contract was broadcasted.

For EVM-based chains, context.origin is the actual address of the account which broadcasted the transaction. For example:

0x2cD3D070aE1BD365909dD859d29F387AA96911e1

For Bitcoin, context.origin is the hexadecimal representation of the Bitcoin address. For example, if the Bitcoin address is:

tb1q2dr85d57450xwde6560qyhj7zvzw9895hq25tx

The hexadecimal representation of the address is:

0x74623171326472383564353734353078776465363536307179686a377a767a7739383935687132357478

The message parameter contains the data that was passed to the omnichain contract when it was called from the connected chain. In our design the first value in the message is the action code. For Bitcoin take the first byte of the message and convert it to an unsigned 8-bit integer (uint8). For EVM-based chains, use abi.decode to decode the first value of the message as a uint8.

Finally, based on the action code, call the corresponding function:

1 - stake ZRC-20 tokens

2 - unstake ZRC-20 tokens

3 - update beneficiary address

Update Beneficiary

updateBeneficiary is a function that will be called by the staking function to set the beneficiary address or can be optionally called by the staker.

The message is encoded differently in EVM-based chains and Bitcoin. For Bitcoin, the beneficiary address follows the uint8 action code (1 byte long) and is 20 bytes long. Use bytesToAddress with an offset of 1 (byte) to decode the beneficiary address.

For EVM-based chains, use abi.decode to get the beneficiary address from the message.

contracts/Staking.sol
    function updateBeneficiary(
        bytes memory staker,
        bytes calldata message
    ) internal {
        address beneficiaryAddress;
        if (chainID == BITCOIN) {
            beneficiaryAddress = BytesHelperLib.bytesToAddress(message, 1);
        } else {
            (, beneficiaryAddress) = abi.decode(message, (uint8, address));
        }
        beneficiary[staker] = beneficiaryAddress;
    }

Stake ZRC-20 Tokens

stake is a function that will be called by onCrossChainCall to stake the deposited tokens.

contracts/Staking.sol
    function stake(
        bytes memory staker,
        uint256 amount,
        bytes calldata message
    ) internal {
        updateBeneficiary(staker, message);
 
        stakes[staker] += amount;
        if (stakes[staker] < amount) revert Overflow();
 
        updateRewards(staker);
    }
 
    function updateRewards(bytes memory staker) internal {
        if (lastStakeTime[staker] == 0) lastStakeTime[staker] = block.timestamp;
        uint256 rewardAmount = queryRewards(staker);
 
        _mint(beneficiary[staker], rewardAmount);
        lastStakeTime[staker] = block.timestamp;
    }
 
    function queryRewards(bytes memory staker) public view returns (uint256) {
        uint256 timeDifference = block.timestamp - lastStakeTime[staker];
        uint256 rewardAmount = timeDifference * stakes[staker] * rewardRate;
        return rewardAmount;
    }

stake increases the staker's balance in the contract. The function also calls the updateRewards function to update the rewards for the staker.

updateRewards calculates the rewards for the staker and mints them to the beneficiary address. The function also updates the timestamp of when the staking happened last.

Unstake ZRC-20 Tokens

The unstake function begins by updating any outstanding rewards due to the user. It then checks that the user has a sufficient staked balance. Subsequently, it identifies the ZRC20 token associated with the contract's chainID and determines the gas fee for the unstaking operation. This fee is then approved. The user's tokens, minus the gas fee, are withdrawn to the encoded recipient address. Finally, the contract updates the user's staking balance and the timestamp of their last stake action.

contracts/Staking.sol
    function unstake(bytes memory staker) internal {
        uint256 amount = stakes[staker];
 
        updateRewards(staker);
 
        address zrc20 = systemContract.gasCoinZRC20ByChainId(chainID);
        (, uint256 gasFee) = IZRC20(zrc20).withdrawGasFee();
 
        if (amount < gasFee) revert WrongAmount();
 
        stakes[staker] = 0;
 
        IZRC20(zrc20).approve(zrc20, gasFee);
        IZRC20(zrc20).withdraw(staker, amount - gasFee);
 
        if (stakes[staker] > amount) revert Underflow();
 
        lastStakeTime[staker] = block.timestamp;
    }

Claim Rewards

claimRewards is a function that will be called by the beneficiary to claim the rewards. The function checks that the caller is the beneficiary and calls the updateRewards function to send rewards to the beneficiary.

contracts/Staking.sol
    function claimRewards(bytes memory staker) external {
        if (beneficiary[staker] != msg.sender) revert NotAuthorized();
        uint256 rewardAmount = queryRewards(staker);
        if (rewardAmount <= 0) revert NoRewardsToClaim();
        updateRewards(staker);
    }

Modify the deploy task to accept an additional --chain flag that will specify the connected chain. The --chain flag is used to determine the chain ID of the connected chain and pass it as an argument to the constructor.

It is also used to get the symbol of the ZRC-20 token on the connected chain.

tasks/deploy.ts
import ZRC20 from "@zetachain/protocol-contracts/abi/zevm/ZRC20.sol/ZRC20.json";
 
// ...
 
const factory = await hre.ethers.getContractFactory("Staking");
 
let symbol, chainID;
if (args.chain === "btc_testnet") {
  symbol = "BTC";
  chainID = 18332;
} else {
  const zrc20 = getAddress("zrc20", args.chain);
  const contract = new hre.ethers.Contract(zrc20, ZRC20.abi, signer);
  symbol = await contract.symbol();
  chainID = hre.config.networks[args.chain]?.chainId;
  if (chainID === undefined) {
    throw new Error(`🚨 Chain ${args.chain} not found in hardhat config.`);
  }
}
 
const contract = await factory.deploy(
  `Staking rewards for ${symbol}`,
  `R${symbol.toUpperCase()}`,
  chainID,
  systemContract
);
 
await contract.deployed();
 
// ...
 
task("deploy", "Deploy the contract", main).addParam("chain", "Chain ID (use btc_testnet for Bitcoin Testnet)");

To make it easier to interact with the contract, create a few tasks that will:

  • stake tokens
  • unstake tokens
  • set beneficiary address

You can find the source code for the tasks in the tasks directory of the project:

https://github.com/zeta-chain/example-contracts/tree/main/omnichain/staking/tasks (opens in a new tab)

When copying these files make sure to also the helper function convertToHexAddress.ts in the lib directory.

To perform the actions above, the tasks simply send transactions to the TSS address on the connected chain with the encoded data in the data field of the transactions.

According to the design, the first byte of the message parameter is the action code. The rest of the message is the data that is required for the action.

For example, to stake tokens, the data is just the uint8 action code:

const data = prepareData(args.contract, ["uint8"], ["1"]);

To set the beneficiary address, the data is the uint8 action code followed by the beneficiary address:

const data = prepareData(args.contract, ["uint8", "address"], ["3", args.beneficiary]);

Under the hood prepareData will also add the omnichain contract address to the beginning of the data to make sure the transaction is sent to the right contract on ZetaChain.

The stake task requires you to send tokens specified by the amount argument. These tokens will be transferred to and locked in the TSS address, and your staking contract will receive the same amount of tokens as ZRC-20s.

Other actions do not require you to send tokens, so the amount argument can be "0".

For Bitcoin, create an addresses task that will help you convert between bech32 address and a hexadecimal representation of the address.

tasks/addresses.ts
import { task } from "hardhat/config";
import { HardhatRuntimeEnvironment } from "hardhat/types";
import { utils } from "ethers";
 
const main = async (args: any, hre: HardhatRuntimeEnvironment) => {
  const dataTypes = ["bytes"];
  const values = [utils.toUtf8Bytes(args.address)];
 
  const encodedData = utils.solidityPack(dataTypes, values);
  console.log(`Encoded: ${encodedData}`);
  console.log(`context.origin: ${encodedData.slice(0, 42)}`);
};
 
task("address", "Encode a Bitcoin bech32 address to hex", main).addPositionalParam("address");

Before proceeding make sure to import all newly created tasks in hardhat.config.ts.

Before proceeding with the next steps, make sure you have created an account and requested ZETA tokens from the faucet.

Deploying the Contract

Clear the cache and artifacts, then compile the contract:

npx hardhat compile --force

Use the deploy task to deploy the contract to ZetaChain with --chain flag specifying Sepolia testnet:

npx hardhat deploy --network zeta_testnet --chain sepolia_testnet

Stake Tokens

npx hardhat stake --amount 0.1 --beneficiary ADDRESS --contract ADDRESS --network sepolia_testnet

Unstake Tokens

npx hardhat unstake --contract ADDRESS --network sepolia_testnet

Deploying the Contract

Clear the cache and artifacts, then compile the contract:

npx hardhat compile --force

Use the deploy task to deploy the contract to ZetaChain with --chain flag specifying the Bitcoin testnet:

npx hardhat deploy --network zeta_testnet --chain btc_testnet

🔑 Using account: 0x2cD3D070aE1BD365909dD859d29F387AA96911e1

🚀 Successfully deployed contract on ZetaChain.
📜 Contract address: 0x57cafCe6802c45F682201b93529B09EfB9A492C3
🌍 Explorer: https://athens3.explorer.zetachain.com/address/0x57cafCe6802c45F682201b93529B09EfB9A492C3

Stake Tokens

Pass your contract address (without the 0x prefix), the action code for staking tokens (01), beneficiary address (2cD3D070aE1BD365909dD859d29F387AA96911e1) to the memo flag. Specify the --amount of tBTC you want to transfer to your omnichain contract.

npx hardhat send-btc --memo 57cafCe6802c45F682201b93529B09EfB9A492C3012cD3D070aE1BD365909dD859d29F387AA96911e1 --amount 0.01 --recipient tb1qy9pqmk2pd9sv63g27jt8r657wy0d9ueeh0nqur

Unstake Tokens

Pass your contract address (without the 0x prefix) and the action code for unstaking tokens (02) to the memo flag.

npx hardhat send-btc --memo 57cafCe6802c45F682201b93529B09EfB9A492C302 --amount 0.0 --recipient tb1qy9pqmk2pd9sv63g27jt8r657wy0d9ueeh0nqur

Congratulations! 🎉 You have created an omnichain staking contract and learned:

  • how to make your contract compatible both with EVM-based chains and Bitcoin
  • how use the context.chainID to get chain ID and the context.origin to get an identifier for the sender
  • that the context.origin represents the actual sender address on EVM-based chains and the first 20 bytes of the hexadecimal representation of the Bitcoin address on Bitcoin
  • the pattern for encoding the action code into the message parameter of the onCrossChainCall function to execute different logic from the same function
  • how to use the BytesHelperLib from ZetaChain's toolkit to convert bytes into an address.
  • how to implement and use bytesToBech32Bytes to take the bytes corresponding to the hexadecimal representation of the Bitcoin address from the message.

You can find the source code for the example in this tutorial here:

https://github.com/zeta-chain/example-contracts/tree/main/omnichain/staking (opens in a new tab)


How's your journey with ZetaChain?
Let us know