Mint and Transfer ERC721/NFTS gaslessly with Biconomy SDK

May 18, 2024
value
read time
Mint and Transfer ERC721/NFTS gaslessly with Biconomy SDK

💡 In this tutorial, we will interact with ERC721 Contract deployed on polygon Mumbai. We will mint multiple NFTs in our smart account address batched together and sent via paymaster. Once minted, we will send a transaction of transferring this from our address to another user's address. We shall be using Hardhat,React JS and Vite in this project.

1.Creating and configuring a Hardhat project
2. Create a smart contract of ERC721 and add SVG code
3. Deploying and verifying your contract to testnet
4. Setting up biconomy dashboard to setup gasless transactions
5. Configuring frontend for the dApp using Vita and React
6. Allow social logins for this dApp
7. Setting backend logic for minting and transferring NFTs by Token ID

Creating and configuring a Hardhat project

  • Create a new folder with the desired name of your project dApp in VS Code .
  • Once created, create a sub folder in root directory of your project named Contract. Once created, navigate to this directory in your terminal and then do cd contract.
  • Then, start installing hardhat for your project in that folder.  Follow the following commands : npm install --save-dev hardhat
  • In the same directory where you installed Hardhat run: npx hardhat
  • You will be given three options to create your project, elect Create a TypeScript project with your keyboard and hit enter.
  • This will create a folder within contract folder and have  hardhat.config.ts as well.
  • Now, we would install @nomicfoundation/hardhat-toolbox, which has everything you need for developing smart contracts.
  • npm install --save-dev @nomicfoundation/hardhat-toolbox

/

If you select Create a Typescript project, a simple project creation wizard will ask you some questions like if you want to have .gitignore etc.  After that, the wizard will create some directories and files and install the necessary dependencies.
The initialized project has the following structure:
  • contracts/
  • scripts/
  • test/
  • hardhat.config.ts
These are the default paths for a Hardhat project
contracts/ is where the source files for your contracts should be.
test/ is where your tests should go.
scripts/ is where simple automation scripts go.

Creating Smart Contract for ERC721 Token

Once you have got the hardhat installed, then we will have add our ERC721 Contract . For this, as per the structure, go to cd contract and then go to cd contracts

  • Name your contract with .sol extension just like I have done here.

The structure of the file is going to look like such -

License

At the start of your smart contract, you can declare a license. In this case, we will leave this as UNLICENSED as we are not too concerned about what this code is used for.


// SPDX-License-Identifier: UNLICENSED
Mention Compiler Version

pragma solidity ^0.8.1; 

Make sure the solidity version declared in the contract matches your compiler version

Let's work on our contract :

1. We would need to import these contracts in our main contract.

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

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";

import "@openzeppelin/contracts/access/Ownable.sol";

import "@openzeppelin/contracts/utils/Counters.sol";

import "@openzeppelin/contracts/utils/Base64.sol";

import "@openzeppelin/contracts/utils/Strings.sol";

  1. ERC721.sol: This contract implements the ERC721 standard, which defines a non-fungible token (NFT) interface. NFTs are unique tokens that represent ownership or proof of authenticity of a specific asset, such as digital artwork or collectibles.
  2. ERC721Enumerable.sol: This contract extends the ERC721 standard and adds functionality to enumerate and iterate over a collection of NFTs. It allows for querying the total supply, getting the NFT at a specific index, and finding the index of a given NFT token ID.
  3. ERC721URIStorage.sol: This contract extends the ERC721 standard and provides functionality to store and retrieve the metadata associated with each NFT token. The metadata typically includes information such as the name, description, and image URL of the NFT.
  4. Ownable.sol: This contract provides a basic access control mechanism, allowing the contract owner to restrict certain functions to be executed only by the owner. It adds a modifier that can be applied to functions, ensuring that only the owner can call those functions.
  5. Counters.sol: This contract provides a utility for managing and tracking numerical counters. It includes functions to increment and decrement counter values and retrieve the current count.
  6. Base64.sol: This contract provides functions to encode and decode data using the Base64 encoding scheme. Base64 encoding is commonly used to convert binary data into a textual format for transmission or storage.
  7. Strings.sol: This contract provides utility functions for manipulating strings, such as concatenation, length calculation, and converting integers to strings.

2. Writing NFT contract:


contract NFTContract is ERC721, ERC721Enumerable, ERC721URIStorage {
    using Counters for Counters.Counter;
    using Strings for uint256;

    Counters.Counter private _tokenIdCounter;

    constructor() ERC721("NFTContract", "MTK") {}

//Adding the code for SVG file for our NFT

function generateSVG(uint256 tokenId) public pure returns (string memory) {
        bytes memory svg = abi.encodePacked(

'<svg xmlns="http://www.w3.org/2000/svg" width="624" height="625" viewBox="0 0 624 625" fill="none">''<g id="Biconomy_NFT">'
'<rect width="624" height="624" transform="translate(0 0.5)" fill="#FF4E17"/>'
'<rect id="Rectangle 349" x="16.127" y="20.4658" width="591.746" height="584.068" rx="30" fill="#1D1D1D" stroke="white" stroke-width="2"/>'
'<path id="Rectangle 311" d="M117 509L117 178C117 165.297 127.297 155 140 155L470 155C482.703 155 493 165.297 493 178L493 509C493 521.703 482.703 532 470 532L140 532C127.297 532 117 521.703 117 509Z" fill="#D9D9D9" fill-opacity="0.81" stroke="white" stroke-width="2"/>'
'<g id="Mask group">'
'<mask id="mask0_1577_6136" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="183" y="289" width="391" height="278">'
'<path id="Rectangle 312" d="M207 565.736C194.297 565.736 184 555.439 184 542.736L184 313C184 300.297 194.297 290 207 290L549.899 290C562.602 290 572.899 300.297 572.899 313L572.899 542.736C572.899 555.439 562.602 565.736 549.899 565.736L207 565.736Z" fill="#D9D9D9" stroke="white" stroke-width="2"/>'
'</mask>'
'<g mask="url(#mask0_1577_6136)">''<g id="Group 8816">''<g id="Group 8813">''<g id="Group">'
'<path id="Vector" d="M571.619 532.768L582.79 529.216L586.958 590.785L506.821 553.406L571.619 532.768Z" fill="#FF4E17"/>'
'<path id="Vector_2" d="M477.332 634.92L506.82 553.407L586.957 590.786L477.92 668.101L467.314 662.619L477.332 634.92Z" fill="#FF4E17"/>'
'<path id="Vector_3" d="M396.131 427.337L339.873 557.599L338.172 463.545L396.131 427.337Z" fill="#FF4E17"/>'
'<path id="Vector_4" d="M463.932 523.147L496.292 539.873L459.396 654.474L351.189 598.547L463.932 523.147Z" fill="#FF4E17"/>'
'<path id="Vector_5" d="M456.797 387.131L463.931 523.147L351.187 598.547L348.92 559.563L353.006 549.507L399.976 433.955L404.71 422.322L456.797 387.131Z" fill="#FF4E17"/>'
'<path id="Vector_6" d="M456.795 387.131L577.195 449.361L581.258 521.677L496.288 539.872L463.929 523.147L456.795 387.131Z" fill="#FF4E17"/>'
'<path id="Vector_7" d="M496.292 539.873L581.261 521.678L571.618 532.769L506.82 553.407L477.332 634.921L459.396 654.475L496.292 539.873Z" fill="black"/>'
'<path id="Vector_8" d="M396.134 427.335L398.998 433.545L399.978 433.955L353.008 549.507L352.761 549.403L339.875 557.597L396.134 427.335Z" fill="black"/>'
'</g>''<g id="Group_2">'
'<path id="Vector_9" d="M351.189 598.547L459.396 654.474L496.292 539.873L463.932 523.147L351.189 598.547Z" stroke="white" stroke-width="2" stroke-miterlimit="10"/>'
'<path id="Vector_10" d="M351.187 598.547L348.92 559.563L353.006 549.507L399.976 433.955L404.71 422.322L456.797 387.131L463.931 523.147" stroke="white" stroke-width="2" stroke-miterlimit="10"/>'
'<path id="Vector_11" d="M496.288 539.872L581.258 521.677L577.195 449.361L456.795 387.131" stroke="white" stroke-width="2" stroke-miterlimit="10"/>'
'<path id="Vector_12" d="M396.131 427.337L338.172 463.545L339.873 557.599L396.131 427.337Z" stroke="white" stroke-width="2" stroke-miterlimit="10"/>'
'<path id="Vector_15" d="M477.332 634.92L467.314 662.619L477.92 668.101L586.957 590.786L582.79 529.217L571.619 532.769L506.82 553.407L477.332 634.92Z" stroke="white" stroke-width="2" stroke-miterlimit="10"/>'
'</g></g></g></g></g></g>'
'<style>.base { fill: black; font-family: Gill Sans; font-size: 30px; }</style>'
'<style>.base2 { fill: white; font-family: Gill Sans; font-size: 60px; }</style>'
'<text x="50%" y="15%" class="base2" dominant-baseline="middle" text-anchor="middle">',"BICONOMY", '</text>'
'<text x="50%" y="45%" class="base" dominant-baseline="middle" text-anchor="middle">',"NFT Minted with", '</text>'
'<text x="50%" y="58%" class="base" dominant-baseline="middle" text-anchor="middle">'," TokenID # ", getNFTID(tokenId) , '</text>'
'</svg>'

        );

        return string(abi.encodePacked("data:image/svg+xml;base64,", Base64.encode(svg)));
    }

Here is an explanation to what is happening in the above solidity code.

  1. Counters: Counters.Counter is a struct defined in the Counters.sol library. It is used to keep track of the current token ID. _tokenIdCounter is an instance of this struct and is declared as private.
  2. Constructor: The constructor function is called when the contract is deployed. It initializes the contract and sets the name and symbol of the NFT contract using the ERC721 constructor.
  3. generateSVG: This function takes a tokenId as input and returns an SVG image as a string. It concatenates multiple SVG elements using string interpolation and encodes the resulting SVG as a Base64 string.

Now let's work on next set of our solidity code:


  function getNFTID(uint256 tokenId) public pure returns (string memory) {
        return tokenId.toString();
    }

    function getTokenURI(uint256 tokenId) public pure returns (string memory) {
       bytes memory dataURI = abi.encodePacked(
            '{',
                '"name": "B-NFT #', tokenId.toString(), '",',
                '"description": "Gasless NFT Minted With Biconomy SDK",',
                '"image": "', generateSVG(tokenId), '"',
            '}'
        );

        return string(abi.encodePacked("data:application/json;base64,", Base64.encode(dataURI)));
    }

  1. getNFTID: This function takes a tokenId as input and converts it to a string using the toString() function from the Strings library. It returns the resulting string.
  2. getTokenURI: This function takes a tokenId as input and returns the token's metadata URI as a string. It generates the JSON metadata object using the generateSVG function and concatenates it with other metadata information, such as the name and description. The resulting JSON metadata object is encoded as a Base64 string.

Now, we will add our mint , transferNFT and getOwnedNFT function :


function mint() public {
        _tokenIdCounter.increment();
        uint256 tokenId = _tokenIdCounter.current();
        _safeMint(msg.sender, tokenId);
        _setTokenURI(tokenId, getTokenURI(tokenId));
    }

    function transferNFT(address recipient, uint256 tokenId) public {
        require(_exists(tokenId), "ERC721: token does not exist");
        require(_isApprovedOrOwner(msg.sender, tokenId), "ERC721: transfer caller is not owner nor approved");
        require(ownerOf(tokenId) == msg.sender, "ERC721: transfer of token that is not own");
        require(recipient != address(0), "ERC721: transfer to the zero address");

        safeTransferFrom(msg.sender, recipient, tokenId);
    }

    function getOwnedNFTs() public view returns (uint256[] memory) {
        uint256[] memory ownedNFTs = new uint256[](balanceOf(msg.sender));
        for (uint256 i = 0; i < balanceOf(msg.sender); i++) {
            ownedNFTs[i] = tokenOfOwnerByIndex(msg.sender, i);
        }
        return ownedNFTs;
    }
  1. mint: This function is used to mint a new NFT. It increments the _tokenIdCounter and retrieves the current tokenId. Then, it calls _safeMint (inherited from ERC721) to create a new NFT with the given tokenId. Finally, it sets the token's URI using _setTokenURI, passing the tokenId and the result of getTokenURI.
  2. transferNFT: This function is used to transfer ownership of an NFT to a specified recipient. It checks various conditions, such as the existence of the token, the caller's ownership or approval, and the validity of the recipient's address. If all conditions are met, it calls safeTransferFrom (inherited from ERC721) to perform the transfer.
  3. getOwnedNFTs: This function returns an array of token IDs representing the NFTs owned by the caller. It retrieves the balance of the caller's NFTs using balanceOf, creates an array with the appropriate length, and populates it with the token IDs using tokenOfOwnerByIndex.

Now, our last step would be adding overrides functions:



function _beforeTokenTransfer(address from, address to, uint256 tokenId, uint256 batchSize)
    internal
    override(ERC721, ERC721Enumerable)
{
    super._beforeTokenTransfer(from, to, tokenId, batchSize);
}

function _burn(uint256 tokenId) internal override(ERC721, ERC721URIStorage) {
    super._burn(tokenId);
}

function tokenURI(uint256 tokenId)
    public
    view
    override(ERC721, ERC721URIStorage)
    returns (string memory)
{
     return super.tokenURI(tokenId);
}

function supportsInterface(bytes4 interfaceId)
   public
   view
   override(ERC721, ERC721Enumerable, ERC721URIStorage)
   returns (bool)
 {
   return super.supportsInterface(interfaceId);
 }
}

Overrides and Interactions with Inherited Contracts: The contract includes several functions that override or interact with functions inherited from the ERC721, ERC721Enumerable, and ERC721URIStorage contracts. These functions include _beforeTokenTransfer, _burn, tokenURI, and supportsInterface. These overrides ensure the correct behavior and compatibility with the underlying contracts.

The  override keyword is used in Solidity to explicitly indicate that a function is intended to override a function from a base contract. Let's go through each of these functions:

  1. _beforeTokenTransfer(address from, address to, uint256 tokenId, uint256 batchSize) internal override(ERC721, ERC721Enumerable): This function overrides the _beforeTokenTransfer function defined in both the ERC721 and ERC721Enumerable contracts. It is called internally before a token transfer occurs. By overriding this function, you can add custom logic that should be executed before a token is transferred.
  2. _burn(uint256 tokenId) internal override(ERC721, ERC721URIStorage): This function overrides the _burn function defined in both the ERC721 and ERC721URIStorage contracts. It is responsible for burning (deleting) a token with the given tokenId. By overriding this function, you can add additional actions or restrictions before the token is burned.
  3. tokenURI(uint256 tokenId) public view override(ERC721, ERC721URIStorage) returns (string memory): This function overrides the tokenURI function defined in both the ERC721 and ERC721URIStorage contracts. It retrieves the metadata URI associated with a given token tokenId. By overriding this function, you can customize how the metadata URI is generated or retrieved.

Here is the entire contract code :


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.1;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/utils/Base64.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

contract NFTContract is ERC721, ERC721Enumerable, ERC721URIStorage {
    using Counters for Counters.Counter;
    using Strings for uint256;

    Counters.Counter private _tokenIdCounter;

    constructor() ERC721("NFTContract", "MTK") {}

    function generateSVG(uint256 tokenId) public pure returns (string memory) {
        bytes memory svg = abi.encodePacked(

'<svg xmlns="http://www.w3.org/2000/svg" width="624" height="625" viewBox="0 0 624 625" fill="none">''<g id="Biconomy_NFT">'
'<rect width="624" height="624" transform="translate(0 0.5)" fill="#FF4E17"/>'
'<rect id="Rectangle 349" x="16.127" y="20.4658" width="591.746" height="584.068" rx="30" fill="#1D1D1D" stroke="white" stroke-width="2"/>'
'<path id="Rectangle 311" d="M117 509L117 178C117 165.297 127.297 155 140 155L470 155C482.703 155 493 165.297 493 178L493 509C493 521.703 482.703 532 470 532L140 532C127.297 532 117 521.703 117 509Z" fill="#D9D9D9" fill-opacity="0.81" stroke="white" stroke-width="2"/>'
'<g id="Mask group">'
'<mask id="mask0_1577_6136" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="183" y="289" width="391" height="278">'
'<path id="Rectangle 312" d="M207 565.736C194.297 565.736 184 555.439 184 542.736L184 313C184 300.297 194.297 290 207 290L549.899 290C562.602 290 572.899 300.297 572.899 313L572.899 542.736C572.899 555.439 562.602 565.736 549.899 565.736L207 565.736Z" fill="#D9D9D9" stroke="white" stroke-width="2"/>'
'</mask>'
'<g mask="url(#mask0_1577_6136)">''<g id="Group 8816">''<g id="Group 8813">''<g id="Group">'
'<path id="Vector" d="M571.619 532.768L582.79 529.216L586.958 590.785L506.821 553.406L571.619 532.768Z" fill="#FF4E17"/>'
'<path id="Vector_2" d="M477.332 634.92L506.82 553.407L586.957 590.786L477.92 668.101L467.314 662.619L477.332 634.92Z" fill="#FF4E17"/>'
'<path id="Vector_3" d="M396.131 427.337L339.873 557.599L338.172 463.545L396.131 427.337Z" fill="#FF4E17"/>'
'<path id="Vector_4" d="M463.932 523.147L496.292 539.873L459.396 654.474L351.189 598.547L463.932 523.147Z" fill="#FF4E17"/>'
'<path id="Vector_5" d="M456.797 387.131L463.931 523.147L351.187 598.547L348.92 559.563L353.006 549.507L399.976 433.955L404.71 422.322L456.797 387.131Z" fill="#FF4E17"/>'
'<path id="Vector_6" d="M456.795 387.131L577.195 449.361L581.258 521.677L496.288 539.872L463.929 523.147L456.795 387.131Z" fill="#FF4E17"/>'
'<path id="Vector_7" d="M496.292 539.873L581.261 521.678L571.618 532.769L506.82 553.407L477.332 634.921L459.396 654.475L496.292 539.873Z" fill="black"/>'
'<path id="Vector_8" d="M396.134 427.335L398.998 433.545L399.978 433.955L353.008 549.507L352.761 549.403L339.875 557.597L396.134 427.335Z" fill="black"/>'
'</g>''<g id="Group_2">'
'<path id="Vector_9" d="M351.189 598.547L459.396 654.474L496.292 539.873L463.932 523.147L351.189 598.547Z" stroke="white" stroke-width="2" stroke-miterlimit="10"/>'
'<path id="Vector_10" d="M351.187 598.547L348.92 559.563L353.006 549.507L399.976 433.955L404.71 422.322L456.797 387.131L463.931 523.147" stroke="white" stroke-width="2" stroke-miterlimit="10"/>'
'<path id="Vector_11" d="M496.288 539.872L581.258 521.677L577.195 449.361L456.795 387.131" stroke="white" stroke-width="2" stroke-miterlimit="10"/>'
'<path id="Vector_12" d="M396.131 427.337L338.172 463.545L339.873 557.599L396.131 427.337Z" stroke="white" stroke-width="2" stroke-miterlimit="10"/>'
'<path id="Vector_15" d="M477.332 634.92L467.314 662.619L477.92 668.101L586.957 590.786L582.79 529.217L571.619 532.769L506.82 553.407L477.332 634.92Z" stroke="white" stroke-width="2" stroke-miterlimit="10"/>'
'</g></g></g></g></g></g>'
'<style>.base { fill: black; font-family: Gill Sans; font-size: 30px; }</style>'
'<style>.base2 { fill: white; font-family: Gill Sans; font-size: 60px; }</style>'
'<text x="50%" y="15%" class="base2" dominant-baseline="middle" text-anchor="middle">',"BICONOMY", '</text>'
'<text x="50%" y="45%" class="base" dominant-baseline="middle" text-anchor="middle">',"NFT Minted with", '</text>'
'<text x="50%" y="58%" class="base" dominant-baseline="middle" text-anchor="middle">'," TokenID # ", getNFTID(tokenId) , '</text>'
'</svg>'

        );

        return string(abi.encodePacked("data:image/svg+xml;base64,", Base64.encode(svg)));
    }

    function getNFTID(uint256 tokenId) public pure returns (string memory) {
        return tokenId.toString();
    }

    function getTokenURI(uint256 tokenId) public pure returns (string memory) {
       bytes memory dataURI = abi.encodePacked(
            '{',
                '"name": "B-NFT #', tokenId.toString(), '",',
                '"description": "Gasless NFT Minted With Biconomy SDK",',
                '"image": "', generateSVG(tokenId), '"',
            '}'
        );

        return string(abi.encodePacked("data:application/json;base64,", Base64.encode(dataURI)));
    }

    function mint() public {
        _tokenIdCounter.increment();
        uint256 tokenId = _tokenIdCounter.current();
        _safeMint(msg.sender, tokenId);
        _setTokenURI(tokenId, getTokenURI(tokenId));
    }

    function transferNFT(address recipient, uint256 tokenId) public {
        require(_exists(tokenId), "ERC721: token does not exist");
        require(_isApprovedOrOwner(msg.sender, tokenId), "ERC721: transfer caller is not owner nor approved");
        require(ownerOf(tokenId) == msg.sender, "ERC721: transfer of token that is not own");
        require(recipient != address(0), "ERC721: transfer to the zero address");

        safeTransferFrom(msg.sender, recipient, tokenId);
    }

    function getOwnedNFTs() public view returns (uint256[] memory) {
        uint256[] memory ownedNFTs = new uint256[](balanceOf(msg.sender));
        for (uint256 i = 0; i < balanceOf(msg.sender); i++) {
            ownedNFTs[i] = tokenOfOwnerByIndex(msg.sender, i);
        }
        return ownedNFTs;
    }
    // The following functions are overrides required by Solidity.
function _beforeTokenTransfer(address from, address to, uint256 tokenId, uint256 batchSize)
    internal
    override(ERC721, ERC721Enumerable)
{
    super._beforeTokenTransfer(from, to, tokenId, batchSize);
}

function _burn(uint256 tokenId) internal override(ERC721, ERC721URIStorage) {
    super._burn(tokenId);
}

function tokenURI(uint256 tokenId)
    public
    view
    override(ERC721, ERC721URIStorage)
    returns (string memory)
{
     return super.tokenURI(tokenId);
}

function supportsInterface(bytes4 interfaceId)
   public
   view
   override(ERC721, ERC721Enumerable, ERC721URIStorage)
   returns (bool)
 {
   return super.supportsInterface(interfaceId);
 }
}

Deploying and verifying your contract to testnet

1. Creating .env file inside the contract folder :

In order to deploy and verify our above smart contract, we would need to create .env file in the below format.


PRIVATE_KEY=xxxx
ETHERSCAN_API_KEY=xxxx
POLYSCAN_API_KEY=xxxx

In PRIVATE_KEY enter the private key of your Metamask wallet address on Polygon Mumbai Network that would cover the initial cost of deployment. Make sure to have some funds in matic in the same wallet. You can use faucets available here -  https://faucet.polygon.technology

POLYGONSCAN_API_KEY enter the API Key that you can get from https://polygonscan.com/myapikey You can go to your Account Dashboard, click on the navigation tab labelled API-KEYs.From there, you may click on Add to create a new key and give a name to your project. Each PolygonScan account is limited to creating 3 keys at any one time.

Let's move on to next steps.

2. Deploy.ts for NFT Contract

First, we would be deploying our smart contract on polygon Mumbai network and verify on the testnet. For this, we would need to look at our deploy.ts first which is in the sub folder of scripts.


import { ethers } from "hardhat";


async function main() {
  const NFTContract = await ethers.getContractFactory("NFTContract");
  const nft = await NFTContract.deploy();

  await nft.deployed();

  console.log("NFT Contract deployed to:", nft.address,"for using biconomy sdk for this demo");
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});
  1. The main function is defined as an asynchronous function, which means it can use the await keyword to wait for promises to resolve. This function will be the entry point for the script.
  2. Inside the main function, it calls ethers.getContractFactory("NFTContract") to retrieve the contract factory for a smart contract named "NFTContract". This assumes that you have a contract named "NFTContract" defined in your project.
  3. The script then deploys the contract by calling nft.deploy(). This deploys an instance of the contract to the Ethereum network.
  4. After deploying the contract, it waits for the deployment to complete by calling await nft.deployed().
  5. Finally, it logs a message to the console, indicating that the NFT contract has been deployed and displays the contract's address. The message also mentions the usage of the Biconomy SDK for the demo, indicating that the Biconomy SDK is being used with this contract.
  6. The main function is invoked by calling main().catch((error) => { ... }). This ensures that any errors that occur during the execution of the main function are caught and logged to the console. The process.exitCode = 1 line sets the exit code of the script to 1 in case of an error.

Execute this command in your terminal to deploy the contract :


  npx hardhat run scripts/deploy.ts --network mumbai

You should get something like this with a contract address deployed on testnet.


"NFT Contract deployed to:",_contract address_,"for using biconomy sdk for this demo"

This is what I get in terminal when I run the above command :

3. Verifying the smart contract post deployment :

Next step would be verifying the contract address, use the following command -


  npx hardhat verify --network mumbai <your-contract-address>

This will verify your contract on Polygonscan and give you a verified address and a link in terminal which you can check on polygon scan. It would look something like this :

My contract address has already been verified !

Now, we can add this verified contract address on Biconomy dashboard. Let's see how.

Setting up Biconomy Dashboard for Gasless Transactions

We will use the verified address in the dashboard for whitelising the address which will allow us to set up a gas tank and do gasless transactions.

  1. Visit Biconomy's new dashboard
  2. You will get three sign up options, you can use magic link or github to login.
  3. Once logged in, you will be able to see this -
  1. Now we will go on ADD NEW PAYMASTER to setup for the contract.
  2. You will see a popup to add the NAME and CHAIN . Add the name of paymaster and the chain on which you have deployed the contract.
  1. The next step is going to be adding or whitelisting our smart contracts. All you need to do is go to “Policies” and click on “Add you first smart contract”. After this the ABI will be fetched automatically. You can choose the WRITE METHODS as well and it will show under active contract.
  1. After you have succesfully setup your paymaster details, you will see API KEY on dashboard which will be need to add in our .env file for frontend later.
  2. Now, click on gas tank. You can recharge the gas tank from your EOA/Metamask wallet. Make sure to connect it to dashboard, enter the amount you would like to refill with and then click on depost. Finally, you will get a popup to add the matic funds in gas tank.

Voila ! You have activated the gas tank that will be sponsoring the gas fees for your users.

You can check out this documentation for more information around Biconomy Dashboard - https://docs-gasless.biconomy.io/guides/biconomy-dashboard

Configuring  Frontend  for your dApp using Vite

For this project we are using Vita + React. For this, we will go to root repository, and in the terminal, enter the following commands to install site for setting up frontend-

With NPM


npm create vite@latest

With Yarn:


yarn create vite

You can check out this documentation as well - https://vitejs.dev/guide/#scaffolding-your-first-vite-project

Install the following dependencies:


npm install @biconomy/core-types @biconomy/smart-account @biconomy/web3-auth ethers@5.7.2

We will use these tools to build out our front end. In addition, lets also install the following:


npm install @esbuild-plugins/node-globals-polyfill rollup-plugin-polyfill-node stream-browserify

Once we have installed all the required dependencies, let's configure our vite.config.ts file.


import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill'

export default defineConfig({
  plugins: [react()],
  optimizeDeps: {
    esbuildOptions: {
      define: {
        global: "globalThis",
      },
      plugins: [
        NodeGlobalsPolyfillPlugin({
            buffer: true
        })
    ]
    },
  },
  resolve: {
    alias: {
      process: "process/browser",
      stream: "stream-browserify",
      util: "util",
    },
  },

  
});

Now, let's create another .env file where we would be adding the Biconomy dApp API key from dashboard, and the verified contract address as well.


VITE_BICONOMY_API_KEY=
VITE_NFT_CONTRACT_ADDRESS=

Allowing Social Logins for the dApp using Biconomy SDK

Now, that we are done with configuration of frontend, let's move on to some actual use case of having social logins via web3auth and on top of that having smart contract wallet implementation.

For this, I am creating a separate sub folder called src inside the root folder of dapp. This folder will contain tsx and css files.

Here is how the structure looks like for me :

  1. Main.tsx :

This code sets up a basic React application with a logo image, headings, an 'App' component, and a footer. It likely represents a web page or a part of a web application that demonstrates the usage of the Biconomy SDK for batched transactions.


import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import logo from '../vite-project/public/logo.svg';

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
   <div>
      <img src={logo} alt="logo" style={{ width: '60px', height: '60px'}}/> 

      <h2 style={{fontFamily:"sans-serif",paddingBottom:"20px", fontSize:"40px"}}>Batched transactions with Biconomy SDK</h2>

      <h2 style={{fontFamily:"sans-serif"}}>Mint and Transfer NFTs as Batched Transaction</h2>
    
<br></br>
      <App />
      <h2 style={{paddingTop:'250px',fontFamily:"monospace"}}>Built with Biconomy SDK</h2>
    </div>
  </React.StrictMode>,
)

Allowing Social Logins for the dApp using Biconomy SDK

To add social logins, let's go to App.tsx file in the src folder.

We would import the following :


import './App.css'
import "@biconomy/web3-auth/dist/src/style.css"
import { useState, useEffect, useRef } from 'react'
import SocialLogin from "@biconomy/web3-auth"
import { ChainId } from "@biconomy/core-types";
import { ethers } from 'ethers'
import SmartAccount from "@biconomy/smart-account";
import Minter from "./components/minter.tsx";

We are using the css files from app.css and then from web3auth package. You can make changes on frontend according to your requirements.

Here is the breakdown for the imports we are using :

useState, useEffect, useRef: React hooks for managing component state and lifecycle.

SocialLogin from @biconomy/web3-auth: A class from Biconomy SDK that allows you to leverage Web3Auth for social logins.

ChainId from @biconomy/core-types: An enumeration of supported blockchain networks.

ethers: A library for interacting with Ethereum.

SmartAccount from @biconomy/smart-account: A class from Biconomy SDK that will allow us to create smart accounts and interact with our contract with them.

Moving on, lets define our state variables:


  const [smartAccount, setSmartAccount] = useState<SmartAccount | null>(null)
  const [interval, enableInterval] = useState(false)
  const sdkRef = useRef<SocialLogin | null>(null)
  const [loading, setLoading] = useState<boolean>(false)
  const [provider, setProvider] = useState<any>(null);
  const [acct, setAcct] = useState<any>(null) 

1. const [smartAccount, setSmartAccount] = useState<SmartAccount | null>(null)

- This line declares a state variable called smartAccount using the useState hook. It initializes the state with a value of null and provides a setter function setSmartAccount to update the state value. The type annotation <SmartAccount | null> indicates that the state variable can hold values of type SmartAccount or null.

2. const [interval, enableInterval] = useState(false)

- This line declares another state variable called interval and its setter function enableInterval. It initializes the state with a value of false. This state variable is likely used to control the enabling or disabling of an interval mechanism.

3. const sdkRef = useRef<SocialLogin | null>(null)

- This line declares a ref called sdkRef using the useRef hook. The ref is initialized with a value of null, and its type annotation <SocialLogin | null> suggests that it can reference an instance of SocialLogin or null.

4. const [loading, setLoading] = useState<boolean>(false)

- This line declares a state variable called loading and its setter function setLoading. It initializes the state with a value of false. This state variable is likely used to indicate the loading state of a component or operation.

5. const [provider, setProvider] = useState<any>(null)

- This line declares a state variable called provider and its setter function setProvider. It initializes the state with a value of null. The type annotation any indicates that the state variable can hold values of any type.

6. const [acct, setAcct] = useState<any>(null)

- This line declares a state variable called acct and its setter function setAcct. It initializes the state with a value of null. Similar to the previous line, the type annotation any suggests that this state variable can hold values of any type.

Here we have some state that will be used to track our smart account that will be generated with the sdk, an interval that will help us with checking for login status, a loading state, provider state to track our web3 provider and a reference to our Social login sdk.

Next let's add a useEffect hook:


useEffect(() => {

let configureLogin:any

if (interval) {

configureLogin = setInterval(() => {

if (!!sdkRef.current?.provider) {

setupSmartAccount()

clearInterval(configureLogin)

}

}, 1000)

}

}, [interval])

This use effect will be triggered after we open our login component, which we'll create a function for shortly. Once a user opens the component it will check if a provider is available and run the functions for setting up the smart account.

Now our login function:


async function login() {

if (!sdkRef.current) {

const socialLoginSDK = new SocialLogin()

const signature1 = await socialLoginSDK.whitelistUrl('http://localhost:5173/')

await socialLoginSDK.init({

chainId: ethers.utils.hexValue(ChainId.POLYGON_MUMBAI).toString(),

network: "testnet",

whitelistUrls: {

'http://localhost:5173/': signature1, //or 'http://localhost:3000/': signature1,

}

})

sdkRef.current = socialLoginSDK

}

if (!sdkRef.current.provider) {

sdkRef.current.showWallet()

enableInterval(true)

} else {

setupSmartAccount()

}

}

The login function is an asynchronous function that handles the login flow for the application. Here's a step-by-step explanation:

  1. SDK Initialization: The function first checks if the sdkRef object (which is a reference to the Biconomy SDK instance) is null. If it is, it means that the SDK is not yet initialized. In this case, it creates a new instance of SocialLogin (a Biconomy SDK component), whitelists a local URL ( [http://localhost:5173/](<http://localhost:5173/>) ), and initializes the SDK with the Polygon Mumbai testnet configuration and the whitelisted URL. After initialization, it assigns the SDK instance to sdkRef.current.
  2. Provider Check: After ensuring the SDK is initialized, the function checks if the provider of the sdkRef object is set. If it is not, it means the user is not yet logged in. It then shows the wallet interface for the user to login using sdkRef.current.showWallet(), and enables the interval by calling enableInterval(true). This interval (setup in a useEffect hook elsewhere in the code) periodically checks if the provider is available and sets up the smart account once it is.
  3. Smart Account Setup: If the provider of sdkRef is already set, it means the user is logged in. In this case, it directly sets up the smart account by calling setupSmartAccount().

In summary, the login function handles the SDK initialization and login flow. It initializes the SDK if it's not already initialized, shows the wallet interface for the user to login if they're not logged in, and sets up the smart account if the user is logged in.

Note - It is important to make sure that you update the whitelist URL with your production url when you are ready to go live!

Now, let's setup our smart account using the SDK :


async function setupSmartAccount() {
    if (!sdkRef?.current?.provider) return
    sdkRef.current.hideWallet()
    setLoading(true)
    const web3Provider = new ethers.providers.Web3Provider(
      sdkRef.current.provider
    )
    setProvider(web3Provider)
    try {
      const smartAccount = new SmartAccount(web3Provider, {
        activeNetworkId: ChainId.POLYGON_MUMBAI,
        supportedNetworksIds: [ChainId.POLYGON_MUMBAI],
        networkConfig: [
          {
            chainId: ChainId.POLYGON_MUMBAI,
            dappAPIKey: import.meta.env.VITE_BICONOMY_API_KEY, //Your dApp API KEY from Biconomy Dashboard
          },
        ],
      })
      const acct = await smartAccount.init()
      setAcct(acct)
      setSmartAccount(smartAccount)
      setLoading(false)
    } catch (err) {
      console.log('error setting up smart account... ', err)
    }
  }

The setupSmartAccount function is an asynchronous function used to initialize a smart account with Biconomy and connect it with the Web3 provider. Here's a step-by-step explanation of what it does:

  1. Check Provider Availability: The function first checks if the provider of the sdkRef object is available. If not, it immediately returns and the rest of the function is not executed. The sdkRef object refers to the Biconomy SDK instance that was stored using the useRef React Hook.
  2. Hide Wallet: If the provider is available, it hides the wallet interface using sdkRef.current.hideWallet().
  3. Set Loading Status: It then sets the loading state to true by calling setLoading(true). This could be used in the UI to show a loading spinner or other loading indicators.
  4. Create Web3 Provider: It creates a new Web3 provider using the ethers library and the provider from sdkRef. This provider is then saved in the state by calling setProvider(web3Provider).
  5. Create and Initialize Smart Account: It then creates a new SmartAccount object using the Web3 provider and a configuration object. This configuration object sets the active network to Polygon Mumbai, the supported networks, and the network configuration, including the chain ID and the Biconomy API key. After creating the smart account, it initializes it by calling smartAccount.init(). This is an asynchronous operation, hence the await keyword.
  6. Save Smart Account and Update Loading Status: After the smart account is initialized, it is saved in the state by calling setSmartAccount(smartAccount). The loading status is then set to false by calling setLoading(false).
  7. Error Handling: If any error occurs during the creation or initialization of the smart account, it is caught in the catch block and logged to the console.

So, in summary, the setupSmartAccount function checks the availability of the Biconomy provider, hides the wallet interface, sets up a Web3 provider, creates and initializes a smart account, and then saves this account and the Web3 provider in the state. If any error occurs during this process, it is logged to the console.

Finally our last function will be a logout function:


const logout = async () => {
    if (!sdkRef.current) {
      console.error('Web3Modal not initialized.')
      return
    }
    await sdkRef.current.logout()
    sdkRef.current.hideWallet()
    setSmartAccount(null)
    enableInterval(false)
  }

  console.log({ acct , provider})

The logout function is an asynchronous function that handles the logout flow for the application. Here's a breakdown of its functionality:

  1. Check SDK Initialization: The function first checks if the sdkRef object (which is a reference to the Biconomy SDK instance) is null. If it is, it means that the SDK is not yet initialized. In this case, it logs an error message and returns immediately without executing the rest of the function.
  2. Logout and Hide Wallet: If the SDK is initialized, it logs the user out by calling sdkRef.current.logout(). This is an asynchronous operation, hence the await keyword. It then hides the wallet interface by calling sdkRef.current.hideWallet().
  3. Clear Smart Account and Interval: After logging the user out and hiding the wallet, it clears the smart account by calling setSmartAccount(null), and disables the interval by calling enableInterval(false).

In summary, the logout function checks if the SDK is initialized, logs the user out and hides the wallet if it is, and then clears the smart account and disables the interval. If the SDK is not initialized, it logs an error message and does not execute the rest of the function.

Finally, you can add this component in your App.tsx to show on load on frontend.


return (
    <div>
      
    
      {
        !smartAccount && !loading && <button onClick={login}>Login to get your SCW address </button>
      }
      {
        loading && <p>Loading account details...</p>
      }
      {
        !!smartAccount && (
          <div className="buttonWrapper">
            <h3>Smart account address: {smartAccount.address} </h3>
            <p></p>

            <Minter smartAccount= {smartAccount} provider={provider} acct={acct} />
            <br></br>
            <br></br>
            <button onClick={logout}>Logout</button>
          </div>
        )
      }
     <p>
      <br></br>
      <br></br>
      <a href="https://docs.biconomy.io/introduction/overview" target="_blank" className="read-the-docs">
  Click here to check out the docs
    </a></p>

    </div>
  )
}

The entire code would look something like this :


import './App.css'
import "@biconomy/web3-auth/dist/src/style.css"
import { useState, useEffect, useRef } from 'react'
import SocialLogin from "@biconomy/web3-auth"
import { ChainId } from "@biconomy/core-types";
import { ethers } from 'ethers'
import SmartAccount from "@biconomy/smart-account";
import Minter from "./components/minter.tsx";



export default function App() {
  const [smartAccount, setSmartAccount] = useState<SmartAccount | null>(null)
  const [interval, enableInterval] = useState(false)
  const sdkRef = useRef<SocialLogin | null>(null)
  const [loading, setLoading] = useState<boolean>(false)
  const [provider, setProvider] = useState<any>(null);
  const [acct, setAcct] = useState<any>(null);

  useEffect(() => {
    let configureLogin:any
    if (interval) {
      configureLogin = setInterval(() => {
        if (!!sdkRef.current?.provider) {
          setupSmartAccount()
          clearInterval(configureLogin)
        }
      }, 1000)
    }
  }, [interval])

  async function login() {
    if (!sdkRef.current) {
      const socialLoginSDK = new SocialLogin()
      const signature1 = await socialLoginSDK.whitelistUrl('http://localhost:3000/')
      await socialLoginSDK.init({
        chainId: ethers.utils.hexValue(ChainId.POLYGON_MUMBAI).toString(),
        network: "testnet",
        whitelistUrls: {
          'http://localhost:3000/': signature1,
        }
      })
      sdkRef.current = socialLoginSDK
    }
    if (!sdkRef.current.provider) {
      sdkRef.current.showWallet()
      enableInterval(true)
    } else {
      setupSmartAccount()
    }
  }

  async function setupSmartAccount() {
    if (!sdkRef?.current?.provider) return
    sdkRef.current.hideWallet()
    setLoading(true)
    const web3Provider = new ethers.providers.Web3Provider(
      sdkRef.current.provider
    )
    setProvider(web3Provider)
    try {
      const smartAccount = new SmartAccount(web3Provider, {
        activeNetworkId: ChainId.POLYGON_MUMBAI,
        supportedNetworksIds: [ChainId.POLYGON_MUMBAI],
        networkConfig: [
          {
            chainId: ChainId.POLYGON_MUMBAI,
            dappAPIKey: import.meta.env.VITE_BICONOMY_API_KEY,
          },
        ],
      })
      const acct = await smartAccount.init()
      setAcct(acct)
      setSmartAccount(smartAccount)
      setLoading(false)
    } catch (err) {
      console.log('error setting up smart account... ', err)
    }
  }
  

  const logout = async () => {
    if (!sdkRef.current) {
      console.error('Web3Modal not initialized.')
      return
    }
    await sdkRef.current.logout()
    sdkRef.current.hideWallet()
    setSmartAccount(null)
    enableInterval(false)
  }

  console.log({ acct , provider})

 
  
  return (
    <div>
      
    
      {
        !smartAccount && !loading && <button onClick={login}>Login to get your SCW address </button>
      }
      {
        loading && <p>Loading account details...</p>
      }
      {
        !!smartAccount && (
          <div className="buttonWrapper">
            <h3>Smart account address: {smartAccount.address} </h3>
            <p></p>

            <Minter smartAccount= {smartAccount} provider={provider} acct={acct} />
            <br></br>
            <br></br>
            <button onClick={logout}>Logout</button>
          </div>
        )
      }
     <p>
      <br></br>
      <br></br>
      <a href="https://docs.biconomy.io/introduction/overview" target="_blank" className="read-the-docs">
  Click here to check out the docs
    </a></p>

    </div>
  )
}

To give you an overview, this is how your localhost is going to look like -

This is how the login popup would look like when you click on “Login to get your scw address”

Now, let's move on to setting up our minter.tsx and enabling gasless transactions.

Setting backend logic for minting and transferring NFTs by Token ID

Working on vite-env.d.ts configuration :

One important configuration to understand in vite-env.d.ts that we are using TypeScript interfaces for the ImportMetaEnv and ImportMeta objects, which are used to provide type information for the environment variables available in a Vite application.


/// 

interface ImportMetaEnv {
    readonly VITE_CUSTOM_ENV_VARIABLE: string
  }
  
  interface ImportMeta {
    readonly env: ImportMetaEnv
  }
  1. ImportMetaEnv is an interface that represents the shape of the environment variables object (import.meta.env). In this example, it includes a single property VITE_CUSTOM_ENV_VARIABLE of type string. You can add more properties to represent additional environment variables used in your application.
  2. ImportMeta is an interface that represents the shape of the import.meta object. It includes a single property env of type ImportMetaEnv, which provides access to the environment variables defined in the ImportMetaEnv interface.

These interfaces are used to provide type information for the environment variables and import.meta object in a Vite application. By using these interfaces, TypeScript can provide autocompletion, type checking, and type inference for environment variables accessed through import.meta.env in your code.

Now, let move to our src file, we have a sub-folder called component, where we will add our minter.tsx file.

This is where the actual magic begins !

We will divide the transactions in two parts :

  1. Minting ERC721 tokens to smart contract wallet address - this function will be known as mintNftAsBalance Function
  2. Minting and Transferring NFTs from the owned NFTs by TokenID to a different address- this function will be known as mintandTransferNFT Function

In both approach, we will check our NFTs shown on OpenSea post minting.

To begin with :

As we would be calling import abi from "../utils/abi.json", we would need to create a separate folder called utils, under which we add abi.json.

Import the following :


import "../minter.css"
import React, { useState, useEffect } from "react";
import { ethers } from "ethers";
import abi from "../utils/abi.json";
import SmartAccount from "@biconomy/smart-account";

Load the interface props:


interface Props {
  smartAccount: SmartAccount;
  provider: any;
  acct: any;
}

Now build the components:


const Minter: React.FC<Props> = ({ smartAccount, provider, acct }) => {
  const [nftContract, setNFTContract] = useState<any>(null);
  const [nftCount, setNFTCount] = useState<number>(0);
  const [recipientAddress, setRecipientAddress] = useState<string>("");
  const [nftIndexes, setNFTIndexes] = useState<number[]>([]);
  const [nftsAsBalanceCount, setNftsAsBalanceCount] = useState(0);
  const [availableTokenIds, setAvailableTokenIds] = useState([]);
 

Calling the Verified contract address of NFT Contract  :


  const nftAddress = import.meta.env.VITE_NFT_CONTRACT_ADDRESS;

useEffect(() => {
    getNFTCount();
    getNFTIndexes();

  }, []);

  useEffect(() => {
    setNftsAsBalanceCount(nftCount);
  }, [nftCount]);

This useEffect hook is called when the component mounts (initial render) due to the empty dependency array []. It means that it will only run once, similar to the behavior of componentDidMount in class components.

>>First useEffect :

Inside the useEffect hook, two functions are called: getNFTCount() and getNFTIndexes(). These functions are likely defined elsewhere in the component or imported from other modules. The purpose of these functions is not apparent from the given code snippet, but they are likely responsible for fetching data related to NFT counts and indexes.

>>Second useEffect :

Inside the useEffect hook, the setNftsAsBalanceCount function is called with the nftCount value as an argument. This suggests that setNftsAsBalanceCount is a setter function for a state variable called nftsAsBalanceCount. It updates the nftsAsBalanceCount state variable with the current value of nftCount whenever nftCount changes.


const getNFTCount = async () => {

    const contract = new ethers.Contract(nftAddress, abi, provider);
    setNFTContract(contract);

    const count = await contract.balanceOf(smartAccount.address);

    setNFTCount(count.toString());
  };

The code snippet defines an asynchronous function getNFTCount() that performs the following actions:

  1. Creates a new instance of the ethers.Contract class using the provided nftAddress, abi, and provider variables. The nftAddress likely represents the address of an NFT contract on the Ethereum network, abi represents the contract's ABI (Application Binary Interface), and provider is an instance of the ethers.js provider.
  2. Sets the NFT contract instance by calling setNFTContract(contract). The setNFTContract function is likely a setter function for a state variable that holds the NFT contract instance.
  3. Calls the balanceOf() function on the NFT contract, passing smartAccount.address as an argument. This function likely retrieves the balance (number of NFTs) associated with the smartAccount address.
  4. Sets the NFT count by calling setNFTCount(count.toString()). The setNFTCount function is likely a setter function for a state variable that holds the count of NFTs. The count is first converted to a string before being set.

Overall, the getNFTCount() function retrieves the NFT count associated with a specific address (smartAccount.address) by interacting with an NFT contract deployed at nftAddress. It sets the NFT contract instance and the NFT count using respective setter functions, which are likely used to update the component's state and trigger re-renders.


const getNFTIndexes = async () => {
    const contract = new ethers.Contract(nftAddress, abi, provider);
    setNFTContract(contract);

    const countdata = await contract.balanceOf(smartAccount.address);
    const nftIndexes = [];

    for (let i = 0; i < countdata; i++) {
      const index = await contract.tokenOfOwnerByIndex(smartAccount.address, i);
      nftIndexes.push(index.toString());
    }

    setNFTIndexes(nftIndexes);
  }

The code snippet defines an asynchronous function getNFTIndexes() that performs the following actions:

  1. Creates a new instance of the ethers.Contract class using the provided nftAddress, abi, and provider variables. This is similar to what was done in the getNFTCount() function.
  2. Sets the NFT contract instance by calling setNFTContract(contract). This is likely a setter function for a state variable that holds the NFT contract instance.
  3. Calls the balanceOf() function on the NFT contract, passing smartAccount.address as an argument. This retrieves the balance (number of NFTs) associated with the smartAccount address and stores it in the countdata variable.
  4. Initializes an empty array nftIndexes to store the token indexes associated with the smartAccount address.
  5. Starts a loop using a for statement, iterating from 0 to countdata (exclusive).
  6. Inside the loop, it calls the tokenOfOwnerByIndex() function on the NFT contract, passing smartAccount.address and i as arguments. This retrieves the token index (unique identifier) for the NFT owned by the smartAccount at the current index i.
  7. Converts the index to a string and pushes it to the nftIndexes array using nftIndexes.push(index.toString()).
  8. After the loop completes, it sets the NFT indexes by calling setNFTIndexes(nftIndexes). This is likely a setter function for a state variable that holds the array of NFT indexes.

Overall, the getNFTIndexes() function retrieves the indexes of NFTs owned by a specific address (smartAccount.address) by interacting with an NFT contract deployed at nftAddress. It sets the NFT contract instance, iterates over the NFTs, retrieves their indexes, and stores them in the nftIndexes array. Finally, it sets the NFT indexes using a setter function, which is likely used to update the component's state and trigger re-renders.

1. Minting ERC721 tokens to smart contract wallet address :


const mintNftAsBalance = async () => {
    try {

 
      const contract = new ethers.Contract(nftAddress, abi, provider);
      
      setNFTContract(contract);

      // Mint two NFTs
      const mintTx1 = await contract.populateTransaction.mint();
      const mintTx2 = await contract.populateTransaction.mint();
 

      const tx1 = {
        to: nftAddress,
        data: mintTx1.data,
      };
     
      
      const tx2 = {
        to: nftAddress,
        data: mintTx2.data,
      };
  
      const mintResponse = await smartAccount.sendTransactionBatch({
        transactions: [tx1, tx2],
      });

   
      const txReciept = await mintResponse.wait();
      console.log('Tx Hash', txReciept.transactionHash);

   
      console.log({ mintResponse });

      getNFTCount();

    } catch (error) {
      console.log(error);
    }
  };
  1. Creates a new instance of the ethers.Contract class using the provided nftAddress, abi, and provider variables. This is similar to what was done in the previous functions.
  2. Sets the NFT contract instance by calling setNFTContract(contract). This is likely a setter function for a state variable that holds the NFT contract instance.
  3. Calls the populateTransaction.mint() function on the NFT contract twice to generate two separate minting transactions. The mint() function is likely responsible for creating a new NFT.
  4. Creates tx1 and tx2 objects that represent Ethereum transactions to mint the NFTs. Each transaction specifies the destination address (nftAddress) and the transaction data obtained from the corresponding minting transaction (mintTx1.data and mintTx2.data).
  5. Uses the smartAccount to send a batch of transactions to mint the NFTs. It calls the sendTransactionBatch() function on the smartAccount object and passes an arrayof transactions ([tx1, tx2]) as the transactions parameter.
  6. Waits for the response of the minting transaction by calling await mintResponse.wait(). This ensures that the transaction is mined and included in a block.
  7. Prints the transaction hash of the minting transaction by logging console.log('Tx Hash', txReciept.transactionHash).
  8. Logs the mintResponse object to the console, which likely contains information about the minting transaction and its status.
  9. Calls the getNFTCount() function, which is likely responsible for updating the NFT count by fetching the latest count after the minting operation.
  10. If any errors occur during the minting process, they are caught and logged to the console using console.log(error).

Overall, the mintNftAsBalance() function mints two NFTs by interacting with an NFT contract deployed at nftAddress. It generates the minting transactions, sends them as a batch using the smartAccount object, waits for the transaction confirmation, and updates the NFT count by calling getNFTCount().

2. Minting and Transferring NFTs from the owned NFTs by TokenID to a different address


const mintAndTransferNft = async () => {
    try {
      if (!recipientAddress) {
        console.log("Recipient address not specified");
        return;
      }
  
      const contract = new ethers.Contract(nftAddress, abi, provider);
      setNFTContract(contract);
  
      const nftSelect = document.getElementById("nft-select") as HTMLSelectElement;
      const selectedNftId = parseInt(nftSelect.value, 10);
  
      console.log('Balance:', nftsAsBalanceCount);
      console.log('Selected NFT ID:', selectedNftId);
  
      // Mint a new NFT
      const mintTx = await contract.populateTransaction.mint();
  
      // Transfer the selected NFT to the recipient address
      const transferTx = await contract.populateTransaction[
        "safeTransferFrom(address,address,uint256)"
      ](smartAccount.address, recipientAddress, selectedNftId);
  
      const tx1 = {
        to: nftAddress,
        data: mintTx.data,
      };
      const tx2 = {
        to: nftAddress,
        data: transferTx.data,
      };
  
      const transferResponse = await smartAccount.sendTransactionBatch({
        transactions: [tx1, tx2],
      });
      await transferResponse.wait();
  
      console.log({ transferResponse });

The code snippet provided includes a function named mintAndTransferNft. Here's an explanation of what each function call inside this function is doing:

  1. ethers.Contract: This is a constructor function from the ethers.js library. It creates a new instance of a smart contract by providing the contract address (nftAddress), the contract ABI (abi), and a provider object (provider).
  2. setNFTContract: This is a function that sets the NFTContract state variable. It is likely a state setter function provided by a React hook, updating the value of NFTContract with the newly created contract instance.
  3. document.getElementById("nft-select"): This retrieves an HTML element with the id "nft-select" from the DOM. It is likely an HTML select element used to select an NFT.
  4. parseInt(nftSelect.value, 10): This converts the value of the selected option in the HTML select element to an integer. It is likely used to retrieve the selected NFT ID.
  5. console.log('Balance:', nftsAsBalanceCount): This logs the value of nftsAsBalanceCount to the console, displaying the NFT balance.
  6. console.log('Selected NFT ID:', selectedNftId): This logs the value of selectedNftId to the console, displaying the selected NFT ID.
  7. contract.populateTransaction.mint(): This is a function call that generates a transaction object for minting a new NFT. It uses the mint() function of the contract object.
  8. contract.populateTransaction["safeTransferFrom(address,address,uint256)"]: This is a dynamic function call that generates a transaction object for transferring an NFT to a recipient address. It uses the safeTransferFrom function of the contract object and provides the sender address (smartAccount.address), recipient address (recipientAddress), and selected NFT ID (selectedNftId) as arguments.
  9. smartAccount.sendTransactionBatch: This is a function call that sends a batch of transactions using the sendTransactionBatch method of the smartAccount object. It takes an array of transaction objects (tx1 and tx2) as an argument.
  10. transferResponse.wait(): This waits for the completion of the transaction batch sent in the previous step. It returns a promise that resolves when the transaction is confirmed.
  11. console.log({ transferResponse }): This logs the transferResponse object to the console. It likely contains information about the transaction batch's response, such as transaction hashes or status.

These functions collectively perform the minting and transfer of an NFT, utilizing a smart contract, selected NFT ID, and recipient address.

Let's add a popup message for our successful transaction :


const popup = document.createElement('div');
      popup.textContent = `Transfer successful. Hash: ${transferResponse.hash}`;
      popup.classList.add('popup');
      document.body.appendChild(popup);
  
      // show the popup box
      popup.style.display = 'block';
  
      // set a timeout to remove the popup box after 5 seconds (5000 milliseconds)
      setTimeout(() => {
        popup.remove();
      }, 5000);

Moving on, lets add another set of function to update the balance and the drop down menu of the NFT IDs that is available for transfer :


// Update the NFT count as balance
      const count = await contract.balanceOf(smartAccount.address);
      setNFTCount(count.toString());
  
      // Update the token ID list in the drop-down menu
      const balance = parseInt(count.toString());
      const updatedNFTIndexes = [];
      if (selectedNftId <= balance) {
        for (let i = 0; i < balance; i++) {
          const index = await contract.tokenOfOwnerByIndex(smartAccount.address, i);
          updatedNFTIndexes.push(index.toString());
        }
      }
      setNFTIndexes(updatedNFTIndexes);
    } catch (error) {
      console.log(error);
    }
  };

To breakdown why and what is happening here -

  1. const count = await contract.balanceOf(smartAccount.address);: This line retrieves the balance of NFTs owned by the smartAccount.address address. It calls the balanceOf function of the contract object, passing the account address as an argument. The returned count is stored in the count variable.
  2. setNFTCount(count.toString());: This line converts the count to a string and sets it as the new value for the NFTCount state variable. It is likely a state setter function provided by a React hook, updating the NFT count in the component's state.
  3. const balance = parseInt(count.toString());: This line converts the count to an integer using parseInt and stores it in the balance variable.
  4. const updatedNFTIndexes = [];: This line initializes an empty array updatedNFTIndexes that will store the updated token ID list.
  5. if (selectedNftId <= balance) { ... }: This condition checks if the selectedNftId is less than or equal to the balance. If true, it means the selected NFT ID is within the range of the owned NFTs.
  6. Inside the if block, there is a for loop that iterates over the NFTs owned by the smartAccount.address. It starts from i = 0 and continues until i is less than balance.
  7. Within the for loop, const index = await contract.tokenOfOwnerByIndex(smartAccount.address, i); retrieves the token ID at the given i index using the tokenOfOwnerByIndex function of the contract object. The token ID is stored in the index variable.
  8. updatedNFTIndexes.push(index.toString()); converts the index to a string and adds it to the updatedNFTIndexes array.
  9. After the loop ends, setNFTIndexes(updatedNFTIndexes); sets the updatedNFTIndexes array as the new value for the NFTIndexes state variable. It is likely another state setter function provided by a React hook, updating the token ID list in the component's state.
  10. The code is wrapped in a try-catch block to catch any errors that occur during the process. If an error occurs, it will be logged to the console using console.log(error).

Note :

In this flow is that the reason why we are minting from the smart account address and adding it as a balance is because we need to hold the tokens in our smart account address that we are trying to send or transfer, otherwise we may fail the transaction and get Call gas estimation error . This means that, until and unless you don't have funds/tokens, you can't send an operation from the smart account for same.

  1. Checking the minted and transfer NFTs on OpenSea testnet :

const nftURL = `https://testnets.opensea.io/${smartAccount.address}`
const transferURL = `https://testnets.opensea.io/${recipientAddress}`
  1. nftURL is a string that represents the URL of the OpenSea marketplace for the NFT associated with the smartAccount.address. It uses template literals to interpolate the smartAccount.address into the URL. This URL is likely used to display the NFT on OpenSea for the account associated with smartAccount.address.
  2. transferURL is a string that represents the URL of the OpenSea marketplace for the recipient address (recipientAddress). Similar to nftURL, it uses template literals to interpolate the recipientAddress into the URL. This URL is likely used to display the recipient's OpenSea profile or collection on OpenSea.

The URLs are constructed using the base URL for the OpenSea testnet marketplace (https://testnets.opensea.io/) followed by the corresponding Ethereum addresses (smartAccount.address and recipientAddress) to navigate to the respective pages on OpenSea.

  1. Calling the rest of the code in Minter.tsx :

We are having two Container Box elements which are conditionally rendered based on the value of nftsAsBalanceCount :

const sortedIndexes = nftIndexes.sort((a, b) => a - b);

    return (
      <div className="container">
        <div className="container-box-1">
          <br></br>
          <br></br>
  
          {nftsAsBalanceCount >= 2 ? (
            <>
              <div>
                <p>You have {nftCount} NFTs in your balance</p>
                <br></br>
                <button onClick={mintNftAsBalance}>Mint NFTs as balance</button>
                <br></br>
                <br></br>
                <button style={{ marginLeft: '10px' }} onClick={() => window.open(nftURL, '_blank')}>View minted on SCW address on OpenSea</button>
              </div>
            </>
          ) : (
            <p>Please Mint more NFTs to add as balance for transfer
              <br></br>
              <button onClick={mintNftAsBalance}>Mint NFTs as balance</button>
  
            </p>
          )}
  1. The nftIndexes array is sorted in ascending order using the sort() method. The sorting is based on comparing the elements an and b using the arrow function (a, b) => a - b. This will result in sortedIndexes containing the sorted array of NFT indexes.
  2. In container-box-1, the content is conditionally rendered based on the condition nftsAsBalanceCount >= 2. Here's how it works:
  3. If nftsAsBalanceCount is greater than or equal to 2, the condition is true. In this case, the first block is rendered.
  • The paragraph <p> element displays the message "You have {nftCount} NFTs in your balance," where {nftCount} is the value of the nftCount variable.
  • There are two buttons rendered: "Mint NFTs as balance" and "View minted on SCW address on OpenSea." The onClick event handlers are assigned to the corresponding functions mintNftAsBalance and () => window.open(nftURL, '_blank').
  1. If nftsAsBalanceCount is less than 2, the condition is false. In this case, the second block is rendered.
  • The paragraph <p> element displays the message "Please Mint more NFTs to add as balance for transfer."
  • There is a button rendered with the label "Mint NFTs as balance," and the onClick event handler is assigned to the mintNftAsBalance function.

{nftsAsBalanceCount >= 2 && (
          <div className="container-box-2">

            <div>
              <label htmlFor="recipient-address">Recipient Address : </label>
              <input
                id="recipient-address"
                type="text"
                value={recipientAddress}
                onChange={(e) => setRecipientAddress(e.target.value)}
              />
            </div>
            <br></br>
            <br></br>
            <div className="selectnft">
              Select an NFT ID to Transfer : <select id="nft-select" value={selectedNftId} onChange={(e) => setSelectedNftId(e.target.value)}>
                {sortedIndexes.map((index) => (
                <option key={index} value={index}>
                  {index}
                  </option>
                  ))}
                  </select> </div>
           <p>
              <button onClick={mintAndTransferNft}>Mint and Transfer NFT</button>
              <br></br>
              <br></br>

              <button style={{ marginLeft: '10px' }} onClick={() => window.open(transferURL, '_blank')}>View Transferred NFTs on OpenSea</button>
            </p>
          </div>
        )}
      </div></div>
  );
}


export default Minter;

In container-box-2, the content is conditionally rendered based on the condition nftsAsBalanceCount >= 2. Here's how it works:

  1. If nftsAsBalanceCount is greater than or equal to 2, the condition is true. In this case, the entire block within parentheses is rendered.

Inside the div with the className="container-box-2", there are two sections:

  • The first section includes an input field labeled "Recipient Address." It is an <input> element that binds its value to the recipientAddress state variable and updates it using the onChange event handler.
  • The second section includes a dropdown menu labeled "Select an NFT ID to Transfer." It is a <select> element that binds its value to the selectedNftId state variable and updates it using the onChange event handler. The dropdown options are dynamically generated from the sortedIndexes array.

There is a paragraph <p> element with two buttons:

  • "Mint and Transfer NFT" button: It triggers the mintAndTransferNft function when clicked.
  • If nftsAsBalanceCount is less than 2, the condition is false, and container-box-2 is not rendered.

To put it together, this is how the code would look like, additionally there is explanation to whats happening in these two conditions :


const sortedIndexes = nftIndexes.sort((a, b) => a - b);

  return (
    <div className="container">
      <div className="container-box-1">
        <br></br>
        <br></br>

        {nftsAsBalanceCount >= 2 ? (
          <>
            <div>
              <p>You have {nftCount} NFTs in your balance</p>
              <br></br>
              <button onClick={mintNftAsaBalance}>Mint NFTs as balance</button>
              <br></br>
              <br></br>
              <button style={{ marginLeft: '10px' }} onClick={() => window.open(nftURL, '_blank')}>View minted on SCW address on OpenSea</button>
            </div>
          </>
        ) : (
          <p>Please Mint more NFTs to add as balance for transfer
            <br></br>
            <button onClick={mintNftAsBalance}>Mint NFTs as balance</button>

          </p>
        )}

        {nftsAsBalanceCount >= 2 && (
          <div className="container-box-2">

            <div>
              <label htmlFor="recipient-address">Recipient Address : </label>
              <input
                id="recipient-address"
                type="text"
                value={recipientAddress}
                onChange={(e) => setRecipientAddress(e.target.value)}
              />
            </div>
            <br></br>
            <br></br>
            <div className="selectnft">
              Select an NFT ID to Transfer : <select id="nft-select" value={selectedNftId} onChange={(e) => setSelectedNftId(e.target.value)}>
                {sortedIndexes.map((index) => (
                <option key={index} value={index}>
                  {index}
                  </option>
                  ))}
                  </select>
            </div>
            <p>
              <button onClick={mintAndTransferNft}>Mint and Transfer NFT</button>
              <br></br>
              <br></br>

              <button style={{ marginLeft: '10px' }} onClick={() => window.open(transferURL, '_blank')}>View Transferred NFTs on OpenSea</button>
            </p>
          </div>
        )}
      </div>
    </div>
  );

}



export default Minter;
  1. The sortedIndexes variable is assigned the sorted array nftIndexes using the sort method, sorting the indexes in ascending order.
  2. The code returns a JSX structure that consists of a container (<div className="container">) containing two container boxes (container-box-1 and container-box-2), which are conditionally rendered based on the value of nftsAsBalanceCount.
  3. container-box-1 displays different content based on the condition nftsAsBalanceCount >= 2:

If the condition is true, it renders a <div> element containing the following elements:

  • A paragraph <p> element displaying the message "You have {nftCount} NFTs in your balance," where {nftCount} is the value of the nftCount variable.
  • Two buttons: "Mint NFTs as balance" and "View minted on SCW address on OpenSea." The first button triggers the mintNftAsBalance function when clicked, and the second button opens a new window with the URL specified in nftURL when clicked.

If the condition is false, it renders a paragraph <p> element displaying the message "Please Mint more NFTs to add as balance for transfer" and a "Mint NFTs as balance" button, which triggers the mintNftAsBalance function when clicked.

  1. container-box-2 is conditionally rendered only if nftsAsBalanceCount >= 2. It displays a <div> element with the className="container-box-2". Inside this div, there are the following elements:
  • A <label> element with the text "Recipient Address :" and an <input> element for entering the recipient's address. The value of the input is bound to the recipientAddress state variable, and its value updates using the onChange event handler.
  • A dropdown menu labeled "Select an NFT ID to Transfer" (<select>) that displays options generated from the sortedIndexes array. The value of the dropdown is bound to the selectedNftId state variable, and it updates using the onChange event handler.
  • A paragraph <p> element with two buttons:
  1. "Mint and Transfer NFT" button triggers the mintAndTransferNft function when clicked.
  2. "View Transferred NFTs on OpenSea" button opens a new window with the URL specified in transferURL when clicked.
  1. Finally, the component Minter is exported as the default export.

Overall, the Minter component provides a user interface that allows users to mint NFTs, view minted NFTs on OpenSea, specify a recipient address, select an NFT ID to transfer, and view transferred NFTs on OpenSea. The UI rendering is conditionally based on the number of NFTs in the balance.

This is how the final output would look like :

Final Steps :

Once you are done with above steps, you can do npm run dev  to run the dapp on localhost. Remember to edit the .env files as I had mentioned. The frontend .env file will include your API key from Biconomy Dashboard and the verified contract address. Your backend env file specific to your contract folder will have PRIVATE Key of your metamask account which should have some funds and API Key from POLYGONSCAN.

You can check out the entire link to the GitHub repository here : https://github.com/vanshika-srivastava/gasless-batched

Subscribe to the Biconomy Academy

Building a decentralised ecosystem is a grind. That’s why education is a core part of our ethos. Benefit from our research and accelerate your time to market.

You're in! Thank you for subscribing to Biconomy.
Oops! Something went wrong while submitting the form.
By subscribing you agree to with our Privacy Policy
Copied link