DEV Community

Tomiwa 😃
Tomiwa 😃

Posted on

How to Build a Multi-Chain NFT Marketplace on Ethereum, using Solidity, React, Hardhat and Ethers.js

In this tutorial, we’ll learn how to create a multi-chain NFT marketplace like arthouse where you can create, sell and buy an NFT on multiple blockchains such as Ethereum, Polygon and Binance Smart using Solidity, React and Ethers.js.

We’ll be writing our code in a very dynamic way so that we can add other chains in the future such as Avalanche, Celo, and Optimism with minimal code changes.

To see a live demo of what it looks like visit art.atila.ca or test.art.atila.ca to use the testnets.

All source code is available at github.com/atilatech/arthouse.

The original tutorial can be found at atila.ca.

What we’ll be Building

arthouse.gif

Our NFT marketplace will have 4 features. Mint NFT, List NFT, Buy NFT and withdraw sales.

We’ll mint an NFT to multiple chains. Then we’ll list the NFT on one of those chains for sale and then finally we’ll use another account to buy that NFT.

This tutorial is inspired by Nader Dabit’s excellent NFT marketplace tuorial and video tutorial. This tutorial will focus mainly on parts that are different from Nader’s tutorial instead of going over every single line of code. If you want more context, watch the video tutorial that I made to accompany this tutorial or check out Nader’s tutorial.

Our Tech Stack

  1. How to write Solidity smart contract code
  2. How to write tests for smart contracts using Hardhat and Waffle
  3. How to dynamically deploy your smart contract to different blockchains using Hardat
  4. How to create a React Web Application to interact with your smart contract using ethers.js

Getting Started

Instead of making projects from scratch, I believe the best way to learn is by taking an existing project and incrementally rebuilding it to add the feature you want.

Clone the github.com/atilatech/arthouse from Github and checkout to an older version of the Repo before the desired functionality was added.

https://github.com/atilatech/art-house/commit/0c2079f647e97a09445d0b6d9f24be4ac5d6793e

git clone https://github.com/atilatech/arthouse
git checkout 0c2079f647e97a09445d0b6d9f24be4ac5d6793e
# the following command allows you to make changes without being in a detached HEAD state
git checkout -b arthouse-tutorial
Enter fullscreen mode Exit fullscreen mode

Sidenote: Here’s a cool trick to find the first commit.

Installing Packages

I’m using yarn but feel free to use npm, pnpm or your favourite package manager.

If you’re using npm you can replace yarn add with npm install.

Next, we need to install some packages. These packages will automatically be installed by yarn add or npm install since these packages are already in our package.json . Note that, some of these packages may also be outdated which is another reason to re-install packages. For the sake of completeness, install these packages.

yarn add ethers hardhat   \
web3modal @openzeppelin/contracts ipfs-http-client \
axios 

# I think it's good to separate main from development dependencies. Especially if you want to run yarn install in CI, you can skip installing testing packages

yarn add -D chai @nomiclabs/hardhat-waffle \
ethereum-waffle @nomiclabs/hardhat-ethers

yarn install

# I like the ant design library but feel free to use any design library you prefer

yarn add antd
Enter fullscreen mode Exit fullscreen mode

Adding Blockchains

Add the Rinkeby and Binance Smart Chain testnets to our configuration file (src/config-chains.json). We’ll add Polygon later so you can see how a new chain can be added without changing any code logic.

  1. Get the chain ID and PRC URLs using chainlist.org. Note that for the public RPC URLs, you might need to get a private RPC Url in case it gets too much traffic or is rate-limited.
    1. If the RPC URLs are not working for you, you might need to create an Infura account or a similar RPC provider.

src/config-chains.json

{
    "4": {
        "NETWORK_NAME": "Rinkeby",
        "CHAIN_NAME": "Ethereum",
        "CURRENCY_SYMBOL": "ETH",
        "NFT_MARKETPLACE_ADDRESS": "0x359f6a8cf6da9b1a886965ce9eb5352bb7913eb4",
        "NFT_ADDRESS": "0x06c6a25884c36156b3d0105283d9724018b034b0",
        "IS_MAIN_NET": false,
        "LOGO_URL": "https://styles.redditmedia.com/t5_2zf9m/styles/communityIcon_hebckbd64w811.png",
        "CHAIN_ID": "4",
        "BLOCK_EXPLORER_URL": "https://rinkeby.etherscan.io",
        "RPC_PROVIDER_URL": "https://rinkeby.infura.io/v3/59b59e23bb7c44d799b5db4a1b83e4ee"
    },
    "97": {
        "NETWORK_NAME": "Testnet",
        "CHAIN_NAME": "Binance Smart Chain",
        "CURRENCY_SYMBOL": "BNB",
        "NFT_MARKETPLACE_ADDRESS": "0xc219B570Efe3a74620F89D884F3DA51e8ebcFfD4",
        "NFT_ADDRESS": "0xa94EC56924E2E4dcCb48e4987fEadA4B646637a3",
        "IS_MAIN_NET": false,
        "LOGO_URL": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fc/Binance-coin-bnb-logo.png/800px-Binance-coin-bnb-logo.png",
        "CHAIN_ID": "97",
        "BLOCK_EXPLORER_URL": "https://testnet.bscscan.com",
        "RPC_PROVIDER_URL": "https://data-seed-prebsc-1-s1.binance.org:8545"
    },
    "localhost": {
        "NETWORK_NAME": "Localhost",
        "CHAIN_NAME": "Ethereum",
        "NFT_MARKETPLACE_ADDRESS": "0x5FbDB2315678afecb367f032d93F642f64180aa3",
        "NFT_ADDRESS": "0x5FbDB2315678afecb367f032d93F642f64180aa3",
        "IS_MAIN_NET": false,
        "LOGO_URL": "https://styles.redditmedia.com/t5_2zf9m/styles/communityIcon_hebckbd64w811.png",
        "CHAIN_ID": "localhost",
        "BLOCK_EXPLORER_URL": "https://rinkeby.etherscan.io",
        "RPC_PROVIDER_URL": ""
    }
}
Enter fullscreen mode Exit fullscreen mode

Configuring Hardhat

Usually, we would run npx hardhat which will configure a new Hardhat development environment and create the following files:

hardhat.config.js, scripts, test contracts

However, you’ll notice our repo already has those files so instead we’ll go ahead and just modify each one starting with hardhat.config.js

  1. You will also need to get a private key. The easiest way to do this is to install Metamask (or similar browser wallet), export one of the private keys and set it to your environment variable. Note: treat this private key like a password or a bank account information. If anyone gets access to this, they can control your wallet.
/* hardhat.config.js */
require("@nomiclabs/hardhat-waffle")
const { CONTRACT_DEPLOYMENT_WALLET_PRIVATE_KEY, INFURA_API_KEY} = process.env;

module.exports = {
  solidity: {
    version: "0.8.4",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200
      }
    },
  },
  paths: {
    artifacts: './src/artifacts',
  },
  defaultNetwork: "hardhat",
  networks: {
    hardhat: {
      chainId: 1337
    },
    rinkeby: {
      url: `https://rinkeby.infura.io/v3/${INFURA_API_KEY}`,
      accounts: [`0x${CONTRACT_DEPLOYMENT_WALLET_PRIVATE_KEY}`],
      chainId: 4
    },
    bsctestnet: {
      url: `https://data-seed-prebsc-1-s1.binance.org:8545`,
      accounts: [`0x${CONTRACT_DEPLOYMENT_WALLET_PRIVATE_KEY}`],
      chainId: 97
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

Why we’re using Ethereum Rinkeby’s Network

We chose Rinkeby because it is supported by the most popular NFT marketplace, Opensea. So if we want our NFTs to be visble there we will need to use Rinkeby. It seems like most other marketplaces use Rinkeby as well.

Ethereum has many testnets and in a previous tutorial, I used Ropsten.

In that tutorial I said:

We're using the Ropsten test network because it's the test network that most resembles the main Ethereum blockchain.

Source: *How to add Ethereum and Binance Payments to a React Website with Metamask and Ethers.js*

Rinkeby doesn't fully reproduce the current mainnet since it uses PoA. While Ropsten uses proof of work just like the Ethereum mainnet. This also means that the Ether supply can only be be mined by a pre-approved group of nodes on Ropsten.

Coding the Smart Contract

Now we can get into the real smart contract development stuff.

// contracts/NFT.sol
// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity ^0.8.3;

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

import "hardhat/console.sol";

contract NFT is ERC721URIStorage {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;

    event NFTMinted (
        uint256 indexed tokenId,
        string tokenURI
    );
    constructor() ERC721("Atila NFT", "ATILANFT") {}

    function createToken(string memory tokenURI) public returns (uint) {
        _tokenIds.increment();
        uint256 newTokenId = _tokenIds.current();
        _mint(msg.sender, newTokenId);
        _setTokenURI(newTokenId, tokenURI);
        emit NFTMinted(newTokenId, tokenURI);
        return newTokenId;
    }
}
Enter fullscreen mode Exit fullscreen mode

If you get the following error in VS Code

file import callback not supported
Enter fullscreen mode Exit fullscreen mode

Do the following:

  1. Install the Solidity extension in Visual Studio Code
  2. Right-click the error and change the default workspace compiler to local or localNodeModue

See this Stack Overflow answer for more context

The first thing we’ll do is code our smart contract. We’ll be using the OpenZeppelin library and making some slight modifications to it. OpenZeppelin is a well tested and audited smart contracts library so we can leverage the level of security in their contracts.

Counters is used to keep track of how many token IDs we’ve created and ERC721URIStorage is what allows us to store the URL that will be pointing to our NFT data. So the actual image in a JPEG NFT isn’t stored on the blokchain but the URL pointing to that JPEG is stored on-chain.

You can see the ERC721 OpenZeppelinimplementation to see how this logic works and compare it to the ERC20 implementation. For example, see the different implementations of balanceOf (ER721, balanceOf in ERC20) or transfer (ERC20, ERC721).

Next, we will simply set the tokenURI for our NFT and emit an event. Emitting an event is useful because it allows us to query the transaction logs and can allow for us to easily index NFT creation events to tools like The Graph.

Also, note that in the original tutorial, the NFT would give the contract permission to transfer the NFT. We’ve removed this code [commit] to decouple the NFT contract from the marketplace contract. Now, once the NFT has been minted, the owner is able to maintain full ownership of their NFT and can decide to list it on the marketplace or do with it as we see fit.

Adding Smart Contract Tests

Due to the immutable and financial nature of smart contracts, testing is extra important. We all know that testing is important, but in smart contract development, it’s especially true.

Create a new file and create some basic tests for your NFT contracts. While this is a pretty simple NFT and we are mostly using OpenZeppelin’s library, it’s good to get into the habit of writing tests and writing tests also help you understand your code better and can often be a very effective form of documentation.

Create a new file called test/contracts/NFT.test.js and add the following:

// import ethers is not mandatory since its globally available but adding here to make it more explicity and intuitive
const { expect } = require("chai");
const { ethers } = require("hardhat");

const DEFAULT_URI = "https://www.mytokenlocation.com";
describe("NFT", function() {

  let NFT, nft,nftContractAddress, ownerSigner, secondNFTSigner;

  before(async function() {
    /* deploy the NFT contract */
    NFT = await ethers.getContractFactory("NFT")
    nft = await NFT.deploy();
    await nft.deployed()
    nftContractAddress = nft.address;
    /* Get users */
    [ownerSigner, secondNFTSigner, ...otherSigners] = await ethers.getSigners();

  })

  describe("createToken", function() {
    it("Emit NFTMinted event", async function() {
        await expect(nft.connect(ownerSigner).createToken(DEFAULT_URI)).to.emit(nft, 'NFTMinted').withArgs(1, DEFAULT_URI);

    })
    it("Should update the balances", async function() {

        await expect(() => nft.connect(ownerSigner).createToken(DEFAULT_URI))
          .to.changeTokenBalance(nft, ownerSigner, 1);

        await expect(() => nft.connect(secondNFTSigner).createToken(DEFAULT_URI))
          .to.changeTokenBalance(nft, secondNFTSigner, 1);

    })
  })
})
Enter fullscreen mode Exit fullscreen mode

In the before block we will be using the ethers library to allow us to “deploy” our contract to the blockchain. Then we will use ethers.getSigners() to get access to a series of accounts that can be used to represent real users.

The most important thing we want to check in these tests is that the NFT event is emitted and that the token balances changes. Fortunately, we can use the Ethereum Waffle package to abstract a lot of tests such as changeTokenBalances .

You can see in this commit [TODO] how much simpler such abstractions can make your tests.

Interestingly that test works for both ERC721 NFTs and ERC20 tokens (it even counts one ERC721 token as 1 wei in the error logs). This is possible because it calls balanceOf, which is implemented in both specifications as I mentioned earlier.

Marketplace Smart Contract

The marketplace smart contract is a bit more complex because it’s doing a lot more custom functions that are not already implemented in OpenZeppelin.

You can copy the entire smart contract code from here. I will simply show the concepts and code that are different from Nader’s tutorial.

If you want more context on the other pieces of the smart contract see Nader’s NFT marketplace tutorial blog post or video tutorial

At a high level, this marketplace allows you to do a few things

  1. List an item for sale
  2. Sell an item
  3. Buy an item
  4. Unlist the item

Going a bit deeper, the selling and buying process is a bit different from Nader Dabit’s tutorial in the following ways:

  1. A seller has the ability to unlist an item
  2. Instead of paying a listing fee, the marketplace charges a 2.5% commission
  3. When the sale is made, the seller’s revenue and the market commission is sent to a credits table which both users can call to withdraw the money from.

Let’s unpack each one starting with the simplest one, unlisting an item:

Unlisting a Market Item

/* Unlists an item previously listed for sale and transfer back to the seller */
  function unListMarketItem(
    address nftContract,
    uint256 itemId
  ) public payable nonReentrant {

    require(msg.sender == idToMarketItem[itemId].seller, "Only seller may unlist an item");
    uint tokenId = idToMarketItem[itemId].tokenId;
    IERC721(nftContract).transferFrom(address(this), msg.sender, tokenId);
    idToMarketItem[itemId].owner = payable(msg.sender);

  }
Enter fullscreen mode Exit fullscreen mode

The unlisting process first verifies that the user making the unlisting call is the same user that sold the NFT.

Then the IERC721 Interface wrapper is used to get access to the methods of another smart contract and we call the transferFrom method to return the NFT back to the sender.

This is possible because when we first list the NFT on the market, we will call setApprovalForAll which will give the market permission to transfer any of the tokens created by nftContract .

Then we set the smart contract owner back to the person who unlisted the items.

Note: this means that a given tokenId can exist in the idToMarketItem mapping multiple times. If you want to fetch what items are currently available for sale then you will filter by items in idToMarketItem that have the owner set to the smart contract.

Creating a Market Sale

This marketplace pays based on a commission instead of a flat fee. I will explain the economic and financial reasons why I coded it this way here then walk through the code implementation.

The Benefits of Percentage Commission

I prefer the idea of a percentage-based commission model because it dynamically changes based on the value of the item being sold and it works across different chains. For example, suppose a smart contract set the listing fee to 1 MATIC in the base currency (10^18 wei). That would be $0.638 USD in Matic but $1787.80 in ETH as of today’s price (May 29, 2022, 3:30 PM EST). You would have to change the listing fee for each contract and update it over time. While a percentage fee doesn’t need to be updated as often. You also don’t know if that listing fee is too little or too expensive for a given user.

*Note that all EVM compatible chains use the term “ether” in their smart contracts and in libraries like ethers.js but this doesn’t mean ETH, it could also mean BNB, MATIC etc.

Artist Royalties

There are also interesting proposals to add a royalty fee to the NFT itself in EIPS-2981. This would allow the NFT owner to receive a lifetime royalty even when other sell the NFT. Note that this implementation would depend on marketplaces agreeing to follow the specification since there is no way to differentiate between a transfer that doesn’t require a royalty and a sale which requires a royalty.

Marketplace smart Contract Code Walkthrough

Let’s walk through the different parts of the code.

Important: This code makes the assumption thatthe IERC721(nftContract).transferFrom() is a safe function call. We will probably need to add validation that ensures this is actually true. See this issue for more context.


/**
    Credit the given address, using a "pull" payment strategy.
    https://fravoll.github.io/solidity-patterns/pull_over_push.html
    https://docs.openzeppelin.com/contracts/2.x/api/payment#PullPayment 
  */
  function _allowForPull(address receiver, uint amount) private {
      credits[receiver] += amount;
  }

function withdrawCredits() public {
      uint amount = credits[msg.sender];

      require(amount > 0, "There are no credits in this recipient address");
      require(address(this).balance >= amount, "There are no credits in this contract address");

      credits[msg.sender] = 0;

      payable(msg.sender).transfer(amount);
  }

/* Creates the sale of a marketplace item */
  /* Transfers ownership of the item, as well as funds between parties */
  function createMarketSale(
    address nftContract,
    uint256 itemId
    ) public payable nonReentrant {
    uint price = idToMarketItem[itemId].price;
    uint tokenId = idToMarketItem[itemId].tokenId;

    // uses the check-effects-interactions design patter. Check if sale can be made. Do the effects of the sale, then perform the sale interactions.
    // make external transfer call last.
    // https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html

    require(idToMarketItem[itemId].owner == address(this), "This item is not available for sale");
    require(msg.value == price, "Please submit the asking price in order to complete the purchase");

    address seller = idToMarketItem[itemId].seller;
    // use basis points and multiply first before dividng because solidity does not support decimals
    // https://ethereum.stackexchange.com/a/55702/92254
    // https://stackoverflow.com/a/53775815/5405197
    uint marketPayment = (price * salesFeeBasisPoints)/basisPointsTotal;
    uint sellerPayment = price - marketPayment;

    // use the pull payment strategy. See function documentation for _allowForPull for more information on how this works
    // note: the funds go from the seller to the contract automatically due to msg.value 
    // we don't need to call payable(address(this)).transfer(amount);
    _allowForPull(seller, sellerPayment);
    idToMarketItem[itemId].owner = payable(msg.sender);
    idToMarketItem[itemId].sold = true;
    _itemsSold.increment();
    _allowForPull(payable(owner), marketPayment);
// NOTE! This code makes the assumption thatthe IERC721(nftContract).transferFrom() is a safe function call.
    IERC721(nftContract).transferFrom(address(this), msg.sender, tokenId);
  }

Enter fullscreen mode Exit fullscreen mode

Prevent Reentrancy attacks

The first thing we have is the nonReentrant modifier. This is to prevent reEntrancy attacks:

A Reentrancy attack occurs when the attacker drains funds from the target by recursively calling the target's withdraw function
. When the contract fails to update its state, a victim's balance, prior to sending funds, the attacker can continuously call the withdraw function to drain the contract's funds.

Source: Reentrancy Vulnerability Identification in Ethereum Smart Contracts

The most famous example of reentrancy attacks is The DAO which resulted in over $150 million being stolen. My favourite recollection of that attack is Laura Shin’s The Cryptopians todo.

This guard, which we inherit from contract NFTMarket is ReentrancyGuard { and OpenZeppelin’s ReentrancyGuard prevents that attack.

For more on reentrancy attacks see:

Check Effects Interaction

The following pieces of code are going to follow the check-effects-interaction pattern. This is another recommendation in Solidity best practices for writing good Solidity code. It essentially ensures that all the correct changes are made internally before making any external changes.

The checks, in the beginning, assure that the calling entity is in the position to call this particular function (e.g. has enough funds). Afterwards, all specified effects are applied and the state variables are updated. Only after the internal state is fully up to date, external interactions should be carried out.

Source: Check Effects Interactions

  1. Check if something can happen:
    1. require(idToMarketItem[itemId].owner == address(this), "This item is not available for sale");
    2. require(msg.value == price, "Please submit the asking price in order to complete the purchase");
  2. Perform the internal state effects of that interaction:
    1. address seller = idToMarketItem[itemId].seller;
    2. _allowForPull(seller, sellerPayment);
    3. etc.
  3. Perform the external interactions
    1. IERC721(nftContract).transferFrom(address(this), msg.sender, tokenId);

A combination of a reentrancy guard and using the check-effects-interaction pattern might have prevented such an attack from happening.

Prefer Pull vs Push Payments

Finally, when a sale is made, instead of transferring the money to an address, we credit the address for the amount of the sale:allowForPull(address, amount) . Later, the address can withdraw that credit: withdrawCredits(). This follows the pull over push Solidity pattern.

First, transferring ether to an untrusted wallet could present a security risk because the address might have malicious code that exploits your smart contract when you call it.

Secondly, the contract may run out of gas and be unable to pay the users on a sale. This shifts the burden of payment unto the hser. Note that this does come with a high cost of adding more burden to users. Makoto Inoue has a fascinating story about an event smart contract he wrote and managing the tradeoffs between making something secure and making something usable.

That covers the bulk of the relevant smart contract code. Next, let’s write tests.

More resources on Smart Contracts

There’s a lot more to learn about smart contract developments. Here are some relevant sources:

Adding Marketplace Smart Contract Tests

The test file is even longer than the smart contracts file, which you can find here: [TODO link to marketplace smart contract]

Create a test/contracts/NFTMarket.test.js file and create some basic variables

const { expect } = require("chai");
const { ethers } = require("hardhat");

const { BigNumber } = ethers;

const auctionPriceInEth = '1.5';
const auctionPrice = ethers.utils.parseUnits(auctionPriceInEth, 'ether');
Enter fullscreen mode Exit fullscreen mode

Define a before block where we deploy our NFT and market contract. Theoretically, in a Unit test, we should only be testing the market contract because the market should also work with other NFTs. This also assumes that the smart contracts we are creating have the same functions that we are using in our tests.

This is actually one benefit of having the NFT coupled with the Market in the other impelemtation. It guarantees that any NFT created here will have the requisite functions. Such as transfer from.

before(async function() {
    /* deploy the marketplace */
    Market = await ethers.getContractFactory("NFTMarket")
    market = await Market.deploy()
    await market.deployed()
    marketAddress = market.address
    marketSigner = market.provider.getSigner(marketAddress);

    /* deploy the NFT contract */
    NFT = await ethers.getContractFactory("NFT")
    nft = await NFT.deploy();
    nft.setApprovalForAll(marketAddress, true);
    await nft.deployed()
    nftContractAddress = nft.address;
    /* Get users */
    [ownerSigner, sellerSigner, buyerSigner, ...otherSigners] = await ethers.getSigners();


  })
Enter fullscreen mode Exit fullscreen mode

Next we’ll test three things

describe("createMarketSale", function() {

    it("Should update the seller and contract token balances when listing item for sale", async function() {

      let createNFTPromise = nft.connect(sellerSigner).createToken("https://www.mytokenlocation.com");
      const tokenId = await getTokenIdOrItemIdFromTransaction(createNFTPromise);
      await nft.connect(sellerSigner).setApprovalForAll(marketAddress, true);


      await expect(() => market.connect(sellerSigner).createMarketItem(nftContractAddress, tokenId, auctionPrice),
       "The seller token balance should decrease by 1 and the market balance should increase by 1")
      .to.changeTokenBalances(nft, [sellerSigner, marketSigner], [-1, 1]);

    })

    it("Should update the buyer and contract token balances when an item is sold", async function() {

      let { tokenId } = await createTokenAndMarketItem(sellerSigner);
      const { itemId } = await getMarketItemIdFromTokenId(tokenId);

      await expect(() => market.connect(buyerSigner).createMarketSale(nftContractAddress, itemId, { value: auctionPrice}))
      .to.changeTokenBalances(nft, [buyerSigner, marketSigner, sellerSigner], [1, -1, 0]);

    })

    it("Should update the buyer and contract ether balances on sale", async function() {

      let { tokenId } = await createTokenAndMarketItem(sellerSigner);
      const { itemId } = await getMarketItemIdFromTokenId(tokenId);

      const negativeAuctionPrice = ethers.utils.parseUnits(`-${auctionPriceInEth}`, 'ether');

      await expect(() => market.connect(buyerSigner).createMarketSale(nftContractAddress, itemId, { value: auctionPrice}),
      `The buyer's ether balance should decrease by the auction price: ${auctionPriceInEth} while the contract's ether balance increases by the auction price.`)
      .to.changeEtherBalances([buyerSigner, marketSigner], [negativeAuctionPrice, auctionPrice]);

    })

  })
Enter fullscreen mode Exit fullscreen mode

The 3 things we’re testing here is:

  1. When an item is listed. Seller's token balance should decrease and market’s token balance should increase.
  2. When an item is sold. Market token balance should decrease and buyer’s token balance should increase.
  3. When an item is sold. Market token balance should decrease and buyer’s token balance should increase.

We’ll also define 3 helper functions inside describe("NFTMarket", that will allow us to easily create tokens and list them for sale:

/**
   * Parse the transaction logs to get the tokenId returned from the function call
   * @param {*} transactionPromise 
   * @returns 
   */
   async function getTokenIdOrItemIdFromTransaction(transactionPromise) {
    transactionPromise = await transactionPromise;
    const transaction = await transactionPromise.wait()
    const event = transaction.events[0];
    let value = event.topics[3]
    value = BigNumber.from(value)
    // We usually shouldn't convert BigNumber toNumber() but this is okay since we don't expect the tokenId or itemId to be very large in our tests
    return value.toNumber()
  }

  /**
   * Reading the itemId from the transaction result is causing potential race conditions where tests pass in isolation
   * but fail when run together in the test suite.
   * To solve this, this helper function was created to get the item ID from the smart contract when it's needed.
   * @param {*} tokenId 
   * @param {*} returnSoldItems 
   * @returns 
   */
  async function getMarketItemIdFromTokenId(tokenId, returnSoldItems = false) {
    let marketItems = (await market.fetchMarketItems());
    return marketItems.find(item=> returnSoldItems ? item.sold : !item.sold && BigNumber.from(tokenId).eq(item.tokenId));
  }

  async function createTokenAndMarketItem(signer) {

    let createNFTPromise = nft.connect(signer).createToken("https://www.mytokenlocation.com");
    const tokenId = await getTokenIdOrItemIdFromTransaction(createNFTPromise);

    await nft.connect(signer).setApprovalForAll(marketAddress, true);

    const createMarketItemPromise = market.connect(signer).createMarketItem(nftContractAddress, tokenId, auctionPrice);
    const itemId = await getTokenIdOrItemIdFromTransaction(createMarketItemPromise);

    return {
      tokenId,
      itemId
    }
  }
Enter fullscreen mode Exit fullscreen mode

Now you can run npx hardhat test

Deploying Smart Contract

Remember the goal of this platform is to build a multi-chain NFT platform. This means that we want to be able to add new chains to our contract without doing a lot of additional work. To do this, we will replace the traditional hardhat scripts with a hardhat task.

First, we create a scripts/helpers.js file that will contain our helper functions and we’ll move the hardhat settings from hardhat.config.js to our scripts/helpers.js file so that the task can use these hardHatSettings:

Creating a Deployment Task

The goal of the deployment task is to allow us to deploy our smart contract to any EVM compatible chain simply by providing a chainID argument. Then, src/config-chains.json will be updated with the new smart contract addresses and our pages will automatically reload.

In the current implementation of deploy.js, we need to update our code for each new contract. Now we can deploy to a specific chain or deploy only the Marketplace or NFT contract:

npx hardhat deploy --chain-id 4 or npx hardhat deploy:nft --chain-id 4

See the Opensea NFT minting tutorial for more instructions on deploying the smart contract.

// scripts/deploy.js

// We require the Hardhat Runtime Environment explicitly here. This is optional
// but useful for running the script in a standalone fashion through `node <script>`.
//
// When running the script with `npx hardhat run <script>` you'll find the Hardhat
// Runtime Environment's members available in the global scope.
// const hre = require("hardhat");
const { task } = require("hardhat/config");
const fs = require('fs');
const { getAvailableChains, chainConfigFilePath, getAccount } = require("./helpers");

task("deploy:nft", "Deploys the NFT.sol contract")
.addParam("chainId", "The Chain ID of the blockchain where this contract will be deployed.")
.setAction(async function (taskArguments, hre) {

  const availableChains = getAvailableChains();
  const { chainId } = taskArguments;

  if (!(chainId in availableChains)) {
    // characters at the beginning to make error print in a different color
    // https://stackoverflow.com/a/41407246/5405197
    let chainsForPrinting = {};
    Object.values(availableChains).forEach(chain=>{chainsForPrinting[chain.CHAIN_ID]= `${chain.CHAIN_NAME} (${chain.NETWORK_NAME})`});
    chainsForPrinting = JSON.stringify(chainsForPrinting, null, 4);

    console.error("\x1b[31m%s\x1b[0m", `Invalid chainId: ${chainId}.\nPlease select one of the following:\n${chainsForPrinting}`);
    process.exit(1);
  }
  const account = getAccount(chainId);
  let NFT;
  if (account) {
    NFT = await hre.ethers.getContractFactory("NFT", account);
  } else {
    NFT = await hre.ethers.getContractFactory("NFT");
  }

  const nft = await NFT.deploy();
  await nft.deployed();

  availableChains[chainId].NFT_ADDRESS = nft.address.toLowerCase();
  fs.writeFileSync(chainConfigFilePath, JSON.stringify(availableChains, null, 4));

  const chainConfig = availableChains[chainId];
  console.log("\x1b[32m%s\x1b[0m", `NFT deployed to ${chainConfig.CHAIN_NAME} (${chainConfig.NETWORK_NAME}): ${chainConfig.NFT_ADDRESS}`);
  console.log("\x1b[32m%s\x1b[0m", `View in block explorer: ${chainConfig.BLOCK_EXPLORER_URL}/address/${chainConfig.NFT_ADDRESS}`);

});

task("deploy:market", "Deploys the Market.sol contract")
.addParam("chainId", "The Chain ID of the blockchain where this contract will be deployed.")
.setAction(async function (taskArguments, hre) {

  const availableChains = getAvailableChains();
  const { chainId } = taskArguments;

  if (!(chainId in availableChains)) {
    let chainsForPrinting = {};
    Object.values(availableChains).forEach(chain=>{chainsForPrinting[chain.CHAIN_ID]= `${chain.CHAIN_NAME} (${chain.NETWORK_NAME})`});
    chainsForPrinting = JSON.stringify(chainsForPrinting, null, 4);

    console.error("\x1b[31m%s\x1b[0m", `Invalid chainId: ${chainId}.\nPlease select one of the following:\n${chainsForPrinting}`);
    process.exit(1);
  }

  const account = getAccount(chainId);
  let NFTMarket;
  if (account) {
    NFTMarket = await hre.ethers.getContractFactory("NFTMarket", account);
  } else {
    NFTMarket = await hre.ethers.getContractFactory("NFTMarket");
  }
  const nftMarket = await NFTMarket.deploy();
  await nftMarket.deployed();

  availableChains[chainId].NFT_MARKETPLACE_ADDRESS = nftMarket.address.toLowerCase();
  fs.writeFileSync(chainConfigFilePath, JSON.stringify(availableChains, null, 4));

  const chainConfig = availableChains[chainId];
  console.log("\x1b[32m%s\x1b[0m", `NFTMarket deployed to ${chainConfig.CHAIN_NAME} (${chainConfig.NETWORK_NAME}): ${chainConfig.NFT_MARKETPLACE_ADDRESS}`);
  console.log("\x1b[32m%s\x1b[0m", `View in block explorer: ${chainConfig.BLOCK_EXPLORER_URL}/address/${chainConfig.NFT_MARKETPLACE_ADDRESS}`);

});

task("deploy", "Deploys the Market.sol and NFT.sol contract")
.addParam("chainId", "The Chain ID of the blockchain where this contract will be deployed.")
.setAction(
  async (taskArgs, hre) => {
    await hre.run("deploy:market", taskArgs);
    await hre.run("deploy:nft", taskArgs);
  }
);
Enter fullscreen mode Exit fullscreen mode
// scripts/helpers.js
const { ethers } = require("ethers");
const fs = require('fs');
const { CONTRACT_DEPLOYMENT_WALLET_PRIVATE_KEY, INFURA_API_KEY} = process.env;

const chainConfigFilePath = './src/config-chains.json';
function getAvailableChains() {
    let chainConfigRaw = fs.readFileSync(chainConfigFilePath);

    let chainConfigs = JSON.parse(chainConfigRaw);
    return chainConfigs
}

const hardHatSettings = {
    networks: {
        rinkeby: {
          url: `https://rinkeby.infura.io/v3/${INFURA_API_KEY}`,
          accounts: [`0x${CONTRACT_DEPLOYMENT_WALLET_PRIVATE_KEY}`],
          chainId: 4
        },
        bsctestnet: {
          url: `https://data-seed-prebsc-1-s1.binance.org:8545`,
          accounts: [`0x${CONTRACT_DEPLOYMENT_WALLET_PRIVATE_KEY}`],
          chainId: 97
        },
    }
};

// Helper method for fetching a connection provider to the Ethereum network
function getNetworkSetting(chainId) {
    return Object.values(hardHatSettings.networks).find(chainSettings => chainSettings.chainId == chainId);
}

// Helper method for fetching a connection provider to the Ethereum network
function getProvider(chainId) {
    const hardhatChainNetwork = getNetworkSetting(chainId);
    return ethers.getDefaultProvider(hardhatChainNetwork?.url);
}

// Helper method for fetching a wallet account using an environment variable for the PK
function getAccount(chainId) {

    const hardhatChainNetwork = getNetworkSetting(chainId);
    if (!hardhatChainNetwork) {
        console.error("\x1b[33m%s\x1b[0m", `No matching chainId found for network: '${chainId}', using localhost.`);
        return null
    }
    return new ethers.Wallet(hardhatChainNetwork? hardhatChainNetwork.accounts[0]:"", getProvider(chainId));
}

module.exports = {
    getAvailableChains,
    chainConfigFilePath,
    hardHatSettings,
    getProvider,
    getAccount,
    getNetworkSetting
}
Enter fullscreen mode Exit fullscreen mode
/* hardhat.config.js */
const { hardHatSettings } = require("./scripts/helpers.js");

require("@nomiclabs/hardhat-waffle");
require("./scripts/deploy.js");

module.exports = {
  solidity: {
    version: "0.8.4",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200
      }
    },
  },
  paths: {
    artifacts: './src/artifacts',
  },
  defaultNetwork: "hardhat",
  networks: hardHatSettings.networks,
}
Enter fullscreen mode Exit fullscreen mode

Loading environment variables

To load the environment variables we will create a .env file and add the following values:

export CONTRACT_DEPLOYMENT_WALLET_PRIVATE_KEY=""
export INFURA_API_KEY=""
Enter fullscreen mode Exit fullscreen mode

Note that these files have been removed from version control via the .gitignore file [todo link] so we don’t have to worry about committing our API key.

Then we can load them to our environment variables:

source .env 
Enter fullscreen mode Exit fullscreen mode

Getting Test Crypto from a faucet

Before we can deploy we’ll need some money, we can get some from any of the following faucets. Note that faucets frequently go down so you might have to search for “chain faucet” and try different results.

Adding Blockchains to your Metamask

Screen Shot 2022-05-29 at 9.35.20 PM.png

Deploy the smart contract

# Deploy to Ethereum’s Rinkeby testnet 

npx hardhat deploy --chain-id 4 

#Deploy to Binance Smart Chain’s Testnet

npx hardhat deploy --chain-id 97

# If we want to only deploy just the nft or market

npx hardhat deploy:nft --chain-id 4` or `npx hardhat deploy:market --chain-id 4
Enter fullscreen mode Exit fullscreen mode

If your deploy task is not working, use the default deploy script

If the deploy task is not working, use the old school deploy script: npx hardhat run --network polygon scripts/deploy.js

// scripts/deploy-hardhat.js

// If the deploy task is not working use the old school deploy script
// npx hardhat run --network polygon scripts/deploy-hardhat.js
// We require the Hardhat Runtime Environment explicitly here. This is optional
// but useful for running the script in a standalone fashion through `node <script>`.
//
// When running the script with `npx hardhat run <script>` you'll find the Hardhat
// Runtime Environment's members available in the global scope.
const hre = require("hardhat");
const fs = require('fs');

// enter the CHAIN ID you want to deploy to here
// TODO make this a task so it can be passed as a command line argument
//  - https://stackoverflow.com/questions/69111785/hardhat-run-with-parameters
// - https://hardhat.org/guides/create-task.html
const chainID = "97";

async function main() {

const chainConfigFilePath = './src/config-chains.json';
let chainConfigRaw = fs.readFileSync(chainConfigFilePath);

let chainConfig = JSON.parse(chainConfigRaw);

  if (!chainID) {
    console.error("Chain ID must be set in scripts/deploy.js");
    process.exit(1)
  }

  if (!chainConfig[chainID]) {
    console.error(`Invalid Chain ID found: ${chainID}. Valid Chain IDs include: ${Object.keys(chainConfig)}`);
    process.exit(1)
  }

  const NFTMarket = await hre.ethers.getContractFactory("NFTMarket");
  const nftMarket = await NFTMarket.deploy();
  await nftMarket.deployed();
  chainConfig[chainID].NFT_MARKETPLACE_ADDRESS = nftMarket.address;

  console.log("NFT_MARKETPLACE_ADDRESS deployed to:", nftMarket.address);
  console.log("\x1b[32m%s\x1b[0m", `View in block explorer: ${chainConfig[chainID].BLOCK_EXPLORER_URL}/address/${chainConfig[chainID].NFT_MARKETPLACE_ADDRESS}`);
  fs.writeFileSync(chainConfigFilePath, JSON.stringify(chainConfig, null, 4))

  const NFT = await hre.ethers.getContractFactory("NFT");
  const nft = await NFT.deploy();
  await nft.deployed();
  chainConfig[chainID].NFT_ADDRESS = nft.address;

  console.log("NFT_ADDRESS deployed to:", nft.address);
  console.log("\x1b[32m%s\x1b[0m", `View in block explorer: ${chainConfig[chainID].BLOCK_EXPLORER_URL}/address/${chainConfig[chainID].NFT_ADDRESS}`);

  fs.writeFileSync(chainConfigFilePath, JSON.stringify(chainConfig, null, 4))
}

// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });
Enter fullscreen mode Exit fullscreen mode

Adding more blockchains

To see how easy it is to add another blockchain. Let’s add the Polygon Mumbai chain. Add the following to your scripts/helpers.js and

// scripts/helpers.js
const hardHatSettings = {
    networks: {
        mumbai: {
          url: `https://polygon-mumbai.infura.io/v3/${rpcApiKeyMumbai}`,
          accounts: [`0x${CONTRACT_DEPLOYMENT_WALLET_PRIVATE_KEY}`],
          chainId: 80001
        },
//...
Enter fullscreen mode Exit fullscreen mode

Add the following tosrc/config-chains.json

"80001": {
        "NETWORK_NAME": "Mumbai",
        "CHAIN_NAME": "Polygon",
        "CURRENCY_SYMBOL": "MATIC",
        "NFT_MARKETPLACE_ADDRESS": "0x290541B82B151cDC05eA1aF04fe74f6d7633ccDC",
        "NFT_ADDRESS": "0x5216962D1308AA3de2e89c969dacc1B2F798EaB5",
        "IS_MAIN_NET": false,
        "LOGO_URL": "https://brandlogovector.com/wp-content/uploads/2021/12/Polygon-Crypto-Icon-Logo-Small.png",
        "CHAIN_ID": "80001",
        "BLOCK_EXPLORER_URL": "https://mumbai.polygonscan.com",
        "RPC_PROVIDER_URL": "https://matic-mumbai.chainstacklabs.com"
    },
Enter fullscreen mode Exit fullscreen mode

Deploy your new chain (make sure you’ve gotten faucet funds for that chain using the steps outlined above):

npx hardhat deploy --chain-id 80001

That’s it! Your react app should automatically reload and you will now see the new chain in your application.

Adding the frontend

For this tutorial, we’ll implement three components and two pages. The code needed for those files will be mentioned in the following links.

CreateNFT and Gallery [TODO don’t use Moralis, just read from the blockchain]

We will also neeed to create three components CryptoPrice, CryptoPriceEdit and NFTCard and NFTCard.scss.

Don’t forget to import all missing code which you can also find from this blob.

Page Components

You can copy-paste the following code snippets and put them in src/components

Displaying Price in Crypto and Fiat

Why do we need CryptoPrice.tsx and CryptoPriceEdit.tsx?

Most people still use currencies like USD as a unit of measurement to intuitively understand how much something costs. So I always display a US cost alongside any crypto costs. As a quick fix, we can hardcode the price of a currency in USD. A future implementation would get this information dynamically from an API and perhaps cache it to local storage.

Create NFT Page

async function onChange(e: any) {
    const file = e.target.files[0];
    // setFileUrl("https://atila.ca/static/media/atila-upway-logo-gradient-circle-border.bfe05867.png");
    // return;
    try {
      const added = await client.add(
        file,
        {
          progress: (progressValue: any) => console.log(`received: ${progressValue}`)
        }
      )
      const url = `https://ipfs.infura.io/ipfs/${added.path}`
      setFileUrl(url)
    } catch (error) {
      console.log('Error uploading file: ', error);
      setError(JSON.stringify(error));
    }  
  }
Enter fullscreen mode Exit fullscreen mode

When you create an NFT you will be uploading it to IPFS to make the storage mroe distributed so it’s not just stored on one s.

Then we create the NFT:

async function _createNFT(activeChainId: string) {

    const activeChain = new Chain({...CONFIG_CHAINS[activeChainId]});
    const url = await getNFTMetadataUrl();

    if (!url) {
      setCreateNFTResponseMessage({
        ...createNFTResponseMessage,
         [activeChain.CHAIN_ID]: {
           type: "error",
           message: "Missing NFT url data. Try reuploading your NFT or refreshing the page.",
         }
         });
        return
    }
    const web3Modal = new Web3Modal()
    const connection = await web3Modal.connect()
    const provider = new ethers.providers.Web3Provider(connection)    
    const signer = provider.getSigner()

      if (window.ethereum.networkVersion !== activeChainId) {
        setError("Switch to the correct chain and try again");
        // switch to the correct network
        await window.ethereum.request({
        method: "wallet_switchEthereumChain",
        params: [{
            chainId: `0x${(Number.parseInt(activeChain.CHAIN_ID)).toString(16)}`,
        }]
      });
        return;
      } else {
        setError("");
      }


      const NFT_ADDRESS = activeChain.NFT_ADDRESS;

      /* next, create the item */
      const updatedcreateNFTResponseMessage = {...createNFTResponseMessage};
      updatedcreateNFTResponseMessage[activeChain.CHAIN_ID] = {
        message: `Minting NFT on ${activeChain.getChainFullName()}`,
        type: "info",
        loading: true,
      };
      setCreateNFTResponseMessage(updatedcreateNFTResponseMessage);
      try {

        let nftContract = new ethers.Contract(NFT_ADDRESS, NFT.abi, signer)
        let mintTransactionPromise = await nftContract.createToken(url)
        let mintTransaction = await mintTransactionPromise.wait()
        let event = mintTransaction.events[0]
        let value = event.args[2]
        let tokenId = value.toNumber();

        console.log({mintTransaction, url});
        const { name, description } = formInput
        const createdNFT: NFTMetadata = {
          name,
          description,
          image: fileUrl || "",
          // url,
          tokenId,
          // tokenAddress: activeChain.NFT_ADDRESS,
          chainId: activeChain.CHAIN_ID,
          // chain: activeChain,
          // mintTransaction,
        }

        const updatedCreatedNFTs = [...createdNFTs];

        updatedCreatedNFTs.push(createdNFT);
        setCreatedNFTs(updatedCreatedNFTs);
        setCreateNFTResponseMessage({
          ...updatedcreateNFTResponseMessage,
           [activeChain.CHAIN_ID]: {
             type: "success",
             message: `Finished creating NFT on ${activeChain.getChainFullName()}`,
           }
           });
      } catch (error: any) {
        console.log(error);
        setCreateNFTResponseMessage({
          ...updatedcreateNFTResponseMessage,
           [activeChain.CHAIN_ID]: {
             type: "error",
             message: error.message || JSON.stringify(error),
           }
           });
      }


  } 
Enter fullscreen mode Exit fullscreen mode

Let’s explain what’s happening here:

const web3Modal = new Web3Modal()
const connection = await web3Modal.connect()
const provider = new ethers.providers.Web3Provider(connection)    
const signer = provider.getSigner()
Enter fullscreen mode Exit fullscreen mode

We use Web3Modal to support different crypto browser extensions so it should work with Metamask, Coinbase Wallet, WalletConnect etc. Then we connect the crypto browser extension to our account signer.

It’s usually at this point that anyone visiting your website for the first time will see a brower extension on Metamask popup. Generally, this isn’t actually a good UX pattern. Anytime you request permsssion to do anything, you should give context and the user should iniaitate the request. But for learning purposes, we can leave it like this for now.

if (window.ethereum.networkVersion !== activeChainId) {
        setError("Switch to the correct chain and try again");
        // switch to the correct network
        await window.ethereum.request({
        method: "wallet_switchEthereumChain",
        params: [{
            chainId: `0x${(Number.parseInt(activeChain.CHAIN_ID)).toString(16)}`,
        }]
      });
        return;
      } else {
        setError("");
      }
Enter fullscreen mode Exit fullscreen mode

Then we create the token:

import NFT from '../../artifacts/contracts/NFT.sol/NFT.json';
// ...

let nftContract = new ethers.Contract(NFT_ADDRESS, NFT.abi, signer)
let mintTransactionPromise = await nftContract.createToken(url)
let mintTransaction = await mintTransactionPromise.wait()
let event = mintTransaction.events[0]
let value = event.args[2]
let tokenId = value.toNumber();
Enter fullscreen mode Exit fullscreen mode

This works by connecting to the NFT smart contract we created earlier using something called an application Binary Interace NFT.abi

Recall the smart contract we created earlier:

// contracts/NFT.sol
function createToken(string memory tokenURI) public returns (uint) {
        _tokenIds.increment();
        uint256 newTokenId = _tokenIds.current();
        _mint(msg.sender, newTokenId);
        _setTokenURI(newTokenId, tokenURI);
        emit NFTMinted(newTokenId, tokenURI);
        return newTokenId;
    }
Enter fullscreen mode Exit fullscreen mode

We can also access the return value using the mintTransaction response.

Listing an NFT for sale

Next, we will list one of our NFTs for sale. We will implement this functionality inside the src/components/NFTCard.tsx component.

This logic is the same as minting an NFT, however, in order to sell the NFT to the buyer, we need to give the marketplace permission to transfer our NFT to themselves and then to the owner.

signer = await getSigner()
const nftContract = new ethers.Contract(activeChain.NFT_ADDRESS, NFT.abi, signer);
const signerAddress = await signer.getAddress()
const isApprovedForAll = await nftContract.isApprovedForAll(signerAddress, activeChain.NFT_MARKETPLACE_ADDRESS);

const marketContract = new ethers.Contract(activeChain.NFT_MARKETPLACE_ADDRESS, Market.abi, signer);

await marketContract.createMarketItem(activeChain.NFT_ADDRESS, nft.tokenId, price);
Enter fullscreen mode Exit fullscreen mode

It’s also important to note that price is a BigNumber type. When working with crypto values, you’ll need to pay attention to when you’re using a number vs BigNumber or even a string . Personally, I’ve found that you should always use a BigNumber for as long as possible. Only converting to a number or string when you need to display a value to a user.

Buying an NFT

Once you’ve implemented a few contract calls using ethers.js the pattern becomes familiar. Get a signer, connect to a contract, and then perform a function. First, we’ll need a Gallery to display all the NFT available for sale in the marketplace. [TODO add Gallery.tsx without Moralis]

To get the list of items for sale we’ll be querying the Smart Contract for the list of all Market Items usin the fetchMarketItems contract. This is an example of something that could be made more efficient using something like Graph Protocol. That can be a fun exercise for you to improve this project.

The full code is available here [TODO update NFTCard]

// .. parts of the code have been removed for brevity
const buyNFT = async  () => {

// ...
        signer = await getSigner()

        const marketContract = new ethers.Contract(activeChain.NFT_MARKETPLACE_ADDRESS, Market.abi, signer);
        const { price } = nft;

        await marketContract.createMarketSale(activeChain.NFT_ADDRESS, nft.itemId, { value: price});
       // ...

    };
Enter fullscreen mode Exit fullscreen mode

This function has a new type of arguement.

await marketContract.createMarketSale(activeChain.NFT_ADDRESS, nft.itemId, { value: price});
Enter fullscreen mode Exit fullscreen mode

The { value: price} argument is very special here and it’s what sends the eth to the smart contract and is accessed in the smart contract as [msg.value global variable](https://docs.soliditylang.org/en/develop/units-and-global-variables.html?highlight=msg.value#block-and-transaction-properties) (a good further explanation).

Withdrawing your Tokens

After you’ve created the tokens, you’ll want to be able to withdraw it into your account.

Create an [AccountSales](https://github.com/atilatech/arthouse/blob/6fa30541eaeec731325d4a7a53e2393304802ea9/src/components/AccountSales.tsx) component and put it inside the Gallery component.

Recall: the code you’ll be using is in the AccountSales link in the prevous line.

This also follows the smart contract pattern: Get a signer, connect to the contract, call a funtion.

This component has two separate functions that call the market smart contract. getBalance and withdrawCredits. There are lots of optimizations you could do to make this one call and maybe cache the value.

However, for learning purposes and simplicty, we’ve separated them.

// src/components/AccountSales.tsx
const getBalance = async (chainId :string) => {

        const activeChain = new Chain({...CONFIG_CHAINS[chainId]});
/...
        const activeSigner = await getSigner();
        const marketContract = new ethers.Contract(activeChain.NFT_MARKETPLACE_ADDRESS, Market.abi, activeSigner);

        const signerAddress = await activeSigner.getAddress()
        const addressCredits = await marketContract.getAddressCredits(signerAddress);
// ...
    }

const withdrawCredits = async (chainId :string) => {

        const activeChain = new Chain({...CONFIG_CHAINS[chainId]});

        const activeSigner = await getSigner();
        const marketContract = new ethers.Contract(activeChain.NFT_MARKETPLACE_ADDRESS, Market.abi, activeSigner);

        await marketContract.withdrawCredits();

        // in theory, the credits may not be zero and we should fetch the updated value from the blockchain
        // but for simplicity, we can assume that after withdrawing all credits the balance is zero.
        //Users can always trigger another get balance request if they want to see an updated balance.
        setChainCredits({
            ...chainCredits,
            [chainId]: BigNumber.from(0),
        })

    }
Enter fullscreen mode Exit fullscreen mode

Putting it All Together

Now we’ll want to buy the NFT we listed for sale. If you want to truly understand the full market flow. I recommend you create 3 wallets.

Similar to what you did in your tests:

// import ethers is not mandatory since its globally available but adding here to make it more explicity and intuitive
// ...test/contracts/NFTMarket.test.js

describe("NFTMarket", function() {

  let Market, market, marketSigner, NFT, nft, ownerSigner, sellerSigner, buyerSigner, otherSigners;

  before(async function() {
// ...
    [ownerSigner, sellerSigner, buyerSigner, ...otherSigners] = await ethers.getSigners();


  })
Enter fullscreen mode Exit fullscreen mode

Now you see why tests can be so useful?

Putting it All Together Diagram

Screen Shot 2022-05-29 at 10.05.21 PM.png

  1. Owner: This is the wallet that deployed the smart contract and receives the 2.5% commission on each sale.
  2. Seller: This is the wallet that creates the NFT and lists it for sale
  3. Buyer: This is the wallet that buys the NFT

Use a faucet as shown earlier or send eth to the additional accounts you need to fund them with enough money to perform the transactions. Then you can click on the token in the block explorer.

Here is a token that went through all these steps. You can also see this token’s transactions in the block explorer or on Opensea.

Top comments (0)