What is ERC-721 on EmpoorioChain?

NFTs are core to many blockchain applications, and ERC-721 is one of the first standards Ethereum developers learn. EmpoorioChain's NFTs work through a native pallet rather than a per-collection contract standard. Here's the mapping.

ERC-721 standardizes non-fungible tokens (NFTs) on Ethereum: each token has a unique identifier and its own metadata, unlike the interchangeable units of ERC-20.

pallet-uniques

EmpoorioChain uses pallet-uniques, Substrate's standard NFT pallet, for non-fungible tokens. Rather than deploying a new contract per collection, you create a new collection ID in the pallet, then mint items (unique IDs) within it. Item-level metadata (name, image URI, attributes) is stored directly in the pallet's own metadata storage, no separate metadata program required.

This mirrors the same architectural point as fungible tokens: one audited, native pallet handles every NFT collection on the chain, instead of one new contract per project.

NFTs Are Identified by (Collection ID, Item ID), Not a Contract Address

On Ethereum, an NFT is identified by its collection's contract address plus a tokenId. On EmpoorioChain, an NFT is identified by a (collection ID, item ID) pair within pallet-uniques — there's no per-collection contract address, because the collection is just an entry in the pallet's own storage.

No Separate Approval Flow for Basic Transfers

On Ethereum, ERC-721 often relies on approve or setApprovalForAll for marketplaces to move your NFTs. On EmpoorioChain, ownership is tracked directly per (collection, item) under the owning AccountId32, and the pallet exposes its own delegated-approval extrinsic where needed, rather than a bespoke per-collection pattern.

What Would an ERC-721-equivalent Look Like in Solidity?

For comparison, here's roughly what the equivalent behavior looks like as a single reusable Solidity contract (illustrative — the real logic lives in pallet-uniques, written in Rust):

solidity
// SPDX-License-Identifier: MIT license
pragma solidity =0.8.28;

struct Mint {
    uint8 decimals;
    uint256 supply;
    address mintAuthority;
    address freezeAuthority;
    address mintAddress;
}

struct TokenAccount {
    address mintAddress;
    address owner;
    uint256 balance;
    bool isFrozen;
}

struct Metadata {
    string name;
    string symbol;
    string tokenURI;
}

contract Spl721 {
    mapping(address => Mint) public mints;
    mapping(address => TokenAccount) public tokenAccounts;
    mapping(address => Metadata) public nftMetadata;
    mapping(address => bool) public mintAddresses;
    mapping(address => bool) public tokenAddresses;

    function initializeMint(
        uint8 decimals,
        address mintAuthority,
        address freezeAuthority,
        address mintAddress
    )
        public
        returns (Mint memory)
    {
        require(!mintAddresses[mintAddress], "Mint already exists");
        mints[mintAddress] = Mint(decimals, 0, mintAuthority, freezeAuthority, mintAddress);
        mintAddresses[mintAddress] = true;
        return mints[mintAddress];
    }

    function setMetadata(
        address mintAddress,
        string memory name,
        string memory symbol,
        string memory tokenURI
    )
        public
    {
        require(mintAddresses[mintAddress], "Mint does not exist");
        nftMetadata[mintAddress] = Metadata(name, symbol, tokenURI);
    }

    function mintNFT(address toMintTokens, address mintAddress) public {
        require(mintAddresses[mintAddress], "NFT mint does not exist");
        require(mints[mintAddress].mintAuthority == msg.sender, "Only the mint authority can mint");
        require(mints[mintAddress].supply == 0, "NFT already minted");
        mints[mintAddress].supply = 1;
        address tokenAddress = address(uint160(uint256(keccak256(abi.encodePacked(toMintTokens, mintAddress)))));
        if (!tokenAddresses[tokenAddress]) {
            tokenAccounts[tokenAddress] = TokenAccount(mintAddress, toMintTokens, 0, false);
            tokenAddresses[tokenAddress] = true;
        }
        tokenAccounts[tokenAddress].balance = 1;
        tokenAccounts[tokenAddress].owner = toMintTokens;
    }

    function transfer(address to, address mintAddress, uint256 amount) public {
        address toTokenAddress = address(uint160(uint256(keccak256(abi.encodePacked(to, mintAddress)))));
        address fromTokenAddress = address(uint160(uint256(keccak256(abi.encodePacked(msg.sender, mintAddress)))));
        require(tokenAccounts[fromTokenAddress].balance >= amount, "Insufficient balance");
        require(amount == 1, "Only transferring 1 NFT at a time");
        require(tokenAccounts[fromTokenAddress].owner == msg.sender, "Not the NFT owner");
        require(!tokenAccounts[fromTokenAddress].isFrozen, "Sender token account is frozen");
        if (tokenAddresses[toTokenAddress]) {
            require(!tokenAccounts[toTokenAddress].isFrozen, "Receiver token account is frozen");
        }
        if (!tokenAddresses[toTokenAddress]) {
            tokenAccounts[toTokenAddress] = TokenAccount(mintAddress, to, 0, false);
            tokenAddresses[toTokenAddress] = true;
        }
        tokenAccounts[fromTokenAddress].balance -= amount;
        tokenAccounts[toTokenAddress].balance += amount;
        tokenAccounts[toTokenAddress].owner = to;
    }

    function freezeAccount(address owner, address mintAddress) public {
        require(mintAddresses[mintAddress], "Mint does not exist");
        require(mints[mintAddress].freezeAuthority == msg.sender, "Only the freeze authority can freeze");
        address tokenAddress = address(uint160(uint256(keccak256(abi.encodePacked(owner, mintAddress)))));
        require(tokenAddresses[tokenAddress], "Token account not found");
        tokenAccounts[tokenAddress].isFrozen = true;
    }

    function getMint(address token) public view returns (Mint memory) {
        return mints[token];
    }

    function getTokenAccount(address owner, address token) public view returns (TokenAccount memory) {
        return tokenAccounts[address(uint160(uint256(keccak256(abi.encodePacked(owner, token)))))];
    }

    function getMetadata(address mintAddress) public view returns (Metadata memory) {
        return nftMetadata[mintAddress];
    }
}

As with fungible tokens, EmpoorioChain's NFT logic ships once as part of the runtime; every collection is data inside that one pallet rather than its own deployed contract.

Where's the NFT Metadata?

Metadata (name, symbol, attributes, and an off-chain URI if used) is stored directly in pallet-uniques's own metadata storage per item and per collection, set via dedicated extrinsics at mint time — no separate metadata program to deploy.

Tradeoffs of Each Approach

ERC-721

  • Highly customizable per collection
  • New contract deployment for each collection
  • Indexing requires tracking every contract address
  • Metadata handling varies contract to contract

pallet-uniques

  • One pallet handles every collection
  • New collections/items created via extrinsics, not new contracts
  • Easier to index — one pallet, consistent storage shape
  • Advanced per-collection logic requires a pallet extension, not a quick redeploy

High-Level Comparison

Below is a comparison of the basic steps for creating an NFT collection on Ethereum vs. EmpoorioChain.

StepEthereum (ERC-721)EmpoorioChain (pallet-uniques)
1. Prepare CodeWrite/import a Solidity contract (e.g. OpenZeppelin's ERC721).No custom code needed — the pallet is already part of the runtime.
2. Compile & DeployCompile and deploy with Hardhat/Foundry.Create a collection ID via an extrinsic — no separate deployment.
3. MintCall the contract's mint() function.Call the pallet's mint extrinsic for a given item ID within the collection.
4. RecipientA plain Ethereum address; wallets often add the contract manually to display it.Any AccountId32 can own an item directly.
5. Check ResultsView on Etherscan/marketplace by contract address.Query via EmpooScan or api.query.uniques.asset by collection/item ID.
6. Unique IdentifiersContract address + tokenId.(Collection ID, item ID) pair.
7. Code UpdatesProxy pattern or redeploy.Pallet logic changes require a governed runtime upgrade.

Want to mint your own NFT on EmpoorioChain? See the pallet reference and emp-cli docs.

How to interact with pallet-uniques

The examples below use @polkadot/api. Since NFT collections on EmpoorioChain aren't tied to a per-collection contract address, you look up items by (collection ID, item ID) rather than a bare tokenId against a contract.

Reading collection/item metadata

solidity
function name() external view returns (string); // Returns the token collection name

Read via api.query.uniques.instanceMetadataOf(collectionId, itemId) or the collection-level metadata query, depending on what you need.

jsx
// EmpoorioChain: query on-chain asset metadata via @polkadot/api
// against the pallet_emp_assets metadata storage for the given assetId.
// See the EmpoorioChain SDK docs for the exact metadata query shape.
import { ApiPromise, WsProvider } from "@polkadot/api";

async function name(assetId) {
  const api = await ApiPromise.create({ provider: new WsProvider("wss://rpc.testnet.empooriochain.org") });
  const metadata = await api.query.assets.metadata(assetId);
  return metadata.toHuman();
}

name().then(nftName => {
    console.log("NFT Name:", nftName);
  })
  .catch(error => {
    console.error("Error:", error);
  });

Comparing to the ERC-721 interface

solidity
function symbol() external view returns (string); // Returns the token collection symbol

Ethereum exposes name()/symbol() as contract view functions; EmpoorioChain exposes the equivalent as storage reads against the pallet.

jsx
// EmpoorioChain: query on-chain asset metadata via @polkadot/api
// against the pallet_emp_assets metadata storage for the given assetId.
// See the EmpoorioChain SDK docs for the exact metadata query shape.
import { ApiPromise, WsProvider } from "@polkadot/api";

async function symbol(assetId) {
  const api = await ApiPromise.create({ provider: new WsProvider("wss://rpc.testnet.empooriochain.org") });
  const metadata = await api.query.assets.metadata(assetId);
  return metadata.toHuman();
}

symbol().then(nftSymbol => {
  console.log("NFT Symbol:", nftSymbol);
}).catch(error => {
  console.error("Error:", error);
});

Reading an item's URI/attributes

bash
function tokenURI(uint256 _tokenId) external view returns (string); // Returns the tokenURI of the token

Stored in the item's metadata entry, set at mint time via the pallet's metadata extrinsic.

jsx
import { createUmi } from "@polkadot/api";
import { fetchDigitalAsset, mplTokenMetadata } from "@polkadot/api";
import { PublicKey } from "@polkadot/api";
const mintAddress = new PublicKey("Token Address");

async function tokenURI( /* no tokenId */ ) {
  try {
    const umi = new WsProvider("wss://rpc.testnet.empooriochain.org");
    umi.use(mplTokenMetadata());
    const digitalAsset = await fetchDigitalAsset(umi, mintAddress);
    return digitalAsset.metadata.uri;
  } catch (error) {
    console.error("Error fetching token URI:", error);
    return null;
  }
}

tokenURI()
  .then(uri => {
    console.log("Token URI:", uri);
  })
  .catch(error => {
    console.error("Error:", error);
  });

How to check ownership

bash
function ownerOf(uint256 _tokenId) public view returns (address) // Returns the owner of the tokenId token

We check the owner of an item via api.query.uniques.asset(collectionId, itemId), using the (collection, item) pair instead of a bare token ID.

jsx
import { ApiPromise, WsProvider } from "@polkadot/api";

const api = await ApiPromise.create({
  provider: new WsProvider("wss://rpc.testnet.empooriochain.org"),
});

async function ownerOf(collectionId, itemId) {
  const item = await api.query.uniques.asset(collectionId, itemId);
  return item.isSome ? item.unwrap().owner.toString() : null;
}

ownerOf(1, 1).then(owner => {
  console.log(owner);
}).catch(error => {
  console.error("Error:", error);
});

How to transfer an item

solidity
function transferFrom(address _from, address _to, uint256 _tokenId) external payable; // Transfers tokenId token from _from to _to

On EmpoorioChain, each item is owned directly by an AccountId32 — there's no derived token-account to look up first. A single uniques.transfer extrinsic moves ownership.

jsx
import { ApiPromise, WsProvider, Keyring } from "@polkadot/api";

const api = await ApiPromise.create({
  provider: new WsProvider("wss://rpc.testnet.empooriochain.org"),
});
const keyring = new Keyring({ type: "sr25519" });
const owner = keyring.addFromUri("//your-seed-here");

// pallet_uniques identifies NFTs by (collectionId, itemId) rather than a
// single contract address + tokenId — there is no separate "associated
// token account" to derive first, the recipient's AccountId32 receives it.
async function transferFrom(collectionId, itemId, to) {
  const tx = api.tx.uniques.transfer(collectionId, itemId, to);
  const hash = await tx.signAndSend(owner);
  return hash.toString();
}

transferFrom(1, 1, "5FHneW...")
  .then(result => {
    console.log("transferFrom result:", result);
  })
  .catch(error => {
    console.error("Error:", error);
  });

Fee-sponsored (relayer-paid) transfers, where a marketplace or app wants to cover the fee, are handled by EmpoorioChain's native paymaster pallet rather than a bespoke pattern.

jsx
// A relayer can submit a user-signed transfer and cover the fee itself —
// EmpoorioChain does not require the receiver to pre-create a token
// account before an NFT can be sent to them.
import { ApiPromise, WsProvider, Keyring } from "@polkadot/api";

const api = await ApiPromise.create({
  provider: new WsProvider("wss://rpc.testnet.empooriochain.org"),
});
const keyring = new Keyring({ type: "sr25519" });
const relayer = keyring.addFromUri("//relayer-seed-here");

async function transferFromSponsored(collectionId, itemId, to, signedPayload) {
  const tx = api.tx.uniques.transfer(collectionId, itemId, to);
  const hash = await tx.signAndSend(relayer, { payload: signedPayload });
  return hash.toString();
}

Want to explore more? See the pallet reference for the full pallet-uniques extrinsic list.

Node storing all history and participating in consensus

  • Ethereum: Archive Node
  • EmpoorioChain: Full node with archive pruning enabled

Node producing/finalizing blocks

  • Ethereum: Full Node (execution + consensus client)
  • EmpoorioChain: Validator node (Aura block production + GRANDPA finality, single Rust binary)

Node serving RPC without validating

  • Ethereum: Light Node
  • EmpoorioChain: RPC node (non-validator, full state)

EVM TO EMPOORIOCHAIN

Start building on EmpoorioChain

Intro to EmpoorioChain Development

Read

EmpoorioChain Pallet Reference

Read

emp-cli Quickstart

Read

More EmpoorioChain Developer Tools

Read
What is ERC-721 on EmpoorioChain? | empoorio