Documentation
Pandorus ERC404
A mixed ERC20 / ERC721 token implementation with native liquidity and fractionalization. This page documents the contracts as they exist in the public repositories.
Overview
ERC404 is an experimental, mixed ERC20 / ERC721 implementation. A single contract keeps two views of the same asset: a fungible ERC20 balance per address, and a set of ERC721 token ids owned by that address. Holding one whole token of ERC20 balance corresponds to holding one ERC721 id.
Because the asset is a normal ERC20 balance, it can be traded on pools and routers that only understand ERC20 (native liquidity). Because the balance is divisible, the same asset can be moved in fractions of a token (fractionalization). The ERC721 side gives each whole token a stable id and ERC721 style ownership, approvals and metadata through tokenURI.
- Core contract:
contracts/ERC404.sol, an abstract contract implementingIERC404. - Variant:
contracts/ERC404U16.sol, same behavior with packed uint16 id storage for smaller collections. - Extensions: Uniswap V2 and V3 auto-exemption, and a Merkle airdrop claim helper.
- License: MIT.
How it works
Dual representation
The constructor takes name_, symbol_ and decimals_. Decimals below 18 revert with DecimalsTooLow. The contract stores units = 10 ** decimals, the amount of ERC20 balance that corresponds to exactly one ERC721. With 18 decimals, one ERC721 equals 1e18 base units.
An address that holds balance / units whole tokens is expected to own that many ERC721 ids. The ERC20 balance lives in balanceOf, the ids live in a per-owner array returned by owned(address).
Shared value space
Several functions take a single uint256 that can mean either an ERC20 amount or an ERC721 id. The contract separates the two by range. A value is a valid ERC721 id when it is greater than ID_ENCODING_PREFIX (1 << 255) and not equal to type(uint256).max. Ids are therefore encoded as 2^255 + n. The README calls this a lossy encoding scheme that assumes ERC20 amounts never occupy the id range.
approveandtransferFrominspect the value and route to the ERC721 path or the ERC20 path.transferalways takes the ERC20 path, even for very large amounts.- Dedicated functions (
erc20Approve,erc721Approve,erc20TransferFrom,erc721TransferFrom) skip the guessing and are recommended for integrations. safeTransferFromonly accepts valid ids and reverts withInvalidTokenIdotherwise.
What happens on an ERC20 transfer
ERC20 transfers go through one internal routine, _transferERC20WithERC721. It first moves the ERC20 balance, then reconciles ERC721 ids depending on whether the sender and recipient are exempt from ERC721 transfers (see the next section).
| Sender | Recipient | ERC721 effect |
|---|---|---|
| Exempt | Exempt | None. |
| Exempt | Normal | Recipient receives one id for each whole token its balance gained, taken from the queue or newly minted. |
| Normal | Exempt | Sender loses one id for each whole token its balance lost. The ids are burned and stored in the queue. |
| Normal | Normal | Whole tokens in the amount move ids directly from sender to recipient. A fractional remainder that makes the sender lose a whole token stores one extra id. A remainder that makes the recipient gain a whole token retrieves or mints one id. |
Which operations mint and burn
- Mint:
_mintERC20(to, value)is an ERC20 transfer from the zero address. The recipient gets ids if it is not exempt. It reverts withInvalidRecipientfor the zero address and withMintLimitReachediftotalSupply + valuewould exceed1 << 255. - ERC721 mint: a new id is created only when the queue is empty. It is
ID_ENCODING_PREFIX + mintedafter incrementingminted. - ERC721 burn: when a holder loses a whole token to a fraction or to an exempt address, the newest id in their array is transferred to the zero address and pushed into the queue. The id is not destroyed, it is banked for reuse.
- ERC721 transfer:
erc721TransferFrommoves exactly one id and exactlyunitsof ERC20 balance.
Whole tokens moved as ERC20 are taken from the end of the sender's owned array, so the most recently received id is spent first.
// 18 decimals, neither side exempt. Alice holds 99.1 tokens (99 ids),
// Bob holds 0.9 tokens (0 ids).
alice.transfer(bob, 3.2e18);
// 3 whole ids move from Alice to Bob.
// Alice: 95.9 tokens, floor drops 99 to 95 (4 lost, 3 moved), so 1 id is banked.
// Bob: 4.1 tokens, floor rises 0 to 4 (4 gained, 3 moved), so 1 id is retrieved or minted.
This case (99.1 and 0.9, sending 3.2) is one of the scenarios covered in test/ERC404.t.ts.
ERC721 transfer exemption
Minting and burning ids on every ERC20 transfer is expensive, and some addresses never need ids: liquidity pools, routers and vaults. The contract keeps an exemption list, _erc721TransferExempt, to skip that work for such addresses. The v1 name for the same idea was the whitelist.
Functions
| Function | Semantics |
|---|---|
erc721TransferExempt(address target_) view returns (bool) | True if target_ is exempt. The zero address always reports true. |
setSelfERC721TransferExempt(bool state_) | Public. Lets any caller set its own exemption state. |
_setERC721TransferExempt(address target_, bool state_) | Internal. Your contract decides who may call it. Reverts with InvalidExemption for the zero address. |
Rebalancing on change
Changing the state of an address that already holds a balance rebalances its ids so they match the new rules.
- Becoming exempt: every id the address owns is burned and stored in the queue.
- Leaving the exemption list: the address is given
erc20BalanceOf / units - erc721BalanceOfids, retrieved from the queue first and minted when the queue runs out.
The code comment still recommends exempting an address before it holds an active balance, since both rebalancing loops cost gas proportional to the number of ids.
Effects on ERC721 transfers
erc721TransferFromreverts withRecipientIsERC721TransferExemptwhen the recipient is exempt. Only the recipient is checked, because an exempt sender has already had its ids stripped.- ERC20 transfers to and from exempt addresses are allowed and follow the table in the previous section.
Built-in extensions
ERC404UniswapV2Exempt: constructor takes the V2 router, exempts it, then computes the token and WETH pair address from the router factory and exempts that pair.ERC404UniswapV3Exempt: constructor takes the swap router and the nonfungible position manager and exempts both. It reverts withERC404UniswapV3ExemptFactoryMismatchorERC404UniswapV3ExemptWETH9Mismatchif the two disagree on factory or WETH9. It then computes and exempts the pool for fee tiers 100, 500, 3000 and 10000.
Both extensions compute the pair address from a hardcoded init code hash, so they apply to the canonical Uniswap deployments.
Token ids and the queue
In v2, ERC721 ids are banked and reused rather than counting up forever. Ids that leave circulation (burned because a holder dropped below a whole token, or because an address became exempt) are stored in a double ended queue, _storedERC721Ids, implemented in contracts/lib/DoubleEndedQueue.sol.
- Store:
pushFront(id)when an id is withdrawn from a holder. - Retrieve:
popBack()when an address needs an id. The oldest stored id is reused first, which makes the queue FIFO. - Mint: only when the queue is empty, with the next id
2^255 + minted. The countermintedonly increases, and reachingtype(uint256).maxreverts withMintLimitReached.
The result is a predictable set of ids, as in a typical NFT collection. With a fixed total supply of N whole tokens, ids stay within 2^255 + 1 to 2^255 + N.
Reading state
erc721TotalSupply()returnsminted, the highest id counter, not the number of ids currently held by users.getERC721QueueLength()returns how many ids are banked.getERC721TokensInQueue(start_, count_)returns a page of banked ids.ownerOf(id_)reverts withInvalidTokenIdfor values outside the id range andNotFoundfor ids that are unminted or currently banked.
Ownership is stored packed: the owner address occupies the low 160 bits and the index in the owner's array the upper 96 bits of one slot per id. An index above the 96 bit range reverts with OwnedIndexOverflow.
ERC404U16
ERC404U16 is an optimized implementation for smaller collections, with ids up to a maximum of 65535 according to the contract comment. It exposes the same interface, IERC404, and the same id encoding as ERC404. Only the storage layout differs.
| Item | ERC404 | ERC404U16 |
|---|---|---|
| Owned ids | mapping(address => uint256[]) | mapping(address => uint16[]) |
| Id queue | DoubleEndedQueue.Uint256Deque | PackedDoubleEndedQueue.Uint16Deque |
| Stored value | Full id | Low 16 bits of the id, the prefix is added back on read |
| Public API | Identical. owned and getERC721TokensInQueue still return full ids. | |
The packed queue (contracts/lib/PackedDoubleEndedQueue.sol) is adapted from the OpenZeppelin double ended queue to store uint16 values packed into storage slots. The rest of the logic, including exemption rules, permit and the transfer cases, matches the base contract.
Functions reference
Signatures follow contracts/interfaces/IERC404.sol and contracts/ERC404.sol. The interface also extends ERC165.
ERC20 side
| Signature | Description |
|---|---|
name() / symbol() / decimals() | Metadata. decimals is immutable and at least 18. |
units() view returns (uint256) | Public immutable, 10 ** decimals. ERC20 amount that equals one ERC721. |
totalSupply() / erc20TotalSupply() | ERC20 total supply. Both return the same value. |
balanceOf(address owner_) / erc20BalanceOf(address owner_) | ERC20 balance. |
allowance(address owner_, address spender_) | ERC20 allowance. |
transfer(address to_, uint256 amount_) returns (bool) | ERC20 transfer. Always treated as an amount. Reverts with InvalidRecipient for the zero address. |
erc20Approve(address spender_, uint256 value_) returns (bool) | Sets an ERC20 allowance. type(uint256).max is unlimited and not deducted. Reverts with InvalidSpender for the zero address. |
erc20TransferFrom(address from_, address to_, uint256 value_) returns (bool) | ERC20 transfer by an operator. Deducts allowance unless unlimited. |
permit(address owner_, address spender_, uint256 value_, uint256 deadline_, uint8 v_, bytes32 r_, bytes32 s_) | EIP-2612 approval. Reverts on expired deadline, zero spender, bad signer, or a value inside the id range. |
DOMAIN_SEPARATOR() view returns (bytes32) | EIP-712 separator, recomputed if the chain id changed. |
nonces(address) view returns (uint256) | Permit nonces (public mapping in the contract). |
ERC721 side
| Signature | Description |
|---|---|
ownerOf(uint256 id_) view returns (address) | Owner of an id. Reverts with InvalidTokenId or NotFound. |
owned(address owner_) view returns (uint256[] memory) | All ids held by an address. |
erc721BalanceOf(address owner_) view returns (uint256) | Number of ids held. |
erc721TotalSupply() view returns (uint256) | Returns minted, the highest id counter. |
tokenURI(uint256 id_) view returns (string memory) | Abstract. Implemented by your contract. |
erc721Approve(address spender_, uint256 id_) | Approve one id. Caller must be the owner or an operator for the owner. |
getApproved(uint256 id) | Approved spender for an id. Reset on transfer. |
setApprovalForAll(address operator_, bool approved_) | Operator approval. Zero address reverts with InvalidOperator. |
isApprovedForAll(address owner_, address operator_) | Operator approval state. |
erc721TransferFrom(address from_, address to_, uint256 id_) | Moves one id plus units of ERC20 balance. Reverts if the recipient is exempt. |
safeTransferFrom(address from_, address to_, uint256 id_) | Transfer with onERC721Received check for contracts. |
safeTransferFrom(address from_, address to_, uint256 id_, bytes data_) | Same, with callback data. Reverts with UnsafeRecipient on a wrong selector. |
getERC721QueueLength() view returns (uint256) | Number of banked ids. |
getERC721TokensInQueue(uint256 start_, uint256 count_) view returns (uint256[] memory) | Banked ids from start_. |
minted() view returns (uint256) | Public counter of the highest minted id offset. |
Mixed and exemption
| Signature | Description |
|---|---|
approve(address spender_, uint256 valueOrId_) returns (bool) | Routes to erc721Approve for a valid id, otherwise to erc20Approve. The spender may be zero to revoke an id approval. |
transferFrom(address from_, address to_, uint256 valueOrId_) returns (bool) | Routes to erc721TransferFrom for a valid id, otherwise to erc20TransferFrom. |
supportsInterface(bytes4 interfaceId) view returns (bool) | True for IERC404 and IERC165. |
erc721TransferExempt(address account_) view returns (bool) | Exemption state. |
setSelfERC721TransferExempt(bool state_) | Caller sets its own exemption. |
ID_ENCODING_PREFIX() view returns (uint256) | Constant 1 << 255. |
Internal hooks for your contract
| Signature | Description |
|---|---|
_mintERC20(address to_, uint256 value_) | Mint ERC20 supply. Mints ids for non exempt recipients. |
_setERC721TransferExempt(address target_, bool state_) | Set exemption and rebalance ids. |
_transferERC20WithERC721(address from_, address to_, uint256 value_) | The ERC20 transfer routine with id reconciliation. |
_retrieveOrMintERC721(address to_) / _withdrawAndStoreERC721(address from_) | Give an id to an address from the queue or mint it, or take one back into the queue. Neither handles exemptions. |
Merkle claim extension
ERC404MerkleClaim is an optional abstract contract: verifyProof(bytes32[] proof_, address claimer_, uint256 value_) and airdropMint(bytes32[] proof_, uint256 value_), with airdropIsOpen, airdropMerkleRoot and hasClaimedAirdrop state. The comment in the source explains that you override airdropMint, call super.airdropMint(proof_, value_), then mint tokens yourself. Errors: AirdropIsClosed, NotEligibleForAirdrop, AirdropAlreadyClaimed.
Events and errors
Events are declared in two small libraries, ERC20Events and ERC721Events, so that every state change is clearly attributed to one side.
| Event | Emitted when |
|---|---|
ERC20Events.Transfer(address indexed from, address indexed to, uint256 amount) | Any ERC20 balance move, including mint (from the zero address). |
ERC20Events.Approval(address indexed owner, address indexed spender, uint256 value) | erc20Approve and permit. |
ERC721Events.Transfer(address indexed from, address indexed to, uint256 indexed id) | Any id move, including mint, store into the queue (to the zero address) and retrieval from the queue (from the zero address). |
ERC721Events.Approval(address indexed owner, address indexed spender, uint256 indexed id) | erc721Approve. |
ERC721Events.ApprovalForAll(address indexed owner, address indexed operator, bool approved) | setApprovalForAll. |
Transfer events share the same signature hash, Transfer(address,address,uint256). They differ in topic count: the ERC721 event indexes the id and has four topics, the ERC20 event has three. Indexers must tell them apart by topic count.Errors
Declared in IERC404: NotFound, InvalidTokenId, AlreadyExists, InvalidRecipient, InvalidSender, InvalidSpender, InvalidOperator, UnsafeRecipient, RecipientIsERC721TransferExempt, Unauthorized, InsufficientAllowance, DecimalsTooLow, PermitDeadlineExpired, InvalidSigner, InvalidApproval, OwnedIndexOverflow, MintLimitReached, InvalidExemption.
Integrating
Inherit ERC404 (or ERC404U16), pass name, symbol and decimals to its constructor, implement tokenURI, and decide who can change exemptions and who can mint. The example below follows contracts/examples/ERC404Example.sol and contracts/mocks/MinimalERC404.sol.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
import {ERC404} from "../ERC404.sol";
contract PandorusExample is Ownable, ERC404 {
constructor(
string memory name_,
string memory symbol_,
uint8 decimals_,
uint256 maxTotalSupplyERC721_,
address initialOwner_,
address initialMintRecipient_
) ERC404(name_, symbol_, decimals_) Ownable(initialOwner_) {
// Do not mint the ERC721s to the initial recipient, it is a waste of gas.
_setERC721TransferExempt(initialMintRecipient_, true);
_mintERC20(initialMintRecipient_, maxTotalSupplyERC721_ * units);
}
function tokenURI(uint256 id_) public pure override returns (string memory) {
return string.concat("https://example.com/token/", Strings.toString(id_));
}
function setERC721TransferExempt(
address account_,
bool value_
) external onlyOwner {
_setERC721TransferExempt(account_, value_);
}
}
Constructor parameters
name_,symbol_: ERC20 and ERC721 metadata.decimals_: must be at least 18.maxTotalSupplyERC721_: the example mints this many whole tokens, asmaxTotalSupplyERC721_ * units.initialOwner_: OpenZeppelinOwnableowner (OpenZeppelin v5 style constructor).initialMintRecipient_: receives the initial ERC20 supply and is exempted first, so no ids are minted for it.
Minting later
The minimal mock exposes minting behind onlyOwner. The core contract has no fixed supply cap besides the 1 << 255 limit, so add your own cap if you need one.
function mintERC20(address account_, uint256 value_) external onlyOwner {
_mintERC20(account_, value_);
}
Adding a pool
Exempt the pool and router before liquidity is added, so transfers into and out of them do not mint or burn ids. With Uniswap you can inherit ERC404UniswapV2Exempt or ERC404UniswapV3Exempt and pass the router addresses to their constructors, as in ERC404ExampleUniswapV2.sol and ERC404ExampleUniswapV3.sol.
contract PandorusUniswapV2 is Ownable, ERC404, ERC404UniswapV2Exempt {
constructor(/* ..., */ address uniswapV2Router_)
ERC404(name_, symbol_, decimals_)
Ownable(initialOwner_)
ERC404UniswapV2Exempt(uniswapV2Router_)
{ /* exempt recipient, mint supply */ }
}
Tests
The repository uses Hardhat with Solidity 0.8.20. Tests live in test/: ERC404.t.ts covers minting, the queue, ERC20 and ERC721 transfers, approvals, permit and exemption rebalancing, and two further files cover the Uniswap V2 and V3 exempt extensions. scripts/gas-test.ts compares gas usage.
Security notes
These points come from reading the contracts and tests. They are not an audit.
- Experimental status: the v2 README states the code has not been fully tested or audited and is not intended for production use yet.
- Value ranges are shared: a value above
2^255is treated as an id byapproveandtransferFrom. Use the expliciterc20*anderc721*functions when calling from other contracts.permitreverts withInvalidApprovalfor values in the id range. - Self exemption is public: any address can call
setSelfERC721TransferExempt. An exempt address holds ERC20 balance without ids and cannot receive ids througherc721TransferFrom. - Exempt before funding: change exemptions before an address holds a large balance. Both rebalancing directions loop once per id.
- Gas grows with whole tokens moved: a transfer of
kwhole tokens between non exempt addresses moveskids in a loop. - Decimals: constructor rejects fewer than 18 decimals, because ids live in the same number space as amounts.
- Supply limit: minting reverts with
MintLimitReachedonce total supply would pass2^255. - Burned ids are banked: a holder who drops below a whole token loses an id to the queue. Another address can receive that same id later.
- Uniswap extensions: they derive pool addresses from fixed init code hashes, so they match the canonical factories only.
Legacy implementation
The repository erc404-legacy holds the v1.0 implementation. Its README states it is no longer actively maintained and that work moved to the v2 repository. It uses Foundry, and the main contract is src/ERC404.sol, with src/Example.sol as a minimal usage example.
| Item | Legacy v1 | Current v2 |
|---|---|---|
| Ids | Minted with an ever increasing counter and burned on loss | Banked in a queue and reused |
| Id detection | amountOrId <= minted | Range above 2^255 |
| Exemption | whitelist mapping, setWhitelist(address, bool) owner only | Exemption functions, hook in your contract |
| Supply | Fixed immutable totalSupply from constructor | Mintable with _mintERC20 |
| ERC20 whole transfer | Burn ids on sender, mint new ids to recipient | Ids move to the recipient |
| Events | ERC20Transfer, ERC721Approval, shared Approval | Separate ERC20 and ERC721 event libraries |
| Permit / ERC165 | No | Yes |
| Ownership | Built in Ownable | Left to your contract |
In v1 the initial supply is minted by setting balanceOf directly, because a transfer would also mint the whole ERC721 supply, which is very expensive. The legacy README notes that an owner who skips the whitelist step will fail on sending those tokens, since the contract assumes they hold matching ids. A copy of the v1 contract is also kept inside the v2 repository under contracts/legacy/.
Resources
- github.com/Pandorus404/erc404, current contracts, tests, scripts.
- github.com/Pandorus404/erc404-legacy, v1.0 implementation.
- x.com/Pandorus404, updates.
Pandorus / ERC404
