How to Implement an NFT Staking Contract

Many NFT projects are looking for ways to bring utility to users and incentivize long-term holding or participation. In essence, a staking contract holds tokens and tracks a few different variables to their respective owners.

Here, I’ll go over a simple implementation for staking NFTs. For this demo, I’ve created three different contracts. One is for the NFT, another for rewards, and finally, one is for the staking contract itself.

The contracts below are relatively simple but will demonstrate most of the core functionality you’d have to implement.

Setup:

My first step was to create a no-frills ERC20 token called RewardsToken:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";


contract RewardsToken is ERC20 {

    constructor(string memory _name, string memory _symbol) ERC20(_name, _symbol) 
    {
            _mint(msg.sender, 1000000 * 10**decimals());
    }
}

Ever since the Azuki mint introduced the ERC721A standard, I’ve been impressed with the gas savings for users minting multiple NFTs. Until proven otherwise, I’ll be using this standard. That’s because implementing the new standard hardly differs from OpenZeppelin’s ERC721 and behaves as expected.

The details of why and how ERC721As are better are outside the scope of this post, but for those that are curious, I’d suggest checking out Chiru Labs on Github.

function mint(uint256 quantity) external payable { 
// _safeMint's second argument now takes in a quantity, not a tokenId.
        require(quantity > 0, "Quantity cannot be zero");
        uint totalMinted = totalSupply();
        require(quantity <= MAX_MINTS, "Cannot mint that many at once");
        require(totalMinted.add(quantity) < MAX_SUPPLY, "Not enough tokens left to mint");
        require(msg.value >= (mintRate * quantity), "Insufficient funds sent");
        _safeMint(msg.sender, quantity);
    }

Without digging into it too far, the main implementation difference is that _safeMint() now takes an address and the number of tokens to mint.

Staking:

For every NFT this contract receives, we need to keep track of some information, and for the contract to receive ERC721 tokens, it must inherit IERC721Receiver from OpenZeppelin.
I’ve created a struct with three properties: tokenId, amount, and timestamp.

contract StakingContract is IERC721Receiver {
    DemoNFT parentNFT;
    RewardsToken rewardsToken;

    struct Stake {
        uint256 tokenId;
        uint256 amount;
        uint256 timestamp;
    }

    // map staker address to stake details
    mapping (address => Stake) public stakes;

    // map staker total staking time
    mapping (address => uint256) public stakingTime;

    constructor(DemoNFT _parentNFT, RewardsToken _rewardsToken) {
        parentNFT = _parentNFT;
        rewardsToken = _rewardsToken;
    }
}

Additionally, I created a few events that will relay information to our frontend whenever someone adds or removes tokens from the contract:


    event NFTStaked(address owner, uint256 tokenId, uint256 value);
    event NFTUnstaked(address owner, uint256 tokenId, uint256 value);
    event Claimed(address owner, uint256 amount);

Finally the stake/unstake functions:


    function stake(uint256 _tokenId, uint256 _amount) external {
        stakes[msg.sender] = Stake(_tokenId, _amount, block.timestamp);
        parentNFT.safeTransferFrom(msg.sender, address(this), _tokenId);
        emit NFTStaked(msg.sender, _tokenId, _amount);
    }

    function unstake() external {
        parentNFT.safeTransferFrom(address(this), msg.sender, stakes[msg.sender].tokenId);
        stakingTime[msg.sender] += (block.timestamp - stakes[msg.sender].timestamp);
        emit NFTUnstaked(msg.sender, stakes[msg.sender].tokenId, stakes[msg.sender].amount);
        delete stakes[msg.sender];
    }

Note: In order for the transfer function to succeed, you must have the NFT token owner setApprovalForAll(true). You can’t do this from within the staking contract because the contract is not the owner of the tokens at the time stake() is called.

The most common way of doing this is by prompting the user to sign the approval transaction from your front end.

A Simple Way to Implement an NFT Staking Contract

You can implement staking in multiple ways but the core tenants remain the same. You’ll need a staking token, data structures to track stakes, functions to create and remove stakes, and finally a reward. Your rewards system will be very dependent on your project’s intended tokenomics, so I’ve withheld calculating and distributing rewards from this demo.

The final piece that you’ll have to decide is what to do with the staked tokens now that they are in possession of your smart contract.