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.
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β
- An Ethereum wallet (e.g., MetaMask, Coinbase Wallet) with some test ETH (You can get some at the Multi-Chain QuickNode Faucet)
- A free QuickNode account to get your endpoint
- A basic understanding of Ethereum and smart contracts
- Node.js installed
- A code editor (e.g., VS Code)
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:
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.
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
You can now access Logs for your RPC endpoints, helping you troubleshoot issues more effectively. If you encounter an issue with your RPC calls, simply check the logs in your QuickNode dashboard to identify and resolve problems quickly. Learn more about log history limits on our pricing page.
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.
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.
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.
{
"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.
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.
//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.
- 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:
// 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;
}
}