Skip to main content
Developers
Omnichain Contracts
Tutorials
Staking

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 withdraw the rewards from the contract.

For simplicity this contract will be compatible with one of the chain of your choosing. The chain ID of the compatible connected chain is defined by the deployer of the contract.

This tutorial demonstrates:

  • how to create an omnichain contract that executes logic with tokens from a connected chain
  • how to use the parameters of the onCrossChainCall function to:
    • get the staker address from the context parameter
    • get the beneficiary address from the message parameter both for EVM-based chains and for Bitcoin
  • how to withdraw tokens correctly both to EVM-based chains and to Bitcoin

Prerequisites

Set Up Your Environment

Clone the Hardhat contract template:

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

Install dependencies:

cd template
yarn

Create the Contract

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

npx hardhat omnichain Staking beneficiary:address

The beneficiary of type address argument will be provided by users calling the omnichain contract from one of the connected chains. The beneficiary argument will be passed to the onCrossChainCall function in the message parameter.

Omnichain Contract

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";

contract Staking is ERC20, zContract {
error SenderNotSystemContract();
error WrongChain();

SystemContract public immutable systemContract;
uint256 public immutable chainID;

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 override {
if (msg.sender != address(systemContract)) {
revert SenderNotSystemContract();
}

address acceptedZRC20 = systemContract.gasCoinZRC20ByChainId(chainID);
if (zrc20 != acceptedZRC20) revert WrongChain();

address staker = BytesHelperLib.bytesToAddress(context.origin, 0);
address beneficiary;

if (context.chainID == 18332) {
beneficiary = BytesHelperLib.bytesToAddress(message, 0);
} else {
beneficiary = abi.decode(message, (address));
}

stakeZRC(staker, beneficiary, amount);
}
}

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.

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 deposits tokens to the omnichain contract. First, check that the function is called by the system contract.

First, check that deposited token matches the token associated with the chain identified by the chainID variable.

context.origin contains information about the address from which the transaction that triggered the omnichain contract was broadcasted. Use BytesHelperLib.bytesToAddress to decode the staker's address from the context.origin bytes.

Next, decode the message parameter to get the beneficiary address. The beneficiary address is passed to the onCrossChainCall function by the user calling the omnichain contract from the connected chain. The message parameter is a bytes array that contains the beneficiary address encoded as bytes. ZetaChain uses 18332 to represent Bitcoin's chain ID, so use this value to check if the connected chain is Bitcoin. Use the BytesHelperLib.bytesToAddress function to decode the beneficiary address from the message parameter if the connected chain is Bitcoin. If the connected chain is an EVM-based chain, use the abi.decode function.

Finally, call the stakeZRC function to stake the deposited tokens.

Stake ZRC-20 Tokens

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

contracts/Staking.sol
    mapping(address => uint256) public stakes;
mapping(address => address) public beneficiaries;
mapping(address => uint256) public lastStakeTime;
uint256 public rewardRate = 1;

function stakeZRC(
address staker,
address beneficiary,
uint256 amount
) internal {
stakes[staker] += amount;
if (beneficiaries[staker] == address(0)) {
beneficiaries[staker] = beneficiary;
}
lastStakeTime[staker] = block.timestamp;

updateRewards(staker);
}

function updateRewards(address staker) internal {
uint256 timeDifference = block.timestamp - lastStakeTime[staker];
uint256 rewardAmount = timeDifference * stakes[staker] * rewardRate;

_mint(beneficiaries[staker], rewardAmount);
lastStakeTime[staker] = block.timestamp;
}

stakeZRC increases the staker's balance in the contract and sets the beneficiary address if it is not set yet. The function also updates the timestamp of when the staking happened last, and 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.

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
    error NotAuthorizedToClaim();

function claimRewards(address staker) external {
if (beneficiaries[staker] != msg.sender) {
revert NotAuthorizedToClaim();
}

updateRewards(staker);
}

Unstake ZRC-20 Tokens

The unstakeZRC 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 recipient's address is encoded differently based on the chainID: for a chainID of 18332 (which is Bitcoin), addressToBytes function is used to convert the address to bytes, otherwise, for EVM-based chains it's directly packed. 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 unstakeZRC(uint256 amount) external {
updateRewards(msg.sender);

require(stakes[msg.sender] >= amount, "Insufficient staked balance");

address zrc20 = systemContract.gasCoinZRC20ByChainId(chainID);

(address gasZRC20, uint256 gasFee) = IZRC20(zrc20).withdrawGasFee();

IZRC20(zrc20).approve(zrc20, gasFee);

bytes memory recipient;

if (chainID == 18332) {
recipient = abi.encodePacked(
BytesHelperLib.addressToBytes(msg.sender)
);
} else {
recipient = abi.encodePacked(msg.sender);
}

IZRC20(zrc20).withdraw(recipient, amount - gasFee);

stakes[msg.sender] -= amount;
lastStakeTime[msg.sender] = block.timestamp;
}

Query Rewards

queryRewards is a function that queries the pending rewards for a given staker address. The function calculates the rewards based on the time difference between the current time and the last time the staker staked tokens similarly to the updateRewards function.

contracts/Staking.sol
    function queryRewards(address account) public view returns (uint256) {
uint256 timeDifference = block.timestamp - lastStakeTime[account];
uint256 rewardAmount = timeDifference * stakes[account] * rewardRate;
return rewardAmount;
}