ERC721 with Royalty Fee
In this course, we will be building on our ERC721 token by adding a royalty fee to it. This will allow us (the creator of the token) to receive a percentage of the sale every time the token is sold.
What is a royalty fee?
A royalty fee is a fee that is paid to the original owner of a token every time it is sold. This is a common feature in the NFT space, and it allows the creator to receive a percentage of the sale every time the token is sold.
What is the motivation behind a royalty fee?
There are a few reasons why the royalty fee is appealing:
- It provides a continuous revenue stream to creators
- It's an incentive for creators to create high-quality content that will have trading value
- Royalty fees also constantly accredit the creator of the token, which is so often overlooked in the digital space
Does this extension alter the ERC721 standard?
The royalty fee proposal is actually separate from the ERC721 standard. It is a separate standard called ERC2981. This standard only specifies a way to signal royalty information and does not enforce its payment. Therefore it does not alter the ERC721 standard, but rather tell us how to implement it if we wanted to.
Setting up our project
Before we start, we need to create a few files.
In the contracts folder:
- Create a new file called
ERC721Royalty.sol
.
In the test folder:
- Create a new file called
ERC721Royalty.ts
.
Writing our smart contract
In the ERC721Royalty.sol
file, add the following code and read the comments:
1// SPDX-License-Identifier: MIT 2pragma solidity ^0.8.18; 3 4import "@openzeppelin/contracts/access/Ownable.sol"; 5import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Royalty.sol"; 6 7contract ERC721RoyaltyToken is ERC721Royalty, Ownable { 8 using Strings for uint256; 9 10 string private _baseTokenURI; 11 12 /** 13 * The token contstructor 14 * @param name Our token name 15 * @param symbol Our token symbol 16 * @param baseURI The base token URI 17 * @param royaltyReceiver_ The address of the royalty receiver 18 * @param royaltyFee_ The royalty fee 19 * @dev The token URI is appended with the token ID to form the full token URI 20 * For example: 21 * - tokenURI = https://mytoken.com/api/ 22 * - token ID = 1 23 * - token's URI = https://mytoken.com/api/1 24 */ 25 constructor( 26 address owner, 27 string memory name, 28 string memory symbol, 29 string memory baseURI, 30 address royaltyReceiver_, 31 uint96 royaltyFee_ 32 ) ERC721(name, symbol) { 33 _transferOwnership(owner); 34 _baseTokenURI = baseURI; 35 _setDefaultRoyalty(royaltyReceiver_, royaltyFee_); // 5% 36 mintNFT(owner, 0); 37 } 38 39 /** 40 * Mints a new NFT 41 * @param recipient The address of the recipient 42 * @return The new token ID 43 */ 44 function mintNFT( 45 address recipient, 46 uint256 tokenId 47 ) public onlyOwner returns (uint256) { 48 _safeMint(recipient, tokenId); 49 return tokenId; 50 } 51 52 /** 53 * Returns the base token URI 54 * In the base ERC721 contract, this function returns an empty string. 55 * We override it to return our own base token URI. 56 */ 57 function _baseURI() internal view override returns (string memory) { 58 return _baseTokenURI; 59 } 60 61 /** 62 * We are overriding this function from the base ERC721 contract. 63 * We want to append .json to the token URI since we'll be storing our token metadata as JSON files. 64 * @dev See {IERC721Metadata-tokenURI}. 65 */ 66 function tokenURI( 67 uint256 tokenId 68 ) public view virtual override returns (string memory) { 69 _requireMinted(tokenId); 70 71 string memory baseURI = _baseURI(); 72 return 73 bytes(baseURI).length > 0 74 ? string(abi.encodePacked(baseURI, tokenId.toString(), ".json")) 75 : ""; 76 } 77 78 function setTokenRoyalty ( 79 uint256 tokenId, 80 address receiver, 81 uint96 feeNumerator 82 ) public onlyOwner { 83 _setTokenRoyalty(tokenId, receiver, feeNumerator); 84 } 85} 86
So let's look at what we did here that is new:
- We imported the
ERC721Royalty
contract and inherited from it.
I want to emphasize that this is the absolute best way to build contracts fast and efficiently - learn to use your resources. Learn what OpenZeppelin provides us with. As we already know, all of their contracts are audited and battle-tested.
As we will see in a bit, the ERC721Royalty
contract also inherits from the ERC2981
contract, which is the royalty fee standard. So let's look at both of these contracts in more detail:
First the ERC2981
contract:
1abstract contract ERC2981 is IERC2981, ERC165 { 2 struct RoyaltyInfo { 3 address receiver; 4 uint96 royaltyFraction; 5 } 6 7 RoyaltyInfo private _defaultRoyaltyInfo; 8 mapping(uint256 => RoyaltyInfo) private _tokenRoyaltyInfo; 9 10 /** 11 * @dev See {IERC165-supportsInterface}. 12 */ 13 function supportsInterface(bytes4 interfaceId) public view virtual override(IERC165, ERC165) returns (bool) { 14 return interfaceId == type(IERC2981).interfaceId || super.supportsInterface(interfaceId); 15 } 16 17 /** 18 * @inheritdoc IERC2981 19 */ 20 function royaltyInfo(uint256 tokenId, uint256 salePrice) public view virtual override returns (address, uint256) { 21 RoyaltyInfo memory royalty = _tokenRoyaltyInfo[tokenId]; 22 23 if (royalty.receiver == address(0)) { 24 royalty = _defaultRoyaltyInfo; 25 } 26 27 uint256 royaltyAmount = (salePrice * royalty.royaltyFraction) / _feeDenominator(); 28 29 return (royalty.receiver, royaltyAmount); 30 } 31 32 /** 33 * @dev The denominator with which to interpret the fee set in {_setTokenRoyalty} and {_setDefaultRoyalty} as a 34 * fraction of the sale price. Defaults to 10000 so fees are expressed in basis points, but may be customized by an 35 * override. 36 */ 37 function _feeDenominator() internal pure virtual returns (uint96) { 38 return 10000; 39 } 40 41 /** 42 * @dev Sets the royalty information that all ids in this contract will default to. 43 * 44 * Requirements: 45 * 46 * - `receiver` cannot be the zero address. 47 * - `feeNumerator` cannot be greater than the fee denominator. 48 */ 49 function _setDefaultRoyalty(address receiver, uint96 feeNumerator) internal virtual { 50 require(feeNumerator <= _feeDenominator(), "ERC2981: royalty fee will exceed salePrice"); 51 require(receiver != address(0), "ERC2981: invalid receiver"); 52 53 _defaultRoyaltyInfo = RoyaltyInfo(receiver, feeNumerator); 54 } 55 56 /** 57 * @dev Removes default royalty information. 58 */ 59 function _deleteDefaultRoyalty() internal virtual { 60 delete _defaultRoyaltyInfo; 61 } 62 63 /** 64 * @dev Sets the royalty information for a specific token id, overriding the global default. 65 * 66 * Requirements: 67 * 68 * - `receiver` cannot be the zero address. 69 * - `feeNumerator` cannot be greater than the fee denominator. 70 */ 71 function _setTokenRoyalty(uint256 tokenId, address receiver, uint96 feeNumerator) internal virtual { 72 require(feeNumerator <= _feeDenominator(), "ERC2981: royalty fee will exceed salePrice"); 73 require(receiver != address(0), "ERC2981: Invalid parameters"); 74 75 _tokenRoyaltyInfo[tokenId] = RoyaltyInfo(receiver, feeNumerator); 76 } 77 78 /** 79 * @dev Resets royalty information for the token id back to the global default. 80 */ 81 function _resetTokenRoyalty(uint256 tokenId) internal virtual { 82 delete _tokenRoyaltyInfo[tokenId]; 83 } 84}
So in a nutshell, this contract allows us to set a default royalty fee for all tokens, and also set a royalty fee and the receiver for a specific token.
Now let's look at the ERC721Royalty
contract:
1abstract contract ERC721Royalty is ERC2981, ERC721 { 2 /** 3 * @dev See {IERC165-supportsInterface}. 4 */ 5 function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721, ERC2981) returns (bool) { 6 return super.supportsInterface(interfaceId); 7 } 8 9 /** 10 * @dev See {ERC721-_burn}. This override additionally clears the royalty information for the token. 11 */ 12 function _burn(uint256 tokenId) internal virtual override { 13 super._burn(tokenId); 14 _resetTokenRoyalty(tokenId); 15 } 16}
This contract inherits from the ERC2981
contract and the ERC721
contract. It overrides the _burn
function from the ERC721
contract to clear the royalty information for the token since it is being burned.
-
In the contructor, we set the default royalty information for all tokens. We can set the specific royalty fee and receiver for a specific token by calling the
setTokenRoyalty
function as we will see in a bit. -
We also changed the way token ids are handled. Instead of monitoring the total supply and automatically updating the token ids, we are instead allowing the caller to pass in the token id.
-
Lastly, we added a
setTokenRoyalty
function that allows us to set the royalty fee and receiver for a specific token id. This function is only accessible by the owner of the contract.
Writing our tests
In the ERC721Royalty.ts
file, add the following code:
1import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; 2import { expect } from "chai"; 3import { ethers } from "hardhat"; 4 5describe("ERC721", function () { 6 async function deployERC721Fixture() { 7 const [owner, account_1] = await ethers.getSigners(); 8 const ERC721RoyaltyToken = await ethers.getContractFactory( 9 "ERC721RoyaltyToken" 10 ); 11 const erc721RoyaltyToken = await ERC721RoyaltyToken.deploy( 12 owner.address, 13 "My NFT", 14 "MNFT", 15 "https://example.com/", 16 owner.address, 17 "500" 18 ); 19 return { erc721RoyaltyToken, owner, account_1 }; 20 } 21 22 describe("Deployment Tests", function () { 23 it("Should set the right owner", async function () { 24 const { erc721RoyaltyToken, owner } = await loadFixture( 25 deployERC721Fixture 26 ); 27 expect(await erc721RoyaltyToken.owner()).to.equal(owner.address); 28 }); 29 30 it("Should mint a new token with the correct URI", async function () { 31 const { erc721RoyaltyToken, owner } = await loadFixture( 32 deployERC721Fixture 33 ); 34 expect(await erc721RoyaltyToken.balanceOf(owner.address)).to.equal(1); 35 expect(await erc721RoyaltyToken.tokenURI(0)).to.equal( 36 "https://example.com/0.json" 37 ); 38 }); 39 }); 40 41 describe("Testing default royalty", function () { 42 it("Should return the correct royalty fee info for token 0 if it was listed for 1 ETH", async function () { 43 const { erc721RoyaltyToken, owner, account_1 } = await loadFixture( 44 deployERC721Fixture 45 ); 46 expect( 47 await erc721RoyaltyToken.royaltyInfo(0, ethers.parseEther("1")) 48 ).to.deep.equal([owner.address, ethers.parseEther("0.05")]); 49 }); 50 }); 51 52 describe("Testing custom royalty per token", function () { 53 it("Should return the correct royalty fee info for token 0 if it was listed for 1 ETH", async function () { 54 const { erc721RoyaltyToken, owner, account_1 } = await loadFixture( 55 deployERC721Fixture 56 ); 57 await erc721RoyaltyToken.setTokenRoyalty(0, account_1.address, "1000"); // 10% 58 expect( 59 await erc721RoyaltyToken.royaltyInfo(0, ethers.parseEther("1")) 60 ).to.deep.equal([account_1.address, ethers.parseEther("0.1")]); 61 }); 62 }); 63});
Since we extended our contract, we have a few more things to test:
- We are making sure that the default royalty is being set correctly on deployment. To do this, we are calling the
royaltyInfo
function and passing in the token id and the sale price. We are expecting the royalty fee to be 5% of the sale price and the receiver to be the owner of the contract. Since the sale price is 1 ETH, we are expecting the royalty fee to be 0.05 ETH.
We set the default royalty fee to be 5% when we call the deployment function in the
deployERC721Fixture
function. We pass in the receiver and fee numerator as the last two arguments.
- Next we are changing the royalty fee for token 0 to be 10% and also the receiver to be account_1. We are then calling the
royaltyInfo
function again and expecting the royalty fee to be 10% of the sale price and the receiver to be account_1.
Compiling and running our smart contracts
To run our tests and make sure everything works, run the following command in your terminal:
1npx hardhat test
We have successfully implemented the royalty fee extension to our ERC721 token! Let's deploy it to the test network.
Deploying our smart contract
Create a new deploy file in the scripts folder called deploy-erc721royalty.ts
and add the following code:
1// We require the Hardhat Runtime Environment explicitly here. This is optional 2// but useful for running the script in a standalone fashion through `node <script>`. 3// 4// You can also run a script with `npx hardhat run <script>`. If you do that, Hardhat 5// will compile your contracts, add the Hardhat Runtime Environment's members to the 6// global scope, and execute the script. 7import hre from "hardhat"; 8 9const config = { 10 owner_address: "your address here", 11 token_name: "My Awesome NFT", 12 token_symbol: "MANFT", 13 base_uri: "ipfs://QmfAJoYp518dvdRg87jMeTMtzzs7AzXS4XjasfhTTLcWyB/", 14 royalty_receiver: "your address here", 15 royalty_fee: "500", 16}; 17 18async function main() { 19 const { 20 owner_address, 21 token_name, 22 token_symbol, 23 base_uri, 24 royalty_receiver, 25 royalty_fee, 26 } = config; 27 28 console.log(`Deploying token ${token_name}. Owner: ${owner_address}...`); 29 30 const Token = await hre.ethers.getContractFactory("ERC721RoyaltyToken"); 31 const token = await Token.deploy( 32 owner_address, 33 token_name, 34 token_symbol, 35 base_uri, 36 royalty_receiver, 37 royalty_fee 38 ); 39 40 console.log(`Deployed token. Owner: ${owner_address}`); 41 42 console.log("Waiting 1 minute for Etherscan to index the contract..."); 43 await new Promise((r) => setTimeout(r, 60000)); 44 45 console.log("Verifying contract..."); 46 await hre.run("verify:verify", { 47 address: token.getAddress(), 48 constructorArguments: [ 49 owner_address, 50 token_name, 51 token_symbol, 52 base_uri, 53 royalty_receiver, 54 royalty_fee, 55 ], 56 }); 57 58 console.log("Contract verified! 🎉"); 59 console.log( 60 `Please find the verified contract on Etherscan: https://sepolia.etherscan.io/address/${token.getAddress()}` 61 ); 62} 63 64// We recommend this pattern to be able to use async/await everywhere 65// and properly handle errors. 66main().catch((error) => { 67 console.error(error); 68 process.exitCode = 1; 69});
And that's it! Now to deploy our contract, run the following command in your terminal:
1npx hardhat run scripts/deploy-erc721royalty.ts --network sepolia
Conclusion
In this course, we learned how to add a royalty fee to our ERC721 token. This contract is now marketplace ready and can be displayed on OpenSea!