Deploying an NFT staking contract on the Gnosis chain

Table of contents

Introduction

Gnosis is an EVM-based blockchain that is currently secured by over 100k validators. It aims for fast transaction times and low transaction fees. Gnosis mainnet and its corresponding Chiado testnet use xDai and the Chiado xDai as native tokens for their respective blockchain networks.

In this tutorial, we will deploy an NFT staking smart contract to the Gnosis Chain. Our project consists of two contracts: 

  • Fuel.sol, will represent our NFT smart contract (ERC-721) 
  • Rewards.sol, will be our staking contract that will receive new NFTs and issue rewards in the form of ERC-20 tokens. 

Prerequisites 

  • Any code editor of your choice on a machine that has NodeJs installed.
  • A grasp of the basic concepts of Solidity and Javascript, since all of our code will be written in those two languages.
  • We will be using Truffle to compile and deploy our smart contracts. It will be helpful, but not necessary to be familiar with the framework.

Setting up a dev environment with Truffle

Before we begin writing our smart contracts, we need to have a dev environment set up which allows us to compile and test our smart contracts before we deploy them to a live network. While there are many great smart contract frameworks available, we will be going with Truffle in this tutorial.
To set up a dev environment with Truffle:

  1. Create a folder named nftGnosis (or any other name you like).
  1. Open the folder in your terminal, and run the command:
    npm init -y
    This command will initialize an empty package.json in your directory.
  1. To install Truffle in your system, run:
    npm install -g truffle
    This command will install the latest version of Truffle into your system.
  1. Let us check if Truffle was correctly installed in our system. To do so, run:
    truffle version
    This command should return the version of Truffle installed in your system.
  1. To initialize a basic Truffle project, first, make sure that your terminal is pointing correctly to the root of your project directory. Now, in the terminal, run:
    truffle init
    This command will install a barebones Truffle project into your local directory.
  1. This is what your terminal and project directory should look like right now:

We are using VS Code as our editor, but any good code editor will do.

Taking a look at Truffle’s project structure

After you’ve initialized a boilerplate Truffle project, your directory should look something like this:

├── contracts 
   └── .gitkeep
├── migrations 
   └── .gitkeep 
├── test
└── .gitkeep
├── package.json 
├── truffle-config.js

Let us quickly go over this:

  • The .gitkeep file is simply used as a placeholder in empty directories since Git does not allow us to push empty directories to remote repositories. This file does not affect our project in any way.
  • The contracts directory contains all of our smart contracts.
  • The migrations directory contains JavaScript files that are used to deploy our contracts to live networks.
  • The test directory contains code that tests our smart contracts. Truffle supports smart contract testing in JavaScript, TypeScript, and Solidity. You can read more about this in their official docs.
  • The package.json contains a reference to all the npm packages that we have installed in our project.
  • The truffle-config file, as the name suggests, contains all the configurations for our Truffle project.
    This is a JavaScript file that exports an object that contains all of our configurations. We can define a wide variety of parameters within this object, including the networks, the Solidity version, and the provider RPC URLs, amongst many more.
    We will look into this in more detail below.

Writing and compiling smart contracts in Truffle

Before getting down to writing Solidity code, let us learn a bit about OpenZeppelin contracts.
OpenZeppelin offers us a variety of products, but their contracts library is the one we will be using in this tutorial.
Solidity as a language supports inheritance. This means we can use pre-built Solidity components to augment our own code. OpenZeppelin on its part has a huge library of Solidity contracts and interfaces that we can use to build ERC-20 and ERC-721 smart contracts in a secure way.
Sure we could do the same without using OpenZeppelin, but why not use a library that has been thoroughly audited and optimized, thus saving us both time and money?

To get started with OpenZepplin contracts, open your terminal and run:

npm i @openzeppelin/contracts

This command will install the @openzeppelin/contracts library into your local directory, and you can check your package.json to make sure it is installed correctly.
We can now inherit OpenZeppelin contracts into our own solidity code.

We are now ready to start writing some Solidity code. In the contracts folder, create 2 files:

  • Fuel.sol: This will be our ERC-721 contract
  • Rewards.sol: This contract will contain the logic for our staking system.

Fuel.sol

In an empty Fuel.sol, paste the following code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
contract Fuel is ERC721, ERC721Burnable, Ownable {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIdCounter;
    constructor() ERC721("Fuel", "FUEL") {}
    function safeMint(address to) public onlyOwner {
        uint256 tokenId = _tokenIdCounter.current();
        _tokenIdCounter.increment();
        _safeMint(to, tokenId);
    }
}

This is a small, but quite effective ERC-721 contract that does the following:

  • The constructor initializes the contract name and symbol at the time of deployment.
  • The counters.sol contract helps us keep track of all the NFTs that have been minted from our contract. Every time a new NFT is minted, the token ID is incremented. This means each NFT minted from our contract has a unique token ID that can be used to uniquely identify it.
  • The safeMint() function uses the _safeMint() function from the ERC-721 contract, which allows us to conveniently mint a new NFT to a specific address. Please note that while we define the ‘safeMint()‘ function, the ‘_safeMint()‘ function is inherited from the ERC-721 contract. You can read more about the ERC-721 template from OpenZeppelin’s official docs.
    The onlyOwner modifier helps us in preventing unauthorized people from calling sensitive functions.
  • ERC721Burnable.sol gives us access to the burn function, which can be used to ‘burn’ a specific NFT by basically transferring it over to the ‘zero’ address, which is basically a generic null address that has no owner.

Rewards.sol

Now open Rewards.sol, and paste the following code inside it:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
contract Rewards is ERC20, ERC721Holder, Ownable {
    IERC721 public nft;
    mapping(uint256 => address) public tokenOwnerOf;
    mapping(uint256 => uint256) public tokenStakedAt;
    mapping(uint256 => bool) public isStaked;
    uint256 public rewardsPerHour = (1 * 10 ** decimals()) / 1 hours;
    constructor(address _nft) ERC20("Reward", "RWD") {
        nft = IERC721(_nft);
    }
    function stake(uint256 tokenId) external {
        nft.safeTransferFrom(msg.sender, address(this), tokenId);
        tokenOwnerOf[tokenId] = msg.sender;
        tokenStakedAt[tokenId] = block.timestamp;
        isStaked[tokenId] = true;
    }
    function calculateRewards(uint256 tokenId) public view returns (uint256) {
        require(isStaked[tokenId], "This Token id was never staked");
        uint timeElapsed = block.timestamp - tokenStakedAt[tokenId];
        return timeElapsed * rewardsPerHour;
    }
    function unstake(uint256 tokenId) external {
        uint timeElapsed = block.timestamp - tokenStakedAt[tokenId];
        uint minimumTime = 7 days;
        require(tokenOwnerOf[tokenId] == msg.sender, "You can't unstake because you are not the owner");
        require(timeElapsed >= minimumTime, "You need to stake for at least 7 days");
        _mint(msg.sender, calculateRewards(tokenId));
        nft.transferFrom(address(this), msg.sender, tokenId);
        delete tokenOwnerOf[tokenId];
        delete tokenStakedAt[tokenId];
        delete isStaked[tokenId];
    }
}

This is our main staking smart contract. Let us go over the main concepts of this contract carefully:

  • The contract uses four OpenZeppelin contract templates, namely:
    • ERC20.sol: The ERC-20 contract, allows us to create a smart contract for fungible tokens. The contract constructor specifies the name, symbol, and initial minting quantity of our token.
    • Ownable.sol: The ownable contract allows us to manage access to our contract functions in a simplified manner. Whenever a smart contract inherits the ownable contract, the deployer of the contract is set as its owner.
    • ERC721Holder.sol: This is an interesting one. When we finally deploy our smart contracts, we will need to approve the Rewards.sol contract to be able to transfer NFTs from Fuel.sol to its own address. That is what we call ‘staking an NFT‘. But for a contract to be able to call the transfer function on an NFT from another contract, it must support the IERC721Receiver interface. The ERC721Holder.sol is simply a convenient implementation of the interface. We just need to inherit the contract into our main Rewards contract. If that was a bit confusing, we recommend you take a look at what interfaces do in Solidity. A look at OpenZeppelin’s official docs will be helpful too.
    • IERC721.sol: This is simply the interface that ERC721 contract is based on. Since our Fuel smart contract is an ERC721 implementation, we can initiate an instance of the Fuel contract in our Rewards contract by wrapping its address inside the IERC721 interface. As you can see in the constructor, we will be providing the address of the Fuel contract to the Rewards contract while deployment.
  • The constructor simply accepts an address argument and initializes an ERC20 token with the symbol “RWD“.
  • The staking function is quite simple. We call the safeTransferFrom() function on the instance of the Fuel contract, which transfers an NFT of a specific token ID to the Rewards contract. Once this is done, we update three mappings in our contract. These mappings keep track of the owners of the NFTs, the specific timestamps when they were staked, and a list of valid token IDs.
    Please note that the function will only work if two conditions are fulfilled-
    • The token ID being passed as the parameter should actually be a valid, minted token ID.
    • And, the address calling the function should actually own the NFT of that specific token ID.
  • The function calculateRewards() simply calculates the amount of tokens we need to give to the owner of a staked NFT based on the amount of time an NFT has been staked for. The function calculates the amount for only valid token IDs, which we keep track of with the help of isStaked[] mapping.
  • The unstake function will allow an owner to unstake an NFT after a minimum of 7 days. The function mints an exact amount of RWD tokens and transfers them to the NFT owner, following which the NFT is returned to the original owner. All data corresponding to that token ID is then deleted.
  • Please feel free to tinker around with the logic of the contract. You can calculate rewards based on other parameters. You can change the minimum staking time or perhaps remove it altogether.

That was quite a lot to digest, but we now have our staking smart contract ready to go.

To compile smart contracts in Truffle, we need to run the compile command in our terminal:

truffle compile

This command compiles all the smart contracts in the contracts directory, and generates artifacts for them in the build/contracts directory. Subsequent invocations of the command will only compile those smart contracts that have undergone any changes since the last compile command.

Setting up a dotenv file

We will soon be deploying and verifying the NFT staking smart contract. To do so, we need three main values:

  • RPC_URL: Truffle needs to connect to a blockchain node to deploy our contract. We can connect to a blockchain using an HTTPS endpoint for a particular blockchain. For a fast and reliable connection to a live network, we recommend you use an endpoint from a provider like Chainstack that is run and maintained by a dedicated team behind the scenes. If you have never set up a node using Chainstack before, feel free to go through this tutorial from our blog.
  • PRIVATE_KEY: Truffle needs access to a wallet’s private key to be able to sign transactions and send them to the blockchain. In this tutorial, we will be deploying our smart contract to the Gnosis mainnet, so make sure to use the private key of a wallet that has some xDAI tokens from the Gnosis chain.
  • API_KEY: We will be verifying our deployed smart contract from Truffle’s command line. To do so, we need an API key from Gnosisscan. You can get started by signing up here.

It is possible to plugin these values directly into the truffle-config file to get up and running, but we would highly discourage you from doing so. Putting these values directly into the config file can lead to you pushing them onto a remote directory which could potentially compromise your funds. We can instead export our sensitive data securely using the dotenv package. That is what we will do now.

Once you have these three variables, open the terminal, make sure it is pointing to the root directory, and run:

npm i dotenv

This will install the dotenv package into your local directory.
Again, in your terminal run:

touch .env

This will create an empty .env file inside your project. Paste the following code inside the file:

PRIVATE_KEY="0x0000000000000000000"
CHIADO_RPC_URL=https://123-456-789
GNOSIS_RPC_URL=https://987-654-321
API_KEY=ABCDEFGHIJKLMNOPQRSTUVWXYZ

Simply replace the mock data with your actual keys and URLs. Note that we are putting two different RPC URLs into our dotenv file. This is because we will define two different networks in our truffle-config file, one for the Gnosis mainnet, and the other for the Chiado testnet.

Once you are ready, save the dotenv file.

Setting up truffle-config

We need to install two packages to power our truffle-config file:

  • HD wallet provider: We need to define a provider to sign transactions through truffle. A provider in Truffle is basically a combination of a private key/mnemonic with an RPC URL. These two values used together can sign transactions to a particular blockchain from a particular wallet address.
  • truffle-plugin-verify: Truffle allows us to install a variety of officially supported plugins into our project. This plugin can be used to verify smart contracts on certain blockchains.

To install these plugins, run the following commands in your terminal:

npm i @truffle/hdwallet-provider
npm i truffle-plugin-verify

Now that you have all the required plugins, go to your truffle-config file and delete everything inside of it.
Paste the following code into the file:

require('dotenv').config();
const HDWalletProvider = require("@truffle/hdwallet-provider");
module.exports = {
  networks: {
    GnosisMainnet: {
      provider: () => {
        return new HDWalletProvider([process.env.PRIVATE_KEY], `${process.env.GNOSIS_RPC_URL}`);
      },
      network_id: 100,
    },
    ChiadoTestnet: {
      provider: () =>
        new HDWalletProvider([process.env.PRIVATE_KEY], `${process.env.CHIADO_RPC_URL}`),
      network_id: 10200,
    },
  },
  compilers: {
    solc: {
      version: "0.8.17",
      settings: {
        optimizer: {
          enabled: true,
          runs: 50,
        },
      },
    },
  },
  plugins: ['truffle-plugin-verify'],
  api_keys: {
    gnosisscan: `${process.env.API_KEY}`
  },
  
};

Let us go over the major configurations we define here:

  • Networks: We can define multiple networks inside truffle-config. Each network has to contain at least one provider along with a chain ID. A new provider can be declared as a new instance of the hdwallet-provider object that contains at least one private key and an HTTPS or WSS URL corresponding to a particular blockchain.
  • The compiler object supports various tweaks to the Solidity compiler. Here we simply define the Solidity version and ask the compiler to optimize the contract deployment for an expected 50 calls throughout its lifetime. You can read more about the supported configurations on Truffle’s reference page.
  • plugins: Truffle allows us to use a variety of plugins that can be installed as NPM dependencies. In this tutorial, we are using only one plugin as explained previously.

Deploying and verifying our smart contract

Before moving further, please note that Gnosis mainnet recently went through the merge. If you don’t know what the merge is, or what it meanse for the Gnosis chain, you can refer to this article from our blog.

gnosis-merge-banner
Gnosis Merge

We have now configured everything in our truffle-config file. To actually deploy a contract in Truffle, however, we need to write a basic deployment script.
Go to the migrations directory, and create a file named 1_deploy_contract.js. Inside the file, paste the following code:

const Fuel = artifacts.require("Fuel");
const Rewards = artifacts.require("Rewards");
module.exports = async function (deployer) {
  await deployer.deploy(Fuel);
  await deployer.deploy(Rewards, Fuel.address);
  await Fuel.setApprovalForAll(Rewards.address, true);
};

This is a very simple deployment script. We import the ABIs of our smart contracts from the artifacts folder. After that, we call the deploy function on all three of the contracts in an async function.
Please note that the NFTstake contract requires the addresses of both Fuel.sol and Rewards.sol in its constructor. Therefore, those not only have to be deployed first, but we also need to pass their respective addresses in NFTstake’s parameters.

Please note that Truffle has a very specific naming convention for its migration files. We strongly recommend you follow this naming convention or the deployment script may not work as intended. You can read more about these conventions on Truffle’s docs.

To deploy our smart contracts to a live network, open the terminal and run:

truffle migrate --network GnosisMainnet

This will engage 1_deploy_Contract.js and deploy the contract to the Gnosis mainnet as specified in truffle-config.js.

Now onto the verification part. Please note that the Gnosisscan API key that we added to our project does not support contract verification on the Chiado testnet. We can only use it to verify contracts on the Gnosis mainnet.
To verify a smart contract on the Gnosis mainnet using a Gnosisscan API key in Truffle:

  1. Deploy a smart contract to Gnosis mainnet using the migrate command with the corresponding network flag as shown above.
  1. Run the following command in your terminal after your contract has been deployed successfully.
truffle run verify <CONTRACT_NAME>@<CONTRACT_ADDRESS> --network GnosisMainnet

This will verify your smart contract on the Gnosis network and return a success message. Your smart contract is now verified!
Verify both the smart contracts on the Mainnet, and you can now interact with them right from Gnosisscan explorer.

If you don’t have xDAI tokens from the mainnet, you can still follow through the tutorial by deploying the contracts to the Chiado testnet. It is just that you won’t be able to use Truffle to verify your contracts. You can still verify them by going to the Chiado explorer. If you need some testnet xDAI tokens, you can get them from the official faucet

Logic flow for staking an NFT

Both of our smart contracts are now deployed and verified on the Gnosis Mainnet. In this last section, we will briefly go over the logic flow for the staking mechanism:

  1. Deploy Fuel.sol. Now Copy Fuel.sol‘s contract address and pass it to the constructor of Rewards.sol. Now deploy the second smart contract. We already completed this step through the migrations script.
  1. Call the safe mint function and mint an NFT each to at least 2 unique addresses. You can check that the NFTs were minted properly by calling the currentTokenID() and ownerOf() functions.
  1. Now go to Rewards.sol and try staking an NFT to it by calling the stake function with a token ID parameter. It won’t work.
    Why? Because we never approved the Rewards.sol as an eligible recipient of a Fuel NFT.
    When you have an ERC721 NFT, you as the owner can hand over custody of your NFT by calling the setApprovalForAll() function, thus approving a particular address as an authorized handler of your NFTs. Go to Fuel.sol and approve the Rewards contract as a recipient by calling the function.
  1. Now all the NFTs of a particular wallet can be staked at Rewards.sol. Please note that only the NFTs of those addresses can be staked at the Rewards contract, those who authorized the rewards contract as an approved handler by calling the setApprovalForAll()function.
  1. If you want to stake NFTs from 10 different addresses at Rewards.sol, you will have to call the setApprovalForAll() from those 10 different addresses before staking their NFTs with the rewards contract.

Conclusion

In this tutorial, you learned how to compile, test and deploy smart contracts using the Truffle framework.
We deployed an NFT staking smart contract on Gnosis mainnet through a migrations script in Truffle and then verified it using an API key from Gnosisscan.
You can go to our blog for more tutorials like this.

Have you already explored what you can achieve with Chainstack? Get started for free today.

Chainstack uses cookies to provide you with a secure and
personalized experience on its website. Learn more.