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):
// 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.
| Step | Ethereum (ERC-721) | EmpoorioChain (pallet-uniques) |
|---|---|---|
| 1. Prepare Code | Write/import a Solidity contract (e.g. OpenZeppelin's ERC721). | No custom code needed — the pallet is already part of the runtime. |
| 2. Compile & Deploy | Compile and deploy with Hardhat/Foundry. | Create a collection ID via an extrinsic — no separate deployment. |
| 3. Mint | Call the contract's mint() function. | Call the pallet's mint extrinsic for a given item ID within the collection. |
| 4. Recipient | A plain Ethereum address; wallets often add the contract manually to display it. | Any AccountId32 can own an item directly. |
| 5. Check Results | View on Etherscan/marketplace by contract address. | Query via EmpooScan or api.query.uniques.asset by collection/item ID. |
| 6. Unique Identifiers | Contract address + tokenId. | (Collection ID, item ID) pair. |
| 7. Code Updates | Proxy 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
function name() external view returns (string); // Returns the token collection nameRead via api.query.uniques.instanceMetadataOf(collectionId, itemId) or the collection-level metadata query, depending on what you need.
// 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
function symbol() external view returns (string); // Returns the token collection symbolEthereum exposes name()/symbol() as contract view functions; EmpoorioChain exposes the equivalent as storage reads against the pallet.
// 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
function tokenURI(uint256 _tokenId) external view returns (string); // Returns the tokenURI of the tokenStored in the item's metadata entry, set at mint time via the pallet's metadata extrinsic.
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
function ownerOf(uint256 _tokenId) public view returns (address) // Returns the owner of the tokenId tokenWe check the owner of an item via api.query.uniques.asset(collectionId, itemId), using the (collection, item) pair instead of a bare token ID.
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
function transferFrom(address _from, address _to, uint256 _tokenId) external payable; // Transfers tokenId token from _from to _toOn 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.
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.
// 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.


