NFT royalties are ways for creators of NFTs to keep receiving a percentage of sales proceeds whenever their NFTs are sold in a secondary marketplace. This reward system allows creators to continue benefiting from their work even after selling it.
Before the introduction of ERC-2981, every marketplace had its own royalty system. This made tracking of royalties difficult for creators. Therefore, royalty payouts were inconsistent and sometimes completely ignored.
ERC-2981 introduced a single, interoperable, on-chain interface that automatically calculates royalty payouts and notifies the marketplace on whom to pay royalties and how much royalty they should receive.
This guide will walk you step-by-step through everything you need to implement real, marketplace-ready NFT royalty with the ERC-2981 standard in Solidity. Here is what you will learn in this post:
✅Why marketplaces need a unified royalty standard.
✅How ERC-2981 solves the royalty fragmentation problem.
✅A walk-through of setting up Hardhat.
✅How to install Openzeppelin’s contract library.
✅Extending your NFT smart contract from ERC-721 and ERC-2981.
✅Implementing the right constructor logic to allow marketplace compatibility and prevent interface conflicts errors.
✅Setting up default and per token royalties.
✅Understanding basis points.
✅How to test royalty implementation with Hardhat.
✅Smart contract deployment and verification on Etherscan.
You can get the full code for this project from my GitHub
Before I go into proper details, listing the prerequisites that you need for this project and the general implementation of the royalty logic, I would like you to understand what ERC-2981 is, how it came about, why it was introduced, why it became the standard for royalty logics, how it works (technically), and its benefits over custom royalty logic.
ERC-2981 (Ethereum Request for Comment) is an NFT royalty standard that introduced a unified, market-friendly, standardized, and universally accepted way for NFT marketplaces to signal how royalties are distributed. It introduced a compatible way of calculating how much royalties NFT creators should receive for their work anytime it is sold in a secondary market, the address that should receive the payouts, and many more.
Before the introduction of the ERC-2981 standard, royalty logics were fragmented and inconsistent. NFT marketplaces struggled to interpret royalty information. ERC-2981 was introduced to solve this problem by:
✅ Defining a standard function that marketplaces can call to get royalty information
✅Defining a standard royalty metadata
✅Ensuring that marketplaces follow the same interface for royalty implementation.
This allows NFT marketplaces to calculate and route royalty payouts to creators, DAOs, and multi-signature wallets with ease, without implementing special logics.
With ERC-2981, smart contracts can set and adjust global royalties and token-specific royalties as required.
Creators gain a predictable royalty payout logic, which makes royalty rules transparent and consistent. Most major NFT marketplace including OpenSea and Magic Eden, rely on the ERC-2981 standard.
From the image above, the marketplace smart contract follows these steps to implement royalty logics with ERC-2981:
1️⃣It calls a royaltyInfo function whenever there is a resale of an NFT.
royaltyInfo(tokenId, salePrice)
This function is built in by default to allow the smart contract know how much to send and who to send it to. This is possible because the royaltyInfo function returns a receiverAddress variable and a royaltyAmount variable.
2️⃣ During resale of the NFT, the smart contract calculates the royalty percentage with basis points. Then it deducts the royalty amount from the sale’s proceeds. The remaining portion becomes the seller’s payout.
3️⃣The royalty amount is sent to the creator, while the seller’s payout gets sent to the seller of the NFT in the secondary market.
This is basically how royalty logics work with the ERC-2981 standard.
While explaining the steps involved in setting up royalty logics with ERC-2981, I mentioned basis points. This is a simple explanation of what basis points are.
Basis points (bps) are units of measurement used in expressing very small percentages. One basis point is equivalent to 0.01 percent, 100 basis points is equivalent to 1%, and so on.
Basis points are used in NFT marketplaces to calculate the percentage of the total sale’s proceeds that will be sent to the NFT’s creator as royalty. By doing something like this:
_setTokenRoyalty(newTokenId, msg.sender, 1000);
From the code above, you are asking the marketplace to set a royalty rate of 1000 bps (which is equivalent to 10% ) for this NFT.
This implies that, when the NFT sells for maybe 1ETH in the secondary market, then the creator of the nft will receive 10% of 1ETH, which should be around 0.1ETH or thereabout.
There are certain tools and libraries that you need to follow along with the context of this article. They include:
🧑💻 Code Editor: an essential tool for writing and editing code (including smart contract code). There are a good number of code editors out there with their own benefits, but over the years, I have come to love Visual Studio Code (VSCode), and I highly recommend it too.
VS Code is a good choice because of its support for Solidity and the availability of Solidity extensions and plugins that you can use.
If you don’t have VS Code, you can follow this link to download it, and then follow the setup instructions to set it up.
⚙️ Smart Contract Development Framework: I am going to make use of Hardhat in this tutorial. The reason for this is that Hardhat syntax is simple to learn and easy to teach. So it is an important plus if you already know a little Hardhat. But it is still fine if you don’t
📦Nodejs and Package managers: You also need a package manager npm/pnpm/yarn for installing Solidity tools, Hardhat plugins, and other important packages like openzeppelin/contracts
⚠️ Solidity Version Requirements: ERC-2981 was recently introduced, so you need to have a version of Solidity that supports it, and that should be at least 0.8.0 or a newer version.
We are also going to make use of royalty extensions of openzeppelin/contracts which require the same version of Solidity. I will show you how to assign the right version of Solidity to your project after setting up your project with Hardhat.
📚Libraries: Just like I mentioned earlier, we will make use of openzeppelin/contracts to access theERC721and ERC2981 standards. Openzeppelin Integrates well with major NFT marketplaces, and it will help you follow the EIP-2981 compliant guide. (EIP-2981 is the proposal document for the ERC-2981 standard)
Before we dive into the technical part of integrating the royalty logic, you need to have an understanding of:
✅ What smart contracts are and how they work.
✅ What State variables are
✅ Interface and inheritance in Solidity
Although this article will try to explain things at a beginner's level, I still need you to understand these things to follow along smoothly.
Before we begin, confirm that you have npm and node installed, by running these commands, in your command prompt or your Visual Studio Code integrated terminal.
npm --version
node --version
These commands should return the version of npm and node that you have in your system. Or it should return an error if you do not have them in your system.
When you install node, npm Gets installed with it as well. So if you get an error, you can simply head over to the Node.js installation page and download the LTS version of nodeand follow the setup instructions to install and set up node on your computer.
After that, you will see a response like the one below when you run those commands above.
Now that you have confirmed you have node and npm installed, you need to initialize a nodejs project with a package.json file by running npm init -y
This command will create a package.json file inside your project directory, and you will get a response like the one below in your VSCode terminal:
The next step is to install Hardhat with the command below:
npm install hardhat
Anode_modules folder will be added to your project directory. This folder stores all the packages (dependencies) that your JavaScript or Node.js project needs to run.
Whenever you install a package using npm (e.g., npm install express), npm downloads that package — along with any dependencies it requires — and places them inside the node_modules directory.
A package-lock.json file was also created to record the exact versions of every installed dependency so that anyone who installs the project later gets the same setup, ensuring consistency and preventing version-related bugs.
After installing Hardhat, you can now run the command below to create a Hardhat project:
npx hardhat --init
This command will start the hardhat creation dialogue in the terminal. You can choose how you want your hardhat project to be by answering the setup prompts.
First, you will be greeted with a welcome message and a prompt to choose a hardhat version for your project. You have the option between Hardhat 2 and Hardhat 3.
Hardhat 3 is still in its beta stage, and might be harder to follow along in a tutorial like this. So I suggest you go with the hardhat version 2. Move the selector to Hardhat 2 and click Enter.
It will ask you where you want your Hardhat project to be. Just click on the enter button to select your project’s folder.
You will be given a variety of options to choose the type of project you want to create.
I want us to keep this project simple, so that you can follow along (no fancy code). So, I recommend we go with A Javascript project using Mocha and Ethers.js without ESM. This will help me go straight to the point faster without the need to explain new logic.
Select the first option (A JavaScript project using Mocha and Ethers.js)
Then it will initialize your project for you and scaffold (auto-create) a Hardhat template project (you will notice that some folders were added to your project’s directory)
These are coming from Hardhat. You can see that you have a contracts folder; this is where you will create your Solidity smart contract files and write smart contract code in them.
You also have a test folder that will contain your test code for testing your smart contract. Then you can see the .gitignore file, a hardhat.config.js file, and the package.json file. You will edit most of these folders later in this tutorial.
You will also notice that hardhat suggested some dependencies for you to install. Install them by clicking Enter.
These packages are important for setting up/configuring your hardhat project, setting up test and deployment scripts, and running the sample project.
Once the installation completes, you will see a result similar to the one below:
You can confirm that you have correctly installed these packages by going under the dependencies section in your package.json file, and you will see them listed like this:
Now, you can also install the openzeppelin/contract library. by running:
npm install @openzeppelin/contracts
Follow these steps to set a default royalty logic in your NFT marketplace smart contract:
1️⃣ Delete the Lock.sol file inside the contracts folder and create a new file inside the contracts folder. You can call your file whatever you want, but it should end with a .sol extension.
For instance, if you choose to call your file NFTMarketplace, then you should name your file NFTMarketplace.sol
2️⃣Now add these two lines of code at the top of your smart contract file (the file you just created)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
These two lines of code contain two separate declarations. The first line
✅// SPDX-License-Identifier: MIT: is a license declaration that tells users, auditors, and tools the open-source license that your smart contract is using. There are different types of licenses, like GPL-3.0, Apache 2.0, MIT, and so on. But this contract is using the MIT license, which you can see because MIT was added to the license identifier.
This is the standard way to declare a license identifier for your Solidity smart contract. At the top of your smart contract file, you add the license identifier so that other people and platforms will know how to use your code.
On the second line, we have:
✅pragma solidity ^0.8.20; : This line tells the compiler what version of Solidity it should use. The contract must be compiled with a Solidity version within the specified range ^0.8.20. This implies that the solidity version must be equal to or higher than 0.8.20 but not upto 0.9.0 (≥ 0.8.20 < 0.9.0).
This range is enforced by the caret (^) symbol, to ensure that your contract compiles with the correct Solidity version, prevent unexpected errors from version changes, and provide some built-in features available from the specified version.
Combining these two lines of code, we now have a standard license declaration that is important for verification and legal clarity, and a compiler version range specifier, which is important to ensure safe and consistent compilation.
3️⃣ The next step is to import the necessary modules from the OpenZeppelin Contracts library, which provides secure, audited implementations of common Ethereum standards.
The first module you will import is ERC721URIStoragean OpenZeppelin extension of the standard ERC721 NFT interface that adds built-in storage for token metadata (token URIs).
Normally, ERC721 only defines what an NFT is and how it behaves, but it does not specify how you store metadata (like the image, name, description, etc.).
ERC721URIStorage Stores token URIs that point to an NFT’s metadata on-chain in the contract’s storage by providing a helper function: _setTokenURI(tokenId, uri). This helper function lets you assign a metadata URI to a specific NFT, and save the URI inside the contract (on Ethereum or whichever chain you deploy to).
This makes each NFT’s metadata easy to manage directly in the contract. To import the ERC271URIStorage contract from OpenZeppelin, add the code below to the smart contract file:
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
The next module you need to import is ERC2981. This module provides utility functions for retrieving royalty details, as well as configuration functions for defining royalty settings across the entire collection.
Add the code below to your smart contract file to import it:
import "@openzeppelin/contracts/token/common/ERC2981.sol";
4️⃣ Next, you have to declare your smart contract and inherit the necessary modules:
contract MyRoyaltiesNFT is ERC721URIStorage, ERC2981{
uint256 private _nextTokenId;
uint256 public mintFee = 0.01 ether;
mapping(uint256 => uint256) public tokenPrices;
//Other smart contract codes
}
The code above:
✅Defines a smart contract called MyRoyaltiesNFT (You can choose any name you prefer) This contract inherits from ERC721URIStorage and ERC2981 which respectively provides:
✔ Storage and management of NFT metadata (token URIs)
✔ Royalty standard implementation
After defining the smart contract, you:
✅ Defined a private variable called _nextTokenId (uint256 private _nextTokenId;). This is a counter variable that will be used later in this project to assign token IDs to newly minted NFTs.
Each time a user mints an NFT, the counter (_nextTokenId) should increase by 1. This ensures that every NFT gets a unique token ID and prevents duplicates
✅Defined a public variable (mintFee) that sets the required cost (0.01 ETH) for minting an NFT.
5️⃣. The next step is to create a constructor for your smart contract and set up a default royalty logic.
This constructor will run once when the contract is deployed, and it will be responsible for initializing key contract settings like the collection’s name and symbol.
In our case, we will:
✔ Assign the NFT collection’s name and symbol
✔ Set the starting value for _nextTokenId counter
✔ Define the default royalty configuration for our marketplace.
With this constructor.
Add the code below to declare the constructor for your smart contract:
constructor() ERC721("MyRoyaltiesNFT", "MRNft") {
_nextTokenId = 1;
_setDefaultRoyalty(msg.sender, 500); // 5%
}
From the code above, you declared a constructor that:
✅ initializes the NFT collection with a name (MyRoyaltiesNFT), and a symbol (MRNft).
✅ sets the starting token ID to 1 so that the first minted NFT will have an ID of 1 instead of 0
✅ sets a 5% default royalty for all NFTs with the contract deployer’s address set as the receiver of the royalty payment (_setDefaultRoyalty(msg.sender, 500);)
With this, you have a default royalty logic for your marketplace, but we will override this default royalty logic with a per-token royalty configuration.
Your contract should only use the default royalty settings when the user fails to set a per-token royalty when they mint their NFTs.
Your smart contract code should look like this now:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/token/common/ERC2981.sol";
contract MyRoyaltiesNFT is ERC721URIStorage, ERC2981 {
uint256 private _nextTokenId;
uint256 public mintFee = 0.01 ether;
// Store the mint price for each NFT
mapping(uint256 => uint256) public tokenPrices;
//Other smart contract codes
constructor() ERC721("MyRoyaltiesNFT", "MRNft") {
_nextTokenId = 1;
_setDefaultRoyalty(msg.sender, 500); // 5%
}
}
In this section, you will set up per-token royalties for your Solidity NFT marketplace smart contract.
1️⃣add this code below to the smart contract to declare a mint function:
function mint( string calldata tokenURI,
address royaltyReceiver,
uint96 royaltyFeeNumerator,
uint256 price
) external payable returns (uint256) {
From the code above, you:
✅ Declared a mint function that will create a new NFT whenever it's called
✅Defined three parameters that the function accepts:
✔ tokenURI — a string that points to the NFT’s metadata (stored on IPFS or another storage platform).
✔royaltyReceiver — the address that will receive royalty payments for this specific token.
✔ royaltyFeeNumerator — a value representing the royalty percentage using basis points, where 500 = 5%, 1000 = 10%, etc.
These parameters are important because they control the NFT’s metadata and its royalty configuration.
✅ Marked the function as external payable. This allows:
✔ Calls from users, dApps, and other contracts, but not from inside the contract itself. So functions within this smart contract cannot call this function.
✔ The function to receive ETH, which is required because your contract charges a minting fee.
✅ Specified a return value. The mint function will return uint256 tokenIdafter minting completes, so the line returns (uint256) ensures that this function returns the ID of the newly minted NFT.
2️⃣ Add the actual minting logic and set the per-token royalty inside the body of the mint function with the code below:
require(msg.value >= mintFee, "Not enough ETH to cover minting fee");
require(royaltyFeeNumerator <= 5000, "Royalty too high");
require(price > 0, "NFT price must be greater than 0");
uint256 tokenId = _nextTokenId++;
_mint(msg.sender, tokenId);
_setTokenURI(tokenId, tokenURI);
tokenPrices[tokenId] = price;
if (royaltyReceiver != address(0)) {
_setTokenRoyalty(tokenId, royaltyReceiver, royaltyFeeNumerator);
}
return tokenId;
}
From the code above, you:
✅ Added a check to ensure that the minting fee gets paid first before allowing users mint NFTs. require(msg.value >= mintFee, “Not enough ETH to cover minting fee”);. This is important to enforce payment of minting fees and allow the marketplace earn money from NFT minters.
✅ Checked the royalty percentage set by the creator. require(royaltyFeeNumerator <= 5000, “Royalty too high”); To prevent unnecessarily high royalty settings. You have to prevent creators from setting an unreasonable amount of royalties for their NFTs (this will discourage buyers). The check above ensures that the royalty percentage is below 50% (5000 bps = 50%). If the royalty percentage is more than or equal to 50%, the transaction will revert with a “Royalty too high” error
✅ Added require(price > 0) to ensure a price is set for the NFT. If the creator fails to set a price for their NFT, then the transaction will revert with the “NFT price must be greater than 0” error.
✅ Increased the _nextTokenId value by 1 and assigned its value to the tokenId variable uint256 tokenId = _nextTokenId++;. This is an important step that lets you track minted NFTs by assigning unique IDs to them.
✅ Minted the nft by calling the _mint( ) helper function from ERC721URIStorage and assigned ownership of the NFT to the mint function caller and a unique ID to the NFT (tokenId). _mint(msg.sender, tokenId);.
✅ Set the metadata URI for the NFT. _setTokenURI(tokenId, tokenURI);. This URI points to a JSON description of the NFT, which will contain its name, image, attributes, and so on.
✅ stored the price of the NFT in a tokenPrices mapping tokenPrices[tokenId] = price;
✅ Set the per-token royalty configuration for the NFT. _setTokenRoyalty(tokenId, royaltyReceiver, royaltyFeeNumerator);. This will allow the creator to define how much royalty they want to receive from this NFT’s resale. But before implementing the per-token royalty, you checked if the creator provided a valid address to receive the royalty payout. if (royaltyReceiver != address(0)).
If the creator fails to provide a valid royaltyReceiver address, then the secondary marketplace will fall back to the default royalty configuration of sending royalties to the smart contract deployer’s address.
✅ Returned the ID of the NFT that was just minted. return tokenId;
Altogether, your mint function should look like this:
function mint(string calldata tokenURI, address royaltyReceiver,uint96 royaltyFeeNumerator, uint256 price) external payable returns (uint256) {
require(msg.value >= mintFee, "Not enough ETH to cover minting fee");
require(royaltyFeeNumerator <= 5000, "Royalty too high");
require(price > 0, "NFT price must be greater than 0");
uint256 tokenId = _nextTokenId++;
_mint(msg.sender, tokenId);
_setTokenURI(tokenId, tokenURI);
tokenPrices[tokenId] = price;
if (royaltyReceiver != address(0)) {
_setTokenRoyalty(tokenId, royaltyReceiver, royaltyFeeNumerator);
}
return tokenId;
}
To start, let me explain what supportsInterface is. supportsInterface Is a function defined by ERC165 standard. It is a way for smart contracts that implements ERC721 and ERC2981 Standards to declare the features they support, so that other marketplaces, wallets, and contracts can interact with them.
For instance, when you deploy your smart contracts, and it tries to interact with other NFT marketplaces, they do something like this:
supportsInterface(0x80ac58cd) // ERC721 interface ID
supportsInterface(0x2a55205a) // ERC2981 interface ID
This checks your smart contract to see if it supports ERC721 standard or ERC2981 standard respectively.
If your contract returns true to any of these checks, the marketplace, like Opensea, can interact with it accordingly.
But here is the problem: when your contract inherits ERC721URIStorage and ERC2981You have two interfaces that implement the same thing (supportsInterface).
Therefore, when other marketplaces check for features that your smart contract supports, they see twosupportsInterface, and they get confused. With this confusion, they might pick one feature over the other.
Your NFT might be available, but the royalty logic may not be fully or correctly implemented.
To avoid this error, you must override the supportsInterface and combine both the ERC721URIStorage and ERC2981 interfaces together
This is how you do it:
function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721URIStorage, ERC2981) returns (bool){
return super.supportsInterface(interfaceId);
}
From the code above, you used override(ERC721URIStorage, ERC2981) to tell Solidity that you want to override both ERC721URIStorage, and ERC2981. Then you combined ERC721URIStorage, and ERC2981 supportsInterface together (return super.supportsInterface(interfaceId);).
So, your complete NFT marketplace smart contract code should look like this:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/token/common/ERC2981.sol";
contract MyRoyaltiesNFT is ERC721URIStorage, ERC2981 {
uint256 private _nextTokenId;
uint256 public mintFee = 0.01 ether;
// Store the mint price for each NFT
mapping(uint256 => uint256) public tokenPrices;
//Other smart contract codes
constructor() ERC721("MyRoyaltiesNFT", "MRNft") {
_nextTokenId = 1;
_setDefaultRoyalty(msg.sender, 500); // 5%
}
function mint( string calldata tokenURI, address royaltyReceiver, uint96 royaltyFeeNumerator, uint256 price) external payable returns (uint256) {
require(msg.value >= mintFee, "Not enough ETH to cover minting fee");
require(royaltyFeeNumerator <= 5000, "Royalty too high");
require(price > 0, "NFT price must be greater than 0");
uint256 tokenId = _nextTokenId++;
_mint(msg.sender, tokenId);
_setTokenURI(tokenId, tokenURI);
tokenPrices[tokenId] = price;
if (royaltyReceiver != address(0)) {
_setTokenRoyalty(tokenId, royaltyReceiver, royaltyFeeNumerator);
}
return tokenId;
}
function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721URIStorage, ERC2981) returns (bool){
return super.supportsInterface(interfaceId);
}
}
Now, how do you test this out to see if the creator or the marketplace actually gets the royalty from secondary sales? Well, we can achieve this by creating a function that simulates a secondary sale of the nft:
function simulateSecondarySale(uint256 tokenId) external payable {
address seller = ownerOf(tokenId);
require(seller != msg.sender, "Buyer must differ");
// Always use stored sale price
uint256 salePrice = tokenPrices[tokenId];
require(salePrice > 0, "NFT's price must be greater than 0");
// Buyer MUST pay exactly the NFT price
require(msg.value == salePrice, "Incorrect payment amount");
// Get royalty info from ERC2981
(address royaltyReceiver, uint256 royaltyAmount) = royaltyInfo(tokenId, salePrice);
uint256 sellerAmount = salePrice - royaltyAmount;
// Pay royalties
if (royaltyReceiver != address(0) && royaltyAmount > 0) {
(bool sentRoyalty, ) = payable(royaltyReceiver).call{value: royaltyAmount}("");
require(sentRoyalty, "Royalty payment failed");
}
// Pay seller
(bool sentSeller, ) = payable(seller).call{value: sellerAmount}("");
require(sentSeller, "Seller payment failed");
// Transfer NFT
_transfer(seller, msg.sender, tokenId);
}
From the code above:
1️⃣ You declared a public function called simulateSecondarySale.
✅ This function takes tokenId as a parameter so that you can use it later in the function’s body to identify the NFT that is being sold.
✅ You marked this function as payable, so that it can receive ether from the buyer.
2️⃣ Then you retrieved the address of the NFT owner: address seller = ownerOf(tokenId); And assigned this value to a seller variable. This variable will be used to track the NFT’s seller.
3️⃣ You checked the caller of this function, and you ensured that the caller is not the same person as the seller: require(seller != msg.sender, “Buyer must differ”); This will prevent NFT owners from trying to buy their NFTs again.
4️⃣ After that, you retrieved the NFT’s price that the user attached to the NFT when they called the mint function. This price was stored in a mapping called tokenPrice. Mappings are just like objects in JavaScript; they store values in key-value pairs.
5️⃣ After retrieving the NFT’s price and assigning it to a salePrice variable, you added a check to make sure that the NFT actually has a price: require(salePrice > 0, “NFT's price must be greater than 0”);. Although we previously enforced this check in the mint function (require(price > 0, “NFT price must be greater than 0”);), we still have to perform a double check, just in case the NFT’s price was accidentally omitted. This prevents accidental sales of NFTs with 0 price.
6️⃣ After that, you made sure the buyer pays exactly the amount set as the NFT’s price: require(msg.value == salePrice, “Incorrect payment amount”);. If the buyer sends less than the NFT’s price, the transaction will revert; if they send more, the transaction will also revert. This will help prevent underpayment and overpayment exploits.
7️⃣ Then you retrieved the royalty info from the smart contract with the ERC-2981 royaltyInfo( )helper function: (address royaltyReceiver, uint256 royaltyAmount) = royaltyInfo(tokenId, salePrice);. This line queries the ERC-2981 system for
✅the NFT’s ID and price: tokenId, salePrice
✅ Who should receive the royalty
Then it returns two values:
✅royaltyReceiver’s address. The address of the royalty receiver
✅royalty amount uint256 royaltyAmount. How much ether will be sent to the royalty receiver’s address based on the royalty percentage? For example, if a 5% royalty was set and the NFT’s price is 1eth, the royalty receiver will receive 0.05eth.
8️⃣ Then you calculated how much the seller will receive after deducting the royalty amount uint256 sellerAmount = salePrice — royaltyAmount;. Then you assigned this value to a variable called sellerAmount. Therefore, if the NFT sells for 1 eth in the secondary marketplace, and the royalty amount is 0.05eth for example, then the seller will receive 0.95eth after deducting the royalty amount.
9️⃣ After that, you checked if the royalty receiver’s address is set (is not 0) and the royalty amount is not 0, before you proceed to pay the royalties to the specified address.
if (royaltyReceiver != address(0) && royaltyAmount > 0) {
(bool sentRoyalty, ) = payable(royaltyReceiver).call{value: royaltyAmount}("");
require(sentRoyalty, "Royalty payment failed");
}
🔟 After sending the royalty amount to the royalty receiver, you sent the remaining sale’s proceed (sellerAmount) to the seller:
(bool sentSeller, ) = payable(seller).call{value: sellerAmount}("");
require(sentSeller, "Seller payment failed");
You also added a check require(sentSeller, “Seller payment failed”); That ensures this transaction succeeds before transferring the NFT’s ownership to the buyer of the NFT in the secondary market: _transfer(seller, msg.sender, tokenId);
In this section, I will teach you step-by-step how to write smart contract test scripts and compile a Solidity smart contract before deploying it to a test network like Seploia.
To test a smart contract code, you have to follow these steps:
1️⃣Your hardhat.config.js file should already have this code; if it doesn’t, edit the code in your hardhat.config.js File to the one below:
require("@nomicfoundation/hardhat-toolbox");
/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
solidity: "0.8.28",
};
These lines of code:
a. Mocha + Chai for testing
b. Ethers.js integration for writing test and deployment scripts
c. Etherscan verification
d. Hardhat tasks and helper functions
Think of the Hardhat toolbox as a starter kit for Ethereum development.
2. Tells the code editor (VSCode) that the object below follows the hardhat config structure with the line /** @type import(‘hardhat/config’).HardhatUserConfig */. This helps prevent configuration mistakes and provides autocomplete for Solidity code and network configuration, but it does not affect run-time behaviour in anyway, you can remove it if you want, and everything will still work very fine. It just enhancesthe development experience.
3. Exports your Hardhat configuration module.exports = {…} so that Hardhat can read it when you run the test command.
4. Sets the solidity version that Hardhat will use in compiling your smart contracts solidity: “0.8.28”. This line tells Hardhat to use version 0.8.28 to compile your smart contracts. This falls within the range you specified in your smart contract.
The next step is to write the test code inside a test file.
When you run the hardhat test command, it looks for a test folder in your project and executes the contents of the test file in it. For instance, if you have a test folder in your project, and there is a sample-test.js file in this folder, Hardhat will run the script inside this sample-test.js file when you run the hardhat test command.
You already have a test folder that was created for you when you initialized your hardhat project. What you need now is a .js file where you can write your test codes.
2️⃣ Inside the test folder, delete the Lock.js file, and create a nft.test.js file:
For this smart contract, you want to test your NFT minting, royalty, and secondary sale simulation logics. So your test structure will likely look like this:
✅Deployment — contract deploys, default royalty set correctly, name/symbol sets correctly
✅Minting — requires mintFee, stores tokenPrices, sets tokenURI, sets per-token royalty
✅royaltyInfo — returns the correct receiver and amount for a given sale price
✅simulateSecondarySale — buyer must pay the correct price, royalty paid to the receiver, seller receives the remainder, ownership transfers
✅Edge cases — reverts on insufficient payment, wrong buyer, zero price, high royalty (> cap)
To run these checks, add this code to your nft.test.js file:
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("MyRoyaltiesNFT", function () {
let MyRoyaltiesNFT, nft;
let owner, alice, bob, royaltyReceiver;
//In Ethers v6: parseEther is top-level and returns bigint
const mintFee = ethers.parseEther("0.01");
beforeEach(async function () {
[owner, alice, bob, royaltyReceiver] = await ethers.getSigners();
// Deploy contract
MyRoyaltiesNFT = await ethers.getContractFactory("MyRoyaltiesNFT");
nft = await MyRoyaltiesNFT.deploy();
await nft.waitForDeployment();
});
it("deploys with correct name, symbol and default royalty", async function () {
expect(await nft.name()).to.equal("MyRoyaltiesNFT");
expect(await nft.symbol()).to.equal("MRNft");
// default royalty: owner, 500 bps => 5%
const salePrice = ethers.parseEther("1");
const royaltyBps = 500n;
const [receiver, amount] = await nft.royaltyInfo(1, salePrice);
expect(receiver).to.equal(owner.address);
const expectedRoyalty = (salePrice * royaltyBps) / 10000n;
expect(amount).to.equal(expectedRoyalty);
});
it("mints token, sets tokenURI, price and per-token royalty", async function () {
const tokenURI = "ipfs://QmExample";
const royaltyBps = 500;
const price = ethers.parseEther("0.5");
await nft.connect(alice).mint(
tokenURI,
royaltyReceiver.address,
royaltyBps,
price,
{ value: mintFee }
);
const tokenId = 1;
expect(await nft.ownerOf(tokenId)).to.equal(alice.address);
expect(await nft.tokenURI(tokenId)).to.equal(tokenURI);
// price stored in contract
expect(await nft.tokenPrices(tokenId)).to.equal(price);
const salePrice = ethers.parseEther("1");
const [rReceiver, rAmount] = await nft.royaltyInfo(tokenId, salePrice);
expect(rReceiver).to.equal(royaltyReceiver.address);
const expectedRoyalty = (salePrice * BigInt(royaltyBps)) / 10000n;
expect(rAmount).to.equal(expectedRoyalty);
});
it("simulateSecondarySale pays royalty and seller and transfers NFT", async function () {
const tokenURI = "ipfs://QmExample";
const royaltyBps = 500n;
const price = ethers.parseEther("1");
await nft.connect(alice).mint(
tokenURI,
royaltyReceiver.address,
Number(royaltyBps),
price,
{ value: mintFee }
);
const tokenId = 1;
expect(await nft.ownerOf(tokenId)).to.equal(alice.address);
const royaltyBefore = await ethers.provider.getBalance(
royaltyReceiver.address
);
await nft.connect(bob).simulateSecondarySale(tokenId, {
value: price,
});
expect(await nft.ownerOf(tokenId)).to.equal(bob.address);
const royaltyAfter = await ethers.provider.getBalance(
royaltyReceiver.address
);
const expectedRoyalty = (price * royaltyBps) / 10000n;
expect(royaltyAfter - royaltyBefore).to.equal(expectedRoyalty);
});
it("reverts if buyer sends wrong amount", async function () {
const tokenURI = "ipfs://QmExample";
const royaltyBps = 500;
const price = ethers.parseEther("1");
await nft.connect(alice).mint(
tokenURI,
royaltyReceiver.address,
royaltyBps,
price,
{ value: mintFee }
);
const tokenId = 1;
await expect(
nft.connect(bob).simulateSecondarySale(tokenId, {
value: ethers.parseEther("0.5"),
})
).to.be.revertedWith("Incorrect payment amount");
});
});
From the code above:
a. MyRoyaltiesNFT(let MyRoyaltiesNFT, nft;): This variable will hold the smart contract’s factory for test deployments.
b. nft (let MyRoyaltiesNFT, nft;): This variable will contain the deployed instance of the smart contract so that you can use it to interact with the smart contract within each test case.
c. owner, alice, bob, royaltyReceiver (let owner, alice, bob, royaltyReceiver;These are test accounts that you will use to call functions in the smart contract. You will assign test Ethereum accounts to these variables later so that they can act as real user accounts.
d. mintFee (const mintFee = ethers.parseEther(“0.01”);): This variable is a constant, and it holds the value of the minting fee that is expected of each user before they can mint NFTs.
4. After defining these global variables, you defined a setup that runs before each test case beforeEach(async function () {….}. This setup ensures that each test case starts with a fresh blockchain state and a clean contract instance. This prevents test errors and bugs.
Inside the test setup:
a. You assigned test Ethereum accounts to the accounts variables (owner, alice, bob, royaltyReceiver). This makes each of the accounts act as different users and interact with your smart contract.
b. You loaded the compiled smart contract MyRoyaltiesNFT = await ethers.getContractFactory(“MyRoyaltiesNFT”); and deployed it to Hardhat's local blockchain nft = await MyRoyaltiesNFT.deploy();. You waited for the deployment to complete and the transaction mined to the blockchain await nft.waitForDeployment(); before writing the test cases.
5. After deploying the smart contract instance and ensuring that the transaction was mined, you wrote your first test scenario it(“deploys with correct name, symbol and default royalty”, async function () {...}. In this test case:
a. You checked if the correct ERC721 metadata (name and symbol) for the smart contract was set correctly. expect(await nft.name()).to.equal(“MyRoyaltiesNFT”);
expect(await nft.symbol()).to.equal(“MRNft”);
b. You checked if the default royalty configuration was set. You ensured that the receiver of the default royalty equals the marketplace owner expect(receiver).to.equal(owner.address); And the royalty amount is exactly 5% of the total sale.
This confirms that the constructor ran correctly.
const expectedRoyalty = (salePrice * royaltyBps) / 10000n;
expect(amount).to.equal(expectedRoyalty);
});
6. Then you wrote the second test case to test the mint function it(“mints token, sets tokenURI, price and per-token royalty”, async function () {...}. This test connects Alice’s test account and uses her address to call the mint function in your smart contract. This is possible because you called the deployed instance of your smart contract await nft.connect(alice).mint(…). This test:
a. checks if Alice was assigned the owner of the NFT that she mints. expect(await nft.ownerOf(tokenId)).to.equal(alice.address);
b. checks if the token URI was set properly
expect(await nft.tokenURI(tokenId)).to.equal(tokenURI);
c. checks if the token’s price was properly stored
expect(await nft.tokenPrices(tokenId)).to.equal(price);
d. checks if the royalty receiver’s address was set correctly expect(rReceiver).to.equal(royaltyReceiver.address); and the royalty amount was properly configured expect(rAmount).to.equal(expectedRoyalty);
This validates the mint function.
7. After testing the mint function, you wrote another test scenario for the simulateSecondarySale function it(“simulateSecondarySale pays royalty and seller and transfers NFT”, async function () {...}. This test case:
a. checks the royalty receiver’s balance before secondary sale to know how much the account holds before receiving royalties.
b. checks if the ownership of the NFT was assigned to Bob after the secondary sale completes expect(await nft.ownerOf(tokenId)).to.equal(bob.address);
c. checks the royalty receiver’s address after the secondary sale completes. const royaltyAfter = await ethers.provider.getBalance(
royaltyReceiver.address
);
d. compares the royalty receiver’s address before and after secondary sale to make sure that they receive the correct amount of royalty
const expectedRoyalty = (price * royaltyBps) / 10000n;
expect(royaltyAfter — royaltyBefore).to.equal(expectedRoyalty);
Notice that you have to call the mint function here again. This is an important step because each test case gets a fresh instance of the smart contract; all transactions mined to the blockchain from the previous test case won’t be available in the smart contract instance for the next test case.
Since you need to have an nft before you can simulate a sale, you have to call the mint function to mint an nft for this test.
9. Finally, you wrote the last test case that checks if the simulateSecondarySale transaction will fail if the buyer sends the wrong amount of payment for the NFT.
With this, your test suite is complete, and you can save the test file and run:
npx hardhat test
To test your NFT marketplace smart contract.
This will automatically compile your NFT marketplace smart contract file and run the test script.
When you run npx hardhat test Hardhat will:
a. Read your hardhat.config.js file
b. load the hardhat toolbox so that you can have access to testing tools, line expect from chai
c. inject ethers, expect, and matchers into your test script
d. run your test file
When your test runs completely, you should see a result like the one below:
This shows that your NFT smart contract is working correctly. The next step is to deploy this smart contract on the Ethereum testnet (Sepolia)
Before deploying your Solidity smart contract, you will need a cryptocurrency wallet to cover the deployment transaction costs.
I prefer MetaMask because of its ease of use and setup. You can either install Metamask as a browser extension on your computer (this is the recommended approach for this tutorial) or you can download the Metamask mobile app on your phone.
Follow these steps to download the Metamask extension on your computer:
4. Click on the Chrome browser, and you will be taken to the Chrome extension page, where you can add the MetaMask extension to your browser
5. Click on the Add to Chrome button. This will start downloading MetaMask. After the download completes, you should see the MetaMask icon in your browser, and you will be redirected to the MetaMask onboarding page.
This is where you can choose to log in to an existing wallet or create a new wallet.
6. Click on Create a new wallet, and select Use secret Recovery Phrase
7. Provide a strong password for your account, and you will be redirected to the secret recovery phrase.
8. Copy your secret recovery phrase and keep it in a secure place where only you have access to it, then click on continue.
9. On the next page, you will be asked to provide the exact phrase that corresponds to the box that is being highlighted. For example, the image below highlights number 6, so I will provide the phrase that was in box number 6.
With these steps, you have successfully created your Metamask wallet.
Follow these steps to switch your Metamask’s network to the Sepolia testnet and receive test ETH for deploying your smart contract to sepolia testnet:
3. You will see the test networks listed among the main networks in the
If you don’t see sepolia network listed automatically in the network dropdown, add it manually with these details:
5. Go back to your wallet home page and select the network dropdown (All popular networks)
6. Switch to the custom tab in the network page and select Sepolia
7. Click the Receive button
8. Copy the Ethereum wallet address
9. Then visit Google Cloud’s web3 faucet
10. Select Ethereum Sepolia from the network selector, paste your Sepolia wallet’s address, and click on the Get Eth button to receive 0.05 Sepolia Eth for testing.
Follow these steps and configure your application so that it can access the Sepolia testnet:
npm install dotenv @nomicfoundation/hardhat-toolbox
2. Create a .env file in the root directory of your project. Inside this file, create these two variables
PRIVATE_KEY=your_metamask_private_key
RPC_URL=https://sepolia.infura.io/v3/YOUR_API_KEY
Replace these values with your Metamask private key and Infura RPC_URL, respectively.
To get your Metamask private key:
3. Then select Accounts details
4. Click on Unlock private keys
This will take you to your private keys page. Copy the Ethereum private key and paste it as the value of your PRIVATE_KEY environment variable inside the .env file
To get your RPC_URL:
a. Go to the Infura website
b. Sign in or sign up with your preferred method
c. Click on My first API key
d. Copy your API key
Make sure SePolia is set under all networks
Replace YOUR_API_KEY in your RPC_URL environment variable’s value with the Infura API key that you just copied
3. Edit your config exports in your hardhat.config.js file by adding the network details.
module.exports = {
solidity: "0.8.20",
networks: {
sepolia: {
url: process.env.RPC_URL,
accounts: [process.env.PRIVATE_KEY],
},
},
};
4. Create a script folder and a deploy.js file in it, then add the code below to the deploy.js file:
const hre = require("hardhat");
async function main() {
// Get the deployer account
const [deployer] = await hre.ethers.getSigners();
console.log("Deploying contract with account:", deployer.address);
console.log(
"Deployer balance:",
hre.ethers.formatEther(await deployer.provider.getBalance(deployer.address)),
"ETH"
);
// Get the contract factory
const MyRoyaltiesNFT = await hre.ethers.getContractFactory("MyRoyaltiesNFT");
// Deploy the contract
const nft = await MyRoyaltiesNFT.deploy();
// Wait for deployment to be mined
await nft.waitForDeployment();
console.log("MyRoyaltiesNFT deployed to:", await nft.getAddress());
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
The code above uses your wallet to deploy your smart contract to the Sepolia network and print the deployed address once it’s live.
From the code above:
Within the async function’s block:
3. You defined an Ethereum account called deployer const [deployer] = await hre.ethers.getSigners();. This account will become the deployer of this smart contract, it will own this smart contract, and be the default royalty receiver. If you deploy this smart contract on Hardhat's local blockchain network, Hardhat will create a fake Ethereum account and assign it to the deployer variable, but on a testnet like Sepolia or a mainnet like the Ethereum mainnet, Hardhat will read your .env file and create a signer account from the private key variable that you provide. That signer becomes the deployer and owner of the smart contract.
4. You logged the deployer’s address console.log(“Deploying contract with account:”, deployer.address); and the deployer’s Eth balance.
console.log(
"Deployer balance:",
hre.ethers.formatEther(await deployer.provider.getBalance(deployer.address)),
"ETH"
);
This is an important step that confirms you are using the right account and you have enough ETH balance for deployment
5. Then you got the smart contract’s factory and assigned it to a variable MyRoyaltiesNFT (const MyRoyaltiesNFT = await hre.ethers.getContractFactory(“MyRoyaltiesNFT”);). Your smart contract factory is like a blueprint that carries details about the smart contract bytecode, ABI, and constructor arguments. It is what you call when you want to deploy your smart contract.
6. Then you deployed the smart contract. const nft = await MyRoyaltiesNFT.deploy();. This code sends a deployment transaction to the blockchain, calls the smart contract constructor to set the contract’s metadata, and return and unconfirmed contract instance.
7. Finally, you wait for the deployment transaction to completely mine and get added to the blockchain await nft.waitForDeployment(); before logging the deployed contract’s address console.log(“MyRoyaltiesNFT deployed to:”, await nft.getAddress());. It is important to confirm that the deploy transaction was complete before logging the deployed address, because this address will be used for so many things, like interaction with a frontend app, verification on Etherscan, and so on. So you have to make sure that the contract exists on the chain first.
8. Then you execute the main function.
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
If the deployment runs completely, the execution will exit neatly. .then(() => process.exit(0)), but if it catches any error, the process will log them out and exit with an error code of 1.
When you run your deployment command, Hardhat will:
Run the command below to test this out and deploy your smart contract to Sepolia testnet:
npx hardhat run scripts/deploy.js --network sepolia
You will see a response like the one in the image below.
This indicates that your smart contract has been deployed to Sepolia, and it shows the address where it was deployed.
Your smart contract is now live on the testnet. You can view it on Etherscan by copying and pasting its address in the etherscan’s search bar.
You can also interact with this smart contract on Remix IDE.
By leveraging ERC-2981 in NFT royalty implementation, you eliminate the guesswork, and inconsistency of using marketplace-specific logics. ERC-2981 allows your smart contract to expose a single standardized on-chain interface that any marketplace with royalty logics can read. This ensures fair and predictable payments of royalties.
In this guide, you learned how to implement marketplace compatible NFT royalties with ERC-2981 standard in Solidity. You also learned how to set default and per token royalties, how to use basis points for royalty calculations, how to test smarts contracts with Hardhat and how to deploy your smart contract on Ethereum.
How to Add Marketplace-Compatible Royalty logics to Your NFT Smart Contracts Using ERC-2981 in… was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.


