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
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
- How to write Solidity smart contract code
- How to write tests for smart contracts using Hardhat and Waffle
- How to dynamically deploy your smart contract to different blockchains using Hardat
- 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
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
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.
- 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.
- 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": ""
}
}
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
- 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
},
}
}
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;
}
}
If you get the following error in VS Code
file import callback not supported
Do the following:
- Install the Solidity extension in Visual Studio Code
- 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);
})
})
})
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
- List an item for sale
- Sell an item
- Buy an item
- 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:
- A seller has the ability to unlist an item
- Instead of paying a listing fee, the marketplace charges a 2.5% commission
- 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);
}
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);
}
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:
- https://spin.atomicobject.com/2021/08/16/reentrancy-guard-smart-contracts/
- https://docs.openzeppelin.com/contracts/4.x/api/security
- https://quantstamp.com/blog/what-is-a-re-entrancy-attack
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
- Check if something can happen:
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");
- Perform the internal state effects of that interaction:
address seller = idToMarketItem[itemId].seller;
_allowForPull(seller, sellerPayment);
- etc.
- Perform the external interactions
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:
- OpenZeppelin implementation of Pull Payment Strategy
- OpenZeppelin implementation of Payment Splitting
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');
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();
})
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]);
})
})
The 3 things we’re testing here is:
- When an item is listed. Seller's token balance should decrease and market’s token balance should increase.
- When an item is sold. Market token balance should decrease and buyer’s token balance should increase.
- 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
}
}
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);
}
);
// 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
}
/* 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,
}
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=""
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
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.
- Ethereum Rinkeby
- Binance Testnent
- Polygon Testnet
Adding Blockchains to your Metamask
- Go to the relevant block explorer and click Add [Network Name] at the bottom right of the page.
- https://testnet.bscscan.com/
- https://mumbai.polygonscan.com/
- Rinkeby is already on most networks by default
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
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);
});
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
},
//...
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"
},
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));
}
}
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),
}
});
}
}
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()
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("");
}
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();
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;
}
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);
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});
// ...
};
This function has a new type of arguement.
await marketContract.createMarketSale(activeChain.NFT_ADDRESS, nft.itemId, { value: price});
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),
})
}
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();
})
Now you see why tests can be so useful?
Putting it All Together Diagram
- Owner: This is the wallet that deployed the smart contract and receives the 2.5% commission on each sale.
- Seller: This is the wallet that creates the NFT and lists it for sale
- 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)