ERC-20 standardizes fungible tokens on Ethereum: every unit of a given token is interchangeable, and each token type is its own deployed contract.
pallet-emp-assets
pallet-emp-assets is the native runtime module EmpoorioChain uses for fungible tokens. Instead of deploying a new contract per token, you register a new asset ID in this pallet's storage. This is different from Ethereum, where every token is its own independently deployed and audited contract.
Why one pallet instead of one contract per token? Because EmpoorioChain's account model separates pallet logic (compiled into the runtime) from application data (stored in each pallet's own storage maps) — see the Accounts and Smart Contracts pages for the full picture.
Tokens Don't Have Contract Addresses, Only Asset IDs
On Ethereum, each token is uniquely identified by its contract address. On EmpoorioChain, each token is identified by an asset ID registered in pallet-emp-assets — there's no separate contract address to deploy, because the logic already lives in the pallet.
No Separate Approval Flow for Basic Transfers
On Ethereum, transferring ERC-20 tokens on someone's behalf often requires approve + transferFrom. On EmpoorioChain, each account already holds its balance directly under its own AccountId32 in the pallet's storage — a single extrinsic can move tokens without a prior approval step for a direct transfer. (Delegated spending, where it's needed, is handled by the pallet's own approval-style calls, not a bespoke pattern per token.)
What Would an ERC-20-equivalent Look Like in Solidity?
For comparison, here's roughly what the same fungible-token behavior would look like written as a single reusable Solidity contract (this is illustrative, not literal EmpoorioChain code — the real logic lives in pallet-emp-assets, 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;
}
contract Spl20 {
mapping(address => Mint) public mints;
mapping(address => TokenAccount) public tokenAccounts;
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] == false, "Mint already exists");
mints[mintAddress] = Mint(decimals, 0, mintAuthority, freezeAuthority, mintAddress);
mintAddresses[mintAddress] = true;
return Mint(decimals, 0, mintAuthority, freezeAuthority, mintAddress);
}
function mintTokens(address toMintTokens, address mintAddress, uint256 amount) public {
require(mints[mintAddress].mintAuthority == msg.sender, "Only the mint authority can mint tokens");
require(mints[mintAddress].mintAddress != address(0), "Token does not exist");
require(mints[mintAddress].supply + amount <= type(uint256).max, "Supply overflow");
mints[mintAddress].supply += amount;
address tokenAddress = address(uint160(uint256(keccak256(abi.encodePacked(toMintTokens, mintAddress)))));
if (tokenAccounts[tokenAddress].mintAddress == address(0)) {
tokenAccounts[tokenAddress] = TokenAccount(mintAddress, toMintTokens, 0, false);
tokenAddresses[tokenAddress] = true;
}
tokenAccounts[tokenAddress].balance += amount;
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(tokenAccounts[toTokenAddress].balance + amount <= type(uint256).max, "Supply overflow");
require(tokenAccounts[fromTokenAddress].owner == msg.sender, "fromToken owner is not msg.sender");
require(tokenAccounts[fromTokenAddress].isFrozen == false, "fromToken is frozen");
require(tokenAccounts[toTokenAddress].isFrozen == false, "toToken is frozen");
if (tokenAccounts[toTokenAddress].mintAddress == address(0)) {
tokenAccounts[toTokenAddress] = TokenAccount(mintAddress, to, 0, false);
tokenAddresses[toTokenAddress] = true;
}
tokenAccounts[fromTokenAddress].balance -= amount;
tokenAccounts[toTokenAddress].balance += amount;
}
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)))))];
}
}As shown, a single reusable contract can express the same core operations (mint, transfer, balance lookup) that pallet-emp-assets exposes as extrinsics — the difference is that on EmpoorioChain this logic ships once with the runtime, rather than once per deployed token contract.
Where's the Token Metadata?
pallet-emp-assets stores basic metadata (name, symbol, decimals) directly in its own metadata storage item per asset ID, set via a dedicated extrinsic at registration time — there's no separate metadata contract to deploy or maintain.
Tradeoffs of Each Approach
ERC-20
- Highly customizable per token (arbitrary logic in the contract)
- Requires a new audited contract deployment per token
- Approval flow needed for delegated transfers
- Indexing requires tracking every contract address separately
pallet-emp-assets
- One audited pallet covers every asset
- New tokens are created by registering an asset ID, not deploying code
- Indexing is simpler — one pallet, one set of storage maps
- Custom per-token logic requires a pallet extension or governance change, not a quick redeploy
High-Level Comparison
Below is a comparison of the basic steps for issuing a fungible token on Ethereum vs. EmpoorioChain.
| Step | Ethereum (ERC-20) | EmpoorioChain (pallet-emp-assets) |
|---|---|---|
| 1. Prepare Token Code | Write/import a Solidity contract (e.g. via OpenZeppelin). | No custom code needed — the pallet is already part of the runtime. |
| 2. Compile & Deploy | Compile and deploy with Hardhat/Foundry. | Register a new asset ID via an extrinsic — no separate deployment step. |
| 3. Initial Mint | Call the contract's constructor or mint(). | Call the pallet's mint extrinsic for the registered asset ID. |
| 4. Recipient Setup | A plain Ethereum address; wallets often need the contract address added manually. | Any AccountId32 can hold the asset directly; no separate account derivation. |
| 5. Check Results | View on Etherscan by contract address. | Query via EmpooScan or api.query.assets.account by asset ID. |
| 6. Code Updates | Proxy pattern or redeploy. | Pallet logic is fixed at runtime-upgrade granularity, governed on-chain. |
| 7. Audit Requirements | Each contract typically needs its own audit. | The shared pallet is audited once; per-asset parameters carry much less custom-code risk. |
Want to register your own asset on EmpoorioChain? Check the pallet reference and emp-cli docs linked below.
How to interact with pallet-emp-assets
The examples below use @polkadot/api, the standard JavaScript/TypeScript client library for Substrate chains, pointed at EmpoorioChain's testnet RPC. Exact extrinsic and storage-item names should be verified against the current EmpoorioChain SDK/pallet reference, since they can evolve.
Reading name/symbol
function name() public view returns (string) // Returns the name of the tokenName and symbol are read from the asset's metadata storage item, via api.query.assets.metadata(assetId).
// 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(name => {
console.log("token Name:", name);
})
.catch(error => {
console.error("Error:", error);
});
How the ERC-20 interface signature compares
function symbol() public view returns (string) // Returns the symbol of the tokenThe Ethereum equivalent is a plain view function on the ERC-20 contract.
// 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(symbol => {
console.log("Symbol:", symbol);
}).catch(error => {
console.error("Error:", error);
});
Reading decimals
function decimals() public view returns (uint8) // Returns the number of decimals the token useDecimals are part of the same asset metadata / asset-info storage item.
import { ApiPromise, WsProvider } from "@polkadot/api";
const api = await ApiPromise.create({
provider: new WsProvider("wss://rpc.testnet.empooriochain.org"),
});
async function decimals(assetId) {
const asset = await api.query.assets.asset(assetId);
return asset.unwrap().decimals?.toNumber?.() ?? asset.toHuman();
}
decimals(1).then(console.log).catch(console.error);Reading a balance
function balanceOf(address _owner) public view returns (uint256 balance) // Returns the number of tokens in owner's accountimport { ApiPromise, WsProvider } from "@polkadot/api";
const api = await ApiPromise.create({
provider: new WsProvider("wss://rpc.testnet.empooriochain.org"),
});
async function balanceOf(assetId, accountId) {
const account = await api.query.assets.account(assetId, accountId);
return account.isSome ? account.unwrap().balance.toString() : "0";
}
balanceOf(1, "5Grwv...").then(console.log).catch(console.error);An account's balance for a given asset is read via api.query.assets.account(assetId, accountId).
Reading total supply
function totalSupply() public view returns (uint256) // Returns the total issuance of tokensTotal supply is part of the asset's on-chain info, via api.query.assets.asset(assetId).
import { ApiPromise, WsProvider } from "@polkadot/api";
const api = await ApiPromise.create({
provider: new WsProvider("wss://rpc.testnet.empooriochain.org"),
});
async function totalSupply(assetId) {
const asset = await api.query.assets.asset(assetId);
return asset.unwrap().supply.toString();
}
totalSupply(1).then(console.log).catch(console.error);Transferring
function transfer(address _to, uint256 _value) public returns (bool success) // Moves a value amount of tokens from the caller’s account to _to On EmpoorioChain, each account holds its balance directly under its AccountId32 — there's no derived token-account address to compute first. A single assets.transfer extrinsic, signed with @polkadot/api, moves the asset.
import { Keypair, Transaction, Connection, PublicKey } from "@polkadot/api";
import { createTransferCheckedInstruction } from "@polkadot/api";
const connection = new Connection("https://rpc.testnet.empooriochain.org", "confirmed");
// Must contain your private key as a Uint8Array
const ownerSecretkey = [];
const ownerPrivatekeypair = Keypair.fromSecretKey(new Uint8Array(ownerSecretkey));
const receiverAddress = new PublicKey("Receiver's Wallet Address");
const mintAddress = new PublicKey("Token Address");
const ownerTokenAccount = new PublicKey("Your Associated Token Account Address");
const receiverTokenAccount = new PublicKey("Receiver's Associated Token Account Address");
// For a token with 9 decimals, transferring 1 => 1 * 10^9
const amount = 1;
async function transfer(_to, _value) {
try {
// Create a transaction with the transfer instruction
const tx = new Transaction().add(
createTransferCheckedInstruction(
ownerTokenAccount,
mintAddress,
receiverTokenAccount,
ownerPrivatekeypair.publicKey,
_value * Math.pow(10, 9), // Decimal correction
9 // decimals
)
);
// Send the transaction (simplified, no explicit blockhash or feePayer set)
await connection.sendTransaction(tx, [ownerPrivatekeypair]);
return true;
} catch (error) {
console.error("Error in transfer:", error);
return false;
}
}
transfer(receiverAddress, amount)
.then(result => {
console.log(result);
})
.catch(error => {
console.error("Error:", error);
});
Fee-sponsored (relayer-paid) transfers are supported natively via EmpoorioChain's paymaster pallet, rather than requiring a bespoke meta-transaction pattern per token.
import { Keypair, Transaction, Connection, PublicKey } from "@polkadot/api";
import { createTransferCheckedInstruction, getOrCreateAssociatedTokenAccount } from "@polkadot/api";
const connection = new Connection("https://rpc.testnet.empooriochain.org", "confirmed");
// Must contain your private key as a Uint8Array
const ownerSecretkey = [];
const ownerPrivatekeypair = Keypair.fromSecretKey(new Uint8Array(ownerSecretkey));
const receiverAddress = new PublicKey("Receiver's Wallet Address");
const mintAddress = new PublicKey("Token Address");
const amount = 1; // Amount to transfer
async function transfer(_to, _value) {
try {
// Get or create the sender's ATA
const ownerTokenAccount = await getOrCreateAssociatedTokenAccount(
connection,
ownerPrivatekeypair, // Fee payer
mintAddress,
ownerPrivatekeypair.publicKey
);
// Get or create the receiver's ATA
const receiverTokenAccount = await getOrCreateAssociatedTokenAccount(
connection,
ownerPrivatekeypair, // Fee payer
mintAddress,
_to
);
// Build the transaction
const tx = new Transaction().add(
createTransferCheckedInstruction(
ownerTokenAccount.address,
mintAddress,
receiverTokenAccount.address,
ownerPrivatekeypair.publicKey,
_value * Math.pow(10, 9), // Decimal correction (9 decimals)
9 // decimals
)
);
// Send the transaction (simple version)
await connection.sendTransaction(tx, [ownerPrivatekeypair]);
return true;
} catch (error) {
console.error("Error in transfer:", error);
return false;
}
}
// Execute the transfer function
transfer(receiverAddress, amount)
.then(result => {
console.log("Transaction result:", result);
})
.catch(error => {
console.error("Error:", error);
});
Want to explore more? See the EmpoorioChain pallet reference for the full pallet-emp-assets extrinsic and storage-item list.


