Pandorus logo

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.

The v2 repository describes itself as an experimental standard that is not intended for production use yet. Integrating protocols should expect that they will not fully understand the mixed function of the token. Read the code and test it yourself before relying on it.
  • Core contract: contracts/ERC404.sol, an abstract contract implementing IERC404.
  • 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.

  • approve and transferFrom inspect the value and route to the ERC721 path or the ERC20 path.
  • transfer always takes the ERC20 path, even for very large amounts.
  • Dedicated functions (erc20Approve, erc721Approve, erc20TransferFrom, erc721TransferFrom) skip the guessing and are recommended for integrations.
  • safeTransferFrom only accepts valid ids and reverts with InvalidTokenId otherwise.

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).

SenderRecipientERC721 effect
ExemptExemptNone.
ExemptNormalRecipient receives one id for each whole token its balance gained, taken from the queue or newly minted.
NormalExemptSender loses one id for each whole token its balance lost. The ids are burned and stored in the queue.
NormalNormalWhole 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 with InvalidRecipient for the zero address and with MintLimitReached if totalSupply + value would exceed 1 << 255.
  • ERC721 mint: a new id is created only when the queue is empty. It is ID_ENCODING_PREFIX + minted after incrementing minted.
  • 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: erc721TransferFrom moves exactly one id and exactly units of 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

FunctionSemantics
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 - erc721BalanceOf ids, 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

  • erc721TransferFrom reverts with RecipientIsERC721TransferExempt when 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 with ERC404UniswapV3ExemptFactoryMismatch or ERC404UniswapV3ExemptWETH9Mismatch if 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 counter minted only increases, and reaching type(uint256).max reverts with MintLimitReached.

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() returns minted, 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 with InvalidTokenId for values outside the id range and NotFound for 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.

ItemERC404ERC404U16
Owned idsmapping(address => uint256[])mapping(address => uint16[])
Id queueDoubleEndedQueue.Uint256DequePackedDoubleEndedQueue.Uint16Deque
Stored valueFull idLow 16 bits of the id, the prefix is added back on read
Public APIIdentical. 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.

The U16 contract does not add its own id range check on mint. Keep the whole token supply within 65535 so that ids fit the uint16 storage.

Functions reference

Signatures follow contracts/interfaces/IERC404.sol and contracts/ERC404.sol. The interface also extends ERC165.

ERC20 side

SignatureDescription
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

SignatureDescription
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

SignatureDescription
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

SignatureDescription
_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.

EventEmitted 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.
The two 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, as maxTotalSupplyERC721_ * units.
  • initialOwner_: OpenZeppelin Ownable owner (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^255 is treated as an id by approve and transferFrom. Use the explicit erc20* and erc721* functions when calling from other contracts. permit reverts with InvalidApproval for 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 through erc721TransferFrom.
  • 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 k whole tokens between non exempt addresses moves k ids 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 MintLimitReached once total supply would pass 2^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.

ItemLegacy v1Current v2
IdsMinted with an ever increasing counter and burned on lossBanked in a queue and reused
Id detectionamountOrId <= mintedRange above 2^255
Exemptionwhitelist mapping, setWhitelist(address, bool) owner onlyExemption functions, hook in your contract
SupplyFixed immutable totalSupply from constructorMintable with _mintERC20
ERC20 whole transferBurn ids on sender, mint new ids to recipientIds move to the recipient
EventsERC20Transfer, ERC721Approval, shared ApprovalSeparate ERC20 and ERC721 event libraries
Permit / ERC165NoYes
OwnershipBuilt in OwnableLeft 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

Pandorus / ERC404