Skip to main content

How to Create and Deploy an ERC404 Token

Created on
Updated on
Nov 26, 2024

38 min read

Overview​

This guide provides a detailed examination of the ERC-404 standard, which represents a novel integration of the well-established ERC-20 and ERC-721 standards. It targets developers interested in broadening their expertise in sophisticated smart contract development and deployment strategies on the Ethereum blockchain.

As you progress through this guide, you will learn more about the ERC-404 standard, from the function details to the contract deployment.


Caution with ERC-404 Usage

Please be advised that ERC-404 is an experimental and currently unaudited standard, potentially containing undiscovered vulnerabilities. ERC-404 has not been formalized as an EIP, and it has not passed the rigorous EIP and ERC validation processes. Increasing the risk of fund loss due to unforeseen security flaws. Exercise caution and avoid investing more than you can afford to lose in ERC-404 projects during these preliminary stages.

What You Will Do​


  • Understand the basics of the ERC-404 standard and its unique features
  • Set up a Hardhat project for Ethereum development
  • Write, test, and deploy an ERC-404 token
  • Verify and interact with your contract on the blockchain

What You Will Need​


What is ERC-404?​

ERC-404 is a new unofficial smart contract standard designed by the Pandora team. It aims to merge the characteristics of ERC-20 (fungible tokens) and ERC-721 (non-fungible tokens or NFTs) into a single standard. This standard enables the creation of digital assets that can function both as fungible tokens for use cases such as staking or trading, and as unique non-fungible token to represent unique ownership.

When a user buys a certain amount of ERC-404 tokens or receives them from another wallet, the contract not only updates the balance of fungible tokens but can also mint a unique ERC-721 NFT to the recipient. This NFT could represent a special right, membership, or ownership over a part of a unique asset associated with the fungible tokens. Conversely, selling or transferring the fungible tokens could trigger the transfer of associated NFTs, ensuring that ownership rights are correctly maintained and transferred alongside the fungible token balances.

Key features of ERC-404 include:

  • Hybrid Nature: While ERC-20 focuses on fungible tokens (identical and interchangeable) and ERC-721 on non-fungible (unique and not interchangeable), ERC-404 utilizes both types of token standards, allowing both fungible and non-fungible functionalities within the same smart contract. This functionality is similar to the already existing ERC-1155 standard, which also enables the same type of token operations from a single contract.

  • Native Fractionalization of NFTs: Unlike standard ERC-721, where an NFT represents a whole, indivisible asset, ERC-404 introduces native support for fractional ownership of NFTs. This means users can own a part of an NFT, enhancing liquidity and accessibility for high-value assets.

  • Enhanced Liquidity: By allowing fractional ownership, ERC-404 overcomes one of the main limitations of traditional NFTsβ€”their lack of liquidity. It enables smaller investors to participate in the ownership of high-value assets and facilitates easier trading on exchanges.

  • Dynamic Functionality: ERC-404 tokens can act as either fungible or non-fungible assets depending on the transaction context. For example, when buying or receiving tokens from another user, the contract can automatically allocate ERC-721 NFTs to represent specific ownership rights or achievements while also handling fungible token transactions seamlessly.

ERC-404 Functions​

The ERC-404 introduces a set of functions that allow for the nuanced handling of both fungible and non-fungible token aspects within a single contract. Let's explain each function and component within the ERC-404 contract:

Owner, Spender, and Operator

Before discussing the functions of the ERC-404 contract, let's define some key terms and roles to avoid confusion:

Owner: The entity or address that holds ownership of the tokens. In the context of NFTs (ERC-721 tokens), the owner possesses a unique token. For fungible tokens (ERC-20), the owner holds a certain quantity of the tokens.

Spender: An address that has been granted permission by the owner to transfer a specified amount of the owner's fungible tokens (ERC-20) or a specific NFT (ERC-721) on their behalf.

Operator: An entity or address given approval by the owner to manage all of their tokens, both fungible and non-fungible. This role is broader than that of a spender, as it can encompass management of all the owner's assets within a contract.

Events:​

  • ERC20Transfer: Emitted when a fungible token transfer occurs.
  • Approval: Indicates approval of a spender to withdraw tokens on behalf of the owner.
  • Transfer: Emitted for both ERC-20 and ERC-721 transfers, indicating a token's transfer.
  • ERC721Approval: Similar to Approval, but specifically for ERC-721 token IDs.
  • ApprovalForAll: Emitted when an owner approves an operator to manage all their tokens.

Errors:​

  • NotFound: Indicates a query for a non-existent token ID.
  • AlreadyExists: Thrown if attempting to mint a token with an ID that already exists.
  • InvalidRecipient: Used when a transfer is attempted to the zero address or an otherwise invalid recipient.
  • InvalidSender: Thrown if the sender is not authorized or valid.
  • UnsafeRecipient: Indicates that a recipient contract cannot handle ERC-721 tokens.

Metadata:​

  • name: The name of the token.
  • symbol: The symbol of the token.
  • decimals: Used for fungible tokens to define the smallest unit.
  • totalSupply: The total supply of fungible tokens.
  • minted: Counter for minted tokens, ensuring unique IDs for NFTs.

Mappings:​

  • balanceOf: Maps an address to its balance of fungible tokens.
  • allowance: Maps an owner to an operator and the number of tokens they're allowed to spend.
  • getApproved: Maps a token ID to an approved address for that specific token.
  • isApprovedForAll: Maps an owner to an operator for approval across all tokens.
  • _ownerOf: Internal mapping of token IDs to their owners.
  • _owned: Maps an address to an array of token IDs they own.
  • _ownedIndex: Keeps track of the index of each token ID in the _owned array.
  • whitelist: Maps addresses that are whitelisted from minting or burning tokens.

Constructor:​

Initializes the contract with the name, symbol, decimals, total supply of fungible tokens, and the contract owner.

Functions:​

  • setWhitelist: Allows the contract owner to whitelist addresses, preventing them from minting or burning tokens while transferring tokens.
  • ownerOf: Returns the owner of a specified token ID.
  • tokenURI: (Abstract Function) Should be implemented to return the URI for a token's metadata.
  • approve: Allows a token owner to approve another address to spend a specific amount or token ID on their behalf.
  • setApprovalForAll: Enables or disables approval for an operator to manage all of the caller's tokens.
  • transferFrom: Facilitates the transfer of fungible tokens or a specific NFT from one address to another.
  • transfer: Allows for the transfer of fungible tokens from the caller's address to another.
  • safeTransferFrom (with and without data): Similar to transferFrom but includes checks to ensure the recipient can safely receive NFTs.
  • _transfer: Internal function that handles the logic of transferring fungible tokens, including potential minting or burning of NFTs based on the transferred amount.
  • _getUnit: Returns the unit used for fractional transfers, typically 10^decimals.
  • _mint: Mints a new token ID to a specified address.
  • _burn: Burns the specified token ID from a given address.
  • _setNameSymbol: Allows updating the token's name and symbol.

Creating an ERC-404 Contract​

In this guide, we will use the Ethereum Sepolia testnet. However, the code in this guide is applicable to all EVM-compatible mainnets and testnets like Ethereum, Polygon, and Arbitrum.

Setting Up Your QuickNode Ethereum Node Endpoint​

To build on the Ethereum Sepolia testnet, you'll need an API endpoint to connect with the network. You're welcome to use public nodes or deploy and manage your own infrastructure; however, if you'd like faster response times, you can leave the heavy lifting to us. Sign up for a free account here.

Once you are logged in, click Create an endpoint and then select the Ethereum Sepolia blockchain.

After creating your endpoint, copy the HTTP Provider link and keep it handy, as you'll need it next.

Ethereum Sepolia Node Endpoint

QuickNode Multi-Chain Faucet​

We'll need to get some test ETH in order to pay for the deployment and interaction of our smart contract.

Navigate to the QuickNode Multi-Chain Faucet and connect your wallet or paste in your wallet address. You'll need to select the Ethereum chain and Sepolia network and then request funds.

Project Setup with Hardhat​

Start by initializing a new Node.js project and installing Hardhat, a development environment designed for smart contract development on Ethereum and other EVM-based blockchains.

Step 1: Initialize a new Node.js project​

Open your terminal and navigate to your desired directory. Then, run the following command to create a new Node.js project in the newly created erc404-project directory.

mkdir erc404-project && cd erc404-project
npm init -y

Step 2: Install Hardhat​

In your project directory, run the following command.

npm install --save-dev hardhat

Step 3: Initialize a Hardhat project​

Run the command below in your terminal. Follow the prompts to create a new Hardhat project. Choose to create a basic TypeScript project with all the default options when prompted.

npx hardhat init

Step 4: Install other Packages​

npm install --save-dev @openzeppelin/contracts dotenv

Step 5: Set environmental variables​

The dotenv library is essential to store sensitive data like your private key and your QuickNode endpoint URL. Create a .env file.

echo > .env

Then, open the .env file and paste the following content. Replace YOUR_QUICKNODE_ENDPOINT_HTTP_URL and YOUR_WALLET_PRIVATE_KEY with your QuickNode endpoint URL and your wallet's private key (to sign transactions), respectively.

.env
HTTP_PROVIDER_URL="YOUR_QUICKNODE_ENDPOINT_HTTP_URL"
PRIVATE_KEY="YOUR_WALLET_PRIVATE_KEY"

Step 6: Set Hardhat configuration​

The hardhat.config.ts file includes all settings related to Hardhat, like the Solidity compiler version, networks, etc.

hardhat.config.ts
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import dotenv from "dotenv";

dotenv.config();

const config: HardhatUserConfig = {
solidity: "0.8.20",
networks: {
sepolia: {
url: process.env.HTTP_PROVIDER_URL,
accounts: [process.env.PRIVATE_KEY as string],
},
},
gasReporter: { enabled: true },
};

export default config;

Step 7: Set TypeScript configuration​

Make sure that your tsconfig.json file includes exclude, include, and files properties in addition to compilerOptions property.

tsconfig.json
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"exclude": ["dist", "node_modules"],
"include": ["./test", "./src", "./scripts", "./typechain-types"],
"files": ["./hardhat.config.ts"]
}

Building the Token Contract​

To construct our ERC-404 token contract, we'll develop two separate Solidity contracts:


  • ERC404.sol: This contract implements the ERC-404 standard, defining the core functionality that enables the mixed fungible and non-fungible token features.
  • My404.sol: This is our custom token contract that inherits from ERC404.sol. Here, we can define specific behaviors, tokenomics, and additional features that are unique to our token.

Step 1: Create Solidity contracts​

Run the following command in your project directory.

echo > contracts/ERC404.sol
echo > contracts/My404.sol

Also, you can delete any other contract files under the contracts directory.

Step 2: Modify the ERC404.sol contract​

Open the ERC404.sol with your code editor, and paste the code below into the file.

info

The transferFrom function in the ERC-404 contract conflates fungible and non-fungible token transfers based on the amountOrId parameter without clear distinction, potentially leading to unintended behavior or errors. If amountOrId is intended to represent a fungible token quantity but coincidentally matches an existing token ID, the function could improperly treat the transfer as an NFT operation, altering the ownership of a unique asset rather than transferring a fungible amount. This ambiguity may cause confusion and unintended loss of assets.

Take your time to check the code and related comments to fully understand the functionalities of functions. To check the source of the ERC404 standard code, check here.

contracts/ERC404.sol
//SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

abstract contract Ownable {
event OwnershipTransferred(address indexed user, address indexed newOwner);

error Unauthorized();
error InvalidOwner();

address public owner;

modifier onlyOwner() virtual {
if (msg.sender != owner) revert Unauthorized();

_;
}

constructor(address _owner) {
if (_owner == address(0)) revert InvalidOwner();

owner = _owner;

emit OwnershipTransferred(address(0), _owner);
}

function transferOwnership(address _owner) public virtual onlyOwner {
if (_owner == address(0)) revert InvalidOwner();

owner = _owner;

emit OwnershipTransferred(msg.sender, _owner);
}

function revokeOwnership() public virtual onlyOwner {
owner = address(0);

emit OwnershipTransferred(msg.sender, address(0));
}
}

abstract contract ERC721Receiver {
function onERC721Received(
address,
address,
uint256,
bytes calldata
) external virtual returns (bytes4) {
return ERC721Receiver.onERC721Received.selector;
}
}

/// @notice ERC404
/// A gas-efficient, mixed ERC20 / ERC721 implementation
/// with native liquidity and fractionalization.
///
/// This is an experimental standard designed to integrate
/// with pre-existing ERC20 / ERC721 support as smoothly as
/// possible.
///
/// @dev In order to support full functionality of ERC20 and ERC721
/// supply assumptions are made that slightly constraint usage.
/// Ensure decimals are sufficiently large (standard 18 recommended)
/// as ids are effectively encoded in the lowest range of amounts.
///
/// NFTs are spent on ERC20 functions in a FILO queue, this is by
/// design.
///
abstract contract ERC404 is Ownable {
// Events
event ERC20Transfer(
address indexed from,
address indexed to,
uint256 amount
);
event Approval(
address indexed owner,
address indexed spender,
uint256 amount
);
event Transfer(
address indexed from,
address indexed to,
uint256 indexed id
);
event ERC721Approval(
address indexed owner,
address indexed spender,
uint256 indexed id
);
event ApprovalForAll(
address indexed owner,
address indexed operator,
bool approved
);

// Errors
error NotFound();
error AlreadyExists();
error InvalidRecipient();
error InvalidSender();
error UnsafeRecipient();

// Metadata
/// @dev Token name
string public name;

/// @dev Token symbol
string public symbol;

/// @dev Decimals for fractional representation
uint8 public immutable decimals;

/// @dev Total supply in fractionalized representation
uint256 public immutable totalSupply;

/// @dev Current mint counter, monotonically increasing to ensure accurate ownership
uint256 public minted;

// Mappings
/// @dev Balance of user in fractional representation
mapping(address => uint256) public balanceOf;

/// @dev Allowance of user in fractional representation
mapping(address => mapping(address => uint256)) public allowance;

/// @dev Approval in native representaion
mapping(uint256 => address) public getApproved;

/// @dev Approval for all in native representation
mapping(address => mapping(address => bool)) public isApprovedForAll;

/// @dev Owner of id in native representation
mapping(uint256 => address) internal _ownerOf;

/// @dev Array of owned ids in native representation
mapping(address => uint256[]) internal _owned;

/// @dev Tracks indices for the _owned mapping
mapping(uint256 => uint256) internal _ownedIndex;

/// @dev Addresses whitelisted from minting / burning for gas savings (pairs, routers, etc)
mapping(address => bool) public whitelist;

// Constructor
constructor(
string memory _name,
string memory _symbol,
uint8 _decimals,
uint256 _totalNativeSupply,
address _owner
) Ownable(_owner) {
name = _name;
symbol = _symbol;
decimals = _decimals;
totalSupply = _totalNativeSupply * (10 ** decimals);
}

/// @notice Initialization function to set pairs / etc
/// saving gas by avoiding mint / burn on unnecessary targets
function setWhitelist(address target, bool state) public onlyOwner {
whitelist[target] = state;
}

/// @notice Function to find owner of a given native token
function ownerOf(uint256 id) public view virtual returns (address owner) {
owner = _ownerOf[id];

if (owner == address(0)) {
revert NotFound();
}
}

/// @notice tokenURI must be implemented by child contract
function tokenURI(uint256 id) public view virtual returns (string memory);

/// @notice Function for token approvals
/// @dev This function assumes id / native if amount less than or equal to current max id
function approve(
address spender,
uint256 amountOrId
) public virtual returns (bool) {
if (amountOrId <= minted && amountOrId > 0) {
address owner = _ownerOf[amountOrId];

if (msg.sender != owner && !isApprovedForAll[owner][msg.sender]) {
revert Unauthorized();
}

getApproved[amountOrId] = spender;

emit Approval(owner, spender, amountOrId);
} else {
allowance[msg.sender][spender] = amountOrId;

emit Approval(msg.sender, spender, amountOrId);
}

return true;
}

/// @notice Function native approvals
function setApprovalForAll(address operator, bool approved) public virtual {
isApprovedForAll[msg.sender][operator] = approved;

emit ApprovalForAll(msg.sender, operator, approved);
}

/// @notice Function for mixed transfers
/// @dev This function assumes id / native if amount less than or equal to current max id
function transferFrom(
address from,
address to,
uint256 amountOrId
) public virtual {
if (amountOrId <= minted) {
if (from != _ownerOf[amountOrId]) {
revert InvalidSender();
}

if (to == address(0)) {
revert InvalidRecipient();
}

if (
msg.sender != from &&
!isApprovedForAll[from][msg.sender] &&
msg.sender != getApproved[amountOrId]
) {
revert Unauthorized();
}

balanceOf[from] -= _getUnit();

unchecked {
balanceOf[to] += _getUnit();
}

_ownerOf[amountOrId] = to;
delete getApproved[amountOrId];

// update _owned for sender
uint256 updatedId = _owned[from][_owned[from].length - 1];
_owned[from][_ownedIndex[amountOrId]] = updatedId;
// pop
_owned[from].pop();
// update index for the moved id
_ownedIndex[updatedId] = _ownedIndex[amountOrId];
// push token to to owned
_owned[to].push(amountOrId);
// update index for to owned
_ownedIndex[amountOrId] = _owned[to].length - 1;

emit Transfer(from, to, amountOrId);
emit ERC20Transfer(from, to, _getUnit());
} else {
uint256 allowed = allowance[from][msg.sender];

if (allowed != type(uint256).max)
allowance[from][msg.sender] = allowed - amountOrId;

_transfer(from, to, amountOrId);
}
}

/// @notice Function for fractional transfers
function transfer(
address to,
uint256 amount
) public virtual returns (bool) {
return _transfer(msg.sender, to, amount);
}

/// @notice Function for native transfers with contract support
function safeTransferFrom(
address from,
address to,
uint256 id
) public virtual {
transferFrom(from, to, id);

if (
to.code.length != 0 &&
ERC721Receiver(to).onERC721Received(msg.sender, from, id, "") !=
ERC721Receiver.onERC721Received.selector
) {
revert UnsafeRecipient();
}
}

/// @notice Function for native transfers with contract support and callback data
function safeTransferFrom(
address from,
address to,
uint256 id,
bytes calldata data
) public virtual {
transferFrom(from, to, id);

if (
to.code.length != 0 &&
ERC721Receiver(to).onERC721Received(msg.sender, from, id, data) !=
ERC721Receiver.onERC721Received.selector
) {
revert UnsafeRecipient();
}
}

/// @notice Internal function for fractional transfers
function _transfer(
address from,
address to,
uint256 amount
) internal returns (bool) {
uint256 unit = _getUnit();
uint256 balanceBeforeSender = balanceOf[from];
uint256 balanceBeforeReceiver = balanceOf[to];

balanceOf[from] -= amount;

unchecked {
balanceOf[to] += amount;
}

// Skip burn for certain addresses to save gas
if (!whitelist[from]) {
uint256 tokens_to_burn = (balanceBeforeSender / unit) -
(balanceOf[from] / unit);
for (uint256 i = 0; i < tokens_to_burn; i++) {
_burn(from);
}
}

// Skip minting for certain addresses to save gas
if (!whitelist[to]) {
uint256 tokens_to_mint = (balanceOf[to] / unit) -
(balanceBeforeReceiver / unit);
for (uint256 i = 0; i < tokens_to_mint; i++) {
_mint(to);
}
}

emit ERC20Transfer(from, to, amount);
return true;
}

// Internal utility logic
function _getUnit() internal view returns (uint256) {
return 10 ** decimals;
}

function _mint(address to) internal virtual {
if (to == address(0)) {
revert InvalidRecipient();
}

unchecked {
minted++;
}

uint256 id = minted;

if (_ownerOf[id] != address(0)) {
revert AlreadyExists();
}

_ownerOf[id] = to;
_owned[to].push(id);
_ownedIndex[id] = _owned[to].length - 1;

emit Transfer(address(0), to, id);
}

function _burn(address from) internal virtual {
if (from == address(0)) {
revert InvalidSender();
}

uint256 id = _owned[from][_owned[from].length - 1];
_owned[from].pop();
delete _ownedIndex[id];
delete _ownerOf[id];
delete getApproved[id];

emit Transfer(from, address(0), id);
}

function _setNameSymbol(
string memory _name,
string memory _symbol
) internal {
name = _name;
symbol = _symbol;
}
}

Step 3: Modify the My404.sol contract​

Open the My404.sol with your code editor, and paste the code below into the file.

Key Points

  • Customization: Developers can change the name, symbol, decimals, and totalSupply in the constructor to fit their project needs.
  • Dynamic Metadata: The setDataURI and setTokenURI functions allow for dynamic changes to the contract's metadata URIs, offering flexibility for updates post-deployment.
  • Token Metadata: The tokenURI function currently returns a static baseTokenURI for all tokens. However, it can be modified to return unique metadata for each token, such as by appending the token ID to the baseTokenURI.

Here's a detailed breakdown with comments added to clarify each part of the contract:

contracts/My404.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

// Importing the ERC404 contract as the base and OpenZeppelin's Strings library for string operations
import "./ERC404.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

// My404 contract inherits ERC404 to create a custom token with both ERC-20 and ERC-721 features
contract My404 is ERC404 {
// Public variables to store URIs for token metadata
string public dataURI;
string public baseTokenURI;

// Constructor to initialize the contract with token details and owner's initial balance
constructor(address _owner) ERC404("My404", "MY404", 18, 10000, _owner) {
balanceOf[_owner] = 10000 * 10 ** 18; // Setting the initial balance of tokens for the owner
}

// Function to set the data URI, which can be used for additional metadata (change as needed)
function setDataURI(string memory _dataURI) public onlyOwner {
dataURI = _dataURI;
}

// Function to set the base URI for token metadata; this can be an IPFS link (changeable by the owner)
function setTokenURI(string memory _tokenURI) public onlyOwner {
baseTokenURI = _tokenURI;
}

// Allows the owner to update the token's name and symbol post-deployment (optional flexibility)
function setNameSymbol(string memory _name, string memory _symbol) public onlyOwner {
_setNameSymbol(_name, _symbol);
}

// Override of the tokenURI function to return the base URI for token metadata; users can implement logic to return unique URIs per token ID
function tokenURI(uint256 id) public view override returns (string memory) {
// Potential place to append the token ID to the base URI for unique metadata per token
// For now, it simply returns the base URI for all tokens
return baseTokenURI;
}
}

Step 4: Compile smart contracts​

Run the command below to compile your contracts and generate artifacts.

npx hardhat compile

Testing the Contract​

Testing is an important part of smart contract security. In this section, we simply show how to test a contract; however, since testing is not the primary scope of this guide, you can develop a comprehensive test yourself.

Step 1: Create the test script file​

Run the following command in your project directory.

echo > test/ERC404.ts

Also, you can delete any other test files under the test directory.

Step 2: Modify the test script​

This test file is written for the My404 smart contract and is designed to verify some functionality of the My404 contract. Here are the explanations of these tests:

  • Owner Check: The first test verifies that the contract's owner is correctly set to owner.address upon deployment.
  • BaseTokenURI Update: Tests the functionality to update the contract's baseTokenURI. It checks that the owner can successfully update it and that the new URI is correctly reflected.
  • Unauthorized BaseTokenURI Update: Attempts to update the baseTokenURI from a non-owner account (addr1), expecting the transaction to be reverted with an "Unauthorized" error, showcasing access control.
  • DataURI Update: Similar to the baseTokenURI test, but for updating dataURI, ensuring that the setter function works and access control is enforced.
  • Unauthorized DataURI Update: Tests that a non-owner cannot update the dataURI, expecting a revert with an "Unauthorized" error.
  • Name and Symbol Update: Verifies that the contract owner can update the token's name and symbol, and checks that the updates are correctly applied.
  • Unauthorized Name and Symbol Update: Checks that non-owners are prevented from changing the name and symbol, with the transaction being reverted due to unauthorized access.
  • Correct TokenURI: Confirms that the tokenURI function returns the expected URI for a given token ID after the baseTokenURI has been set by the owner.

For production purposes, more comprehensive tests that cover all functionalities of the contract should be developed.

Open the test/ERC404.ts file and modify it as below.

test/ERC404.ts
import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'
import { expect } from 'chai'
import { ethers } from 'hardhat'
import { My404 } from '../typechain-types'

describe('My404', function () {
let my404: My404
let owner: SignerWithAddress
let addr1: SignerWithAddress
let addr2: SignerWithAddress

beforeEach(async function () {
// Get signers
;[owner, addr1, addr2] = await ethers.getSigners()

// Deploy the My404 contract
my404 = await ethers.deployContract('My404', [owner.address], owner)
})

it('Should set the right owner', async function () {
expect(await my404.owner()).to.equal(owner.address)
})

it('Should update baseTokenURI', async function () {
const newURI = 'ipfs://newBaseURI/'
await my404.connect(owner).setTokenURI(newURI)
expect(await my404.baseTokenURI()).to.equal(newURI)
})

it('Should revert when non-owner tries to set baseTokenURI', async function () {
const newURI = 'ipfs://newBaseURI/'
await expect(
my404.connect(addr1).setTokenURI(newURI)
).to.be.revertedWithCustomError(my404, 'Unauthorized')
})

it('Should update dataURI', async function () {
const newDataURI = 'ipfs://newDataURI/'
await my404.connect(owner).setDataURI(newDataURI)
expect(await my404.dataURI()).to.equal(newDataURI)
})

it('Should revert when non-owner tries to set dataURI', async function () {
const newDataURI = 'ipfs://newDataURI/'
await expect(
my404.connect(addr1).setDataURI(newDataURI)
).to.be.revertedWithCustomError(my404, 'Unauthorized')
})

it('Should update name and symbol', async function () {
const newName = 'NewName'
const newSymbol = 'NN'
await my404.connect(owner).setNameSymbol(newName, newSymbol)
expect(await my404.name()).to.equal(newName)
expect(await my404.symbol()).to.equal(newSymbol)
})

it('Should revert when non-owner tries to set name and symbol', async function () {
const newName = 'NewName'
const newSymbol = 'NN'
await expect(
my404.connect(addr1).setNameSymbol(newName, newSymbol)
).to.be.revertedWithCustomError(my404, 'Unauthorized')
})

it('Should return the correct tokenURI', async function () {
const baseTokenURI = 'ipfs://baseTokenURI/'
await my404.connect(owner).setTokenURI(baseTokenURI)
const tokenId = 1 // Example token ID
expect(await my404.tokenURI(tokenId)).to.equal(baseTokenURI)
})
})

Step 3: Run your tests​

Execute the command below to run your test scripts.

npx hardhat test

The possible output should be like the one below.

Since we enabled gasReporter in the hardhat.config.ts file before, the output displays not only the testing results but also how much gas is needed for the deployment and each method calls.

My404
βœ” Should set the right owner
βœ” Should update baseTokenURI
βœ” Should revert when non-owner tries to set baseTokenURI
βœ” Should update dataURI
βœ” Should revert when non-owner tries to set dataURI
βœ” Should update name and symbol
βœ” Should revert when non-owner tries to set name and symbol
βœ” Should return the correct tokenURI

Β·------------------------------|----------------------------|-------------|-----------------------------Β·
| Solc version: 0.8.20 Β· Optimizer enabled: false Β· Runs: 200 Β· Block limit: 30000000 gas β”‚
Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·
| Methods β”‚
Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·
| Contract Β· Method Β· Min Β· Max Β· Avg Β· # calls Β· usd (avg) β”‚
Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·
| My404 Β· setDataURI Β· - Β· - Β· 47631 Β· 1 Β· - β”‚
Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·
| My404 Β· setNameSymbol Β· - Β· - Β· 37145 Β· 1 Β· - β”‚
Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·
| My404 Β· setTokenURI Β· 47563 Β· 47587 Β· 47575 Β· 2 Β· - β”‚
Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·
| Deployments Β· Β· % of limit Β· β”‚
Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·|Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·
| My404 Β· - Β· - Β· 3081303 Β· 10.3 % Β· - β”‚
Β·------------------------------|--------------|-------------|-------------|---------------|-------------Β·

Based on the gas report, 3081303 gas is needed to deploy My404 contract. Let's assume that the gas price is 10 gwei/gas. So, at least 0.03081303 ether would be needed to deploy the My404 contract. Please note, actual gas costs may vary based on the network traffic at the time of deployment.

(0.00000001 ether/gas) * (3081303 gas) = 0.03081303 ether

Deploying the Contract to the Ethereum Sepolia​

Now, it's time to deploy the contract to the blockchain!

Make sure that you have enough ETH to deploy the contract, as we calculated the needed amount of ETH in the previous section before moving to the deployment.

Step 1: Create the deployment script file​

Run the following command in your project directory.

echo > scripts/deploy.ts

Also, you can delete any other script files under the scripts directory.

Step 2: Modify the deployment script​

This script is designed to deploy the My404 smart contract to the blockchain and then whitelist the deployer's address.

Additional Explanation on setWhitelist Functionality

The setWhitelist function plays a crucial role in the operational flexibility of the My404 contract. By allowing specific addresses to transfer tokens without minting or burning the corresponding NFTs, the contract owner can manage the token supply more efficiently. This feature is particularly important during the initial distribution phase, where the owner, possessing all the initial token supply, might need to distribute tokens without being constrained by the requirement to burn NFTs.

Here's a detailed explanation with comments added for clarity:

scripts/deploy.ts
// Importing necessary functionalities from the Hardhat package.
import { ethers } from 'hardhat'

async function main() {
// Retrieve the first signer, typically the default account in Hardhat, to use as the deployer.
const [deployer] = await ethers.getSigners()

console.log('Contract is deploying...')
// Deploying the My404 contract, passing the deployer's address as a constructor argument.
const my404 = await ethers.deployContract('My404', [deployer.address])

// Waiting for the contract deployment to be confirmed on the blockchain.
await my404.waitForDeployment()

// Logging the address of the deployed My404 contract.
console.log(`My404 contract is deployed. Token address: ${my404.target}`)

console.log('Deployer address is being whitelisted...')
// Calling the setWhitelist function of the deployed contract to whitelist the deployer's address.
// This enables the deployer to transfer tokens without having corresponding NFTs,
// essential for initial setup or specific operational exceptions.
const tx = await my404.setWhitelist(deployer.address, true)
await tx.wait() // Waiting for the transaction to be mined.
console.log(`Tx hash for whitelisting deployer address: ${tx.hash}`)
}

// This pattern allows the use of async/await throughout and ensures that errors are caught and handled properly.
main().catch(error => {
console.error(error)
process.exitCode = 1
})

Step 3: Deploy your ERC-404 contract​

Execute the command below to deploy your ERC-404 contract.

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

The output should be like the one below.

Contract is deploying...
My404 contract is deployed. Token address: 0x18E7e3b02286bB6A405909552cfFbD61Da0d09A4
Deployer address is being whitelisted...
Tx hash for whitelisting deployer address: 0xb2fdbea51ed123c92c781673c72ffce9f26741cb7b6a5dbc43ea25363f585e86

Congrats, you just deployed an ERC-404 contract!

Testing on the Testnet​

Now, let's try to send some My404 tokens to another address. Currently the deployer (owner) owns the total supply of tokens.

You can send the token either via an Ethereum wallet (e.g., MetaMask, Rabby, etc.) or with the help of a script. We will show you how to do it with a script.

Step 1: Create the transfer script file​

Run the following command in your project directory.

echo > scripts/transferToken.ts

Step 2: Modify the transfer script​

This script is designed to transfer some amount of ERC-404 token to another address. Replace ANOTHER_ADDRESS and MY404_CONTRACT_ADDRESS placeholders with the address that you want to send 20 My404 tokens to, and the My404 token address, respectively.

scripts/transferToken.ts
import { ethers } from 'hardhat'

async function main() {
const toAddress = 'ANOTHER_ADDRESS'
const contractAddress = 'MY404_CONTRACT_ADDRESS'

console.log('Sending My404 token...')
const my404 = await ethers.getContractAt('My404', contractAddress)

const tx = await my404.transfer(toAddress, ethers.parseEther('20'))
tx.wait()
console.log(`Tx hash for sending My404 token: ${tx.hash}`)
}

// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch(error => {
console.error(error)
process.exitCode = 1
})

Step 3: Run the transfer script​

Run the command below.

npx hardhat run scripts/transferToken.ts --network sepolia

If you check the transaction hash on the block explorer like Etherscan, you can see that 20 NFTs are transferred to the same address as 20 My404 token is transferred as well.

Etherscan ERC-404 Token Transfer

Verifying the Contract (optional)​

Having a verified contract is a good way to build trust in the community since unverified contracts may have some suspicious functions. So, you may want to add additional steps, such as verifying the contract source code on Etherscan after deployment. If so, please check our verification guide.

Creating a Pool on Uniswap V3 (optional)​

If you are looking to enhance liquidity and trading opportunities for your ERC-404 token, creating a pool on Uniswap V3 is a strategic step. This section outlines how to establish a liquidity pool between your ERC-404 token and WETH (Wrapped Ether) on the Uniswap V3 platform.

Step 1: Prepare ABI files​

You need to have the ABI files for Uniswap's NonfungiblePositionManager and UniswapV3Factory contracts saved in your project's abis directory to be able to interact with these contracts.

Run the following command in your project directory.

mkdir abis
echo > abis/NonfungiblePositionManager.json
echo > abis/UniswapV3Factory.json

Then, go to Etherscan's contract page for each contract, copy their ABIs, and modify your ABI files.

  • NonfungiblePositionManager: 0x1238536071E1c677A632429e3655c799b22cDA52
  • UniswapV3Factory: 0x0227628f3F023bb0B980b67D528571c95c6DaC1c

Step 2: Define your pool parameters​

  • Token Addresses (token0 and token1): These addresses define the trading pair in the liquidity pool. Your ERC-404 token and WETH are used here to create a market for trading your token against a widely used and stable value reference, WETH.

  • Fee Tier (fee): The fee tier impacts how much traders will pay in fees and how much liquidity providers will earn. A 1% fee tier is chosen as an example, balancing the potential for earnings with attractiveness to traders.

  • Initial Price (sqrtPriceX96): The initial price sets the starting point for trading in the pool. It's encoded in a specific format used by Uniswap V3, reflecting the desired price ratio between the two tokens.

Step 3: Prepare the script file​

Run the following command to create the script file that will be used to initialize a pool on Uniswap V3.

echo > scripts/poolInitializer.ts

Then, modify it as the one below.

Replace the YOUR_ERC404_TOKEN_ADDRESS placeholder with your ERC404 token address.

If you use any other blockchain than Ethereum Sepolia, check the Uniswap documentation for the contract address and check the WETH token address.

scripts/poolInitializer.ts
import { ethers } from 'hardhat'
import NonfungiblePositionManagerABI from '../abis/NonfungiblePositionManager.json'
import UniswapV3Factory from '../abis/UniswapV3Factory.json'

async function main() {
const [deployer] = await ethers.getSigners()

console.log('My404 - WETH pool is initializing on Uniswap V3...')
const token0 = 'YOUR_ERC404_TOKEN_ADDRESS' // MY404 Token Address on Sepolia
const token1 = '0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14' // WETH Token Address on Sepolia
const fee = 10000n // 1% fee
const sqrtPriceX96 = 792281625000000000000000000n // 1/10000 price ratio

// Token and pool parameters are defined:
// - token0 and token1 represent the pair of tokens for the pool, with your ERC-404 token and WETH.
// - fee denotes the pool's fee tier, affecting trading fees and potential liquidity provider earnings.
// - sqrtPriceX96 is an encoded value representing the initial price of the pool, set based on the desired price ratio.
const contractAddress = {
uniswapV3NonfungiblePositionManager:
'0x1238536071E1c677A632429e3655c799b22cDA52',
uniswapV3Factory: '0x0227628f3F023bb0B980b67D528571c95c6DaC1c',
}

// Contract instances for interacting with Uniswap V3's NonfungiblePositionManager and Factory.
const nonfungiblePositionManagerContract = new ethers.Contract(
contractAddress.uniswapV3NonfungiblePositionManager,
NonfungiblePositionManagerABI,
deployer
)

const uniswapV3FactoryContract = new ethers.Contract(
contractAddress.uniswapV3Factory,
UniswapV3Factory,
deployer
)

const my404Contract = await ethers.getContractAt('My404', token0, deployer)

// Creating the pool on Uniswap V3 by specifying the tokens, fee, and initial price.
let tx =
await nonfungiblePositionManagerContract.createAndInitializePoolIfNecessary(
token0,
token1,
fee,
sqrtPriceX96
)

await tx.wait()

console.log(`Tx hash for initializing a pool on Uniswap V3: ${tx.hash}`)

// Retrieving the newly created pool's address to interact with it further.
const pool = await uniswapV3FactoryContract.getPool(token0, token1, fee)

console.log(`The pool address: ${pool}`)

// Whitelisting the Uniswap V3 pool address in your ERC-404 token contract.
// This step is crucial to bypass the token's built-in protections or requirements for minting and burning,
// which may be triggered during liquidity provision or trading on Uniswap.
console.log('Uniswap V3 Pool address is being whitelisted...')
tx = await my404Contract.setWhitelist(pool, true)
tx.wait()
console.log(`Tx hash for whitelisting Uniswap V3 pool: ${tx.hash}`)
}

// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch(error => {
console.error(error)
process.exitCode = 1
})

Step 4: Execute the script​

Execute the command below to initialize your Uniswap V3 pool.

npx hardhat run scripts/poolInitializer.ts --network sepolia

Step 5: Adding liquidity​

After initializing the pool, you can add liquidity into the pool either via the frontend of Uniswap or a script file.

ERC404 Uniswap V3 Adding Liquidity

Configuring the NFT Metadata using IPFS​

To configure the NFT metadata for your ERC-404 token using IPFS, you'll primarily interact with the setTokenURI function within your smart contract. This function allows you to set the base URI for your token's metadata, which can be an IPFS link pointing to the location where your metadata is stored. By storing metadata on IPFS, you ensure that it is decentralized and tamper-proof, aligning with the ethos of blockchain technology.

Suggested Guide for Further Reading​

To learn more about using IPFS for a NFT project, check out these QuickNode guides's IPFS-related sections:

Conclusion​

In conclusion, this guide displays the process of developing, deploying, and managing an ERC-404 token, from initial setup with Hardhat to creating liquidity on Uniswap V3. By following the detailed steps provided, you're equipped to launch a token that leverages the unique advantages of ERC-404's hybrid functionality. Remember, the key to a successful blockchain project lies in thorough testing, security audits, and continuous learning.

Subscribe to our newsletter for more articles and guides on Web3 and blockchain. If you have any questions or need further assistance, feel free to join our Discord server or provide feedback using the form below. Stay up to date with the latest by following us on Twitter (@QuickNode) and our Telegram announcement channel.

We ❀️ Feedback!

Let us know if you have any feedback or requests for new topics. We'd love to hear from you.

Share this guide