Introduction to Token Standards
Token standards define the technical specifications and interfaces that tokens must implement to ensure interoperability, security, and functionality. These standards are critical for asset tokenization as they determine how tokens can be created, transferred, queried, and integrated with wallets, exchanges, and other smart contracts. Choosing the right token standard is one of the most important technical decisions in a tokenization project.
Ethereum has become the dominant platform for tokenized assets, and Ethereum token standards serve as templates that have been adopted or adapted by other blockchain platforms. The most relevant standards for asset tokenization are ERC-20 (fungible tokens), ERC-721 (non-fungible tokens), ERC-1400 (security tokens), and ERC-3643 (T-REX standard for compliant securities).
ERC-20: Fungible Token Standard
Overview and Core Functions
ERC-20, proposed in 2015, defines a standard interface for fungible tokens where each token is identical and interchangeable. This standard is suitable for tokenizing assets where all units are equivalent, such as shares in a REIT, bonds, or commodity-backed tokens. ERC-20's simplicity and widespread adoption make it the most common starting point for tokenization projects.
The ERC-20 standard defines six mandatory functions (totalSupply, balanceOf, transfer, transferFrom, approve, allowance) and two optional fields (name, symbol, decimals). These functions enable basic token operations: checking balances, transferring tokens, and approving third parties to spend tokens on the owner's behalf (useful for decentralized exchanges and automated market makers).
// TypeScript interfaces for ERC-20 token standard
interface IERC20 {
// Returns the total token supply
totalSupply(): Promise<bigint>;
// Returns the account balance of another account
balanceOf(account: string): Promise<bigint>;
// Transfers tokens to a specified address
transfer(to: string, amount: bigint): Promise<boolean>;
// Returns the amount which spender is still allowed to withdraw from owner
allowance(owner: string, spender: string): Promise<bigint>;
// Allows spender to withdraw from your account multiple times, up to amount
approve(spender: string, amount: bigint): Promise<boolean>;
// Transfers tokens from one address to another
transferFrom(from: string, to: string, amount: bigint): Promise<boolean>;
}
// Extended ERC-20 for tokenized real estate
interface ITokenizedRealEstateERC20 extends IERC20 {
// Additional metadata
name(): Promise<string>;
symbol(): Promise<string>;
decimals(): Promise<number>;
// Asset-specific functions
propertyAddress(): Promise<string>;
appraisedValue(): Promise<bigint>;
// Dividend distribution
distributeDividends(amount: bigint): Promise<void>;
claimDividends(): Promise<bigint>;
dividendsOf(account: string): Promise<bigint>;
// Compliance
isTransferAllowed(from: string, to: string, amount: bigint): Promise<boolean>;
}
// Implementation example
class TokenizedRealEstateERC20 implements ITokenizedRealEstateERC20 {
private _totalSupply: bigint;
private _balances: Map<string, bigint>;
private _allowances: Map<string, Map<string, bigint>>;
private _dividendBalances: Map<string, bigint>;
constructor(
private _name: string,
private _symbol: string,
private _propertyAddress: string,
private _appraisedValue: bigint,
initialSupply: bigint
) {
this._totalSupply = initialSupply;
this._balances = new Map();
this._allowances = new Map();
this._dividendBalances = new Map();
}
async totalSupply(): Promise<bigint> {
return this._totalSupply;
}
async balanceOf(account: string): Promise<bigint> {
return this._balances.get(account) || 0n;
}
async transfer(to: string, amount: bigint): Promise<boolean> {
const sender = this.getCurrentAccount();
// Check compliance before transfer
const allowed = await this.isTransferAllowed(sender, to, amount);
if (!allowed) {
throw new Error('Transfer not allowed by compliance rules');
}
const senderBalance = await this.balanceOf(sender);
if (senderBalance < amount) {
throw new Error('Insufficient balance');
}
this._balances.set(sender, senderBalance - amount);
const recipientBalance = await this.balanceOf(to);
this._balances.set(to, recipientBalance + amount);
return true;
}
async distributeDividends(amount: bigint): Promise<void> {
const totalSupply = await this.totalSupply();
for (const [account, balance] of this._balances) {
const share = (balance * amount) / totalSupply;
const currentDividends = this._dividendBalances.get(account) || 0n;
this._dividendBalances.set(account, currentDividends + share);
}
}
async claimDividends(): Promise<bigint> {
const account = this.getCurrentAccount();
const dividends = this._dividendBalances.get(account) || 0n;
if (dividends > 0n) {
this._dividendBalances.set(account, 0n);
// Transfer dividends to account (implementation depends on payment method)
}
return dividends;
}
async isTransferAllowed(
from: string,
to: string,
amount: bigint
): Promise<boolean> {
// Implement KYC/AML checks, accredited investor verification,
// transfer restrictions, etc.
return true; // Simplified
}
async allowance(owner: string, spender: string): Promise<bigint> {
return this._allowances.get(owner)?.get(spender) || 0n;
}
async approve(spender: string, amount: bigint): Promise<boolean> {
const owner = this.getCurrentAccount();
if (!this._allowances.has(owner)) {
this._allowances.set(owner, new Map());
}
this._allowances.get(owner)!.set(spender, amount);
return true;
}
async transferFrom(from: string, to: string, amount: bigint): Promise<boolean> {
const spender = this.getCurrentAccount();
const currentAllowance = await this.allowance(from, spender);
if (currentAllowance < amount) {
throw new Error('Insufficient allowance');
}
// Reduce allowance
this._allowances.get(from)!.set(spender, currentAllowance - amount);
// Execute transfer
const fromBalance = await this.balanceOf(from);
if (fromBalance < amount) {
throw new Error('Insufficient balance');
}
this._balances.set(from, fromBalance - amount);
const toBalance = await this.balanceOf(to);
this._balances.set(to, toBalance + amount);
return true;
}
async name(): Promise<string> { return this._name; }
async symbol(): Promise<string> { return this._symbol; }
async decimals(): Promise<number> { return 18; }
async propertyAddress(): Promise<string> { return this._propertyAddress; }
async appraisedValue(): Promise<bigint> { return this._appraisedValue; }
async dividendsOf(account: string): Promise<bigint> {
return this._dividendBalances.get(account) || 0n;
}
private getCurrentAccount(): string {
// Implementation to get current transaction sender
return '0x...';
}
}
Limitations of ERC-20 for Securities
While ERC-20 is simple and widely supported, it lacks features essential for compliant securities tokenization. ERC-20 has no built-in transfer restrictions, no identity management, no forced transfers (required for court orders or corporate actions), and no partition management (needed for different share classes). These limitations led to the development of specialized security token standards.
ERC-721: Non-Fungible Token Standard
Use Cases in Asset Tokenization
ERC-721 defines a standard for non-fungible tokens (NFTs) where each token is unique and not interchangeable. This standard is ideal for tokenizing unique assets like individual real estate properties, specific artworks, or intellectual property rights. Each ERC-721 token has a unique tokenId and can have distinct metadata, ownership history, and attributes.
For asset tokenization, ERC-721 enables representing each unique asset as a distinct token. A portfolio of 100 different properties could be represented as 100 ERC-721 tokens, each with metadata describing that specific property. This approach maintains the uniqueness and individual characteristics of each asset while providing blockchain-based ownership and transfer mechanisms.
// TypeScript interfaces for ERC-721 token standard
interface IERC721 {
// Returns the number of NFTs owned by owner
balanceOf(owner: string): Promise<bigint>;
// Returns the owner of the NFT specified by tokenId
ownerOf(tokenId: bigint): Promise<string>;
// Transfers ownership of an NFT
transferFrom(from: string, to: string, tokenId: bigint): Promise<void>;
// Safely transfers ownership of an NFT
safeTransferFrom(from: string, to: string, tokenId: bigint): Promise<void>;
// Grants permission to transfer a specific NFT
approve(approved: string, tokenId: bigint): Promise<void>;
// Grants or revokes permission to transfer all NFTs
setApprovalForAll(operator: string, approved: boolean): Promise<void>;
// Gets the approved address for a single NFT
getApproved(tokenId: bigint): Promise<string>;
// Queries if an address is an authorized operator for another address
isApprovedForAll(owner: string, operator: string): Promise<boolean>;
}
// Metadata extension for asset details
interface IERC721Metadata {
name(): Promise<string>;
symbol(): Promise<string>;
tokenURI(tokenId: bigint): Promise<string>;
}
// Tokenized unique real estate property
interface PropertyMetadata {
propertyId: string;
address: string;
type: 'residential' | 'commercial' | 'industrial';
squareFootage: number;
yearBuilt: number;
appraisedValue: bigint;
legalDescription: string;
imageURIs: string[];
documentURIs: string[]; // title, deeds, etc.
}
class TokenizedPropertyNFT implements IERC721, IERC721Metadata {
private _owners: Map<bigint, string>;
private _balances: Map<string, bigint>;
private _tokenApprovals: Map<bigint, string>;
private _operatorApprovals: Map<string, Set<string>>;
private _tokenMetadata: Map<bigint, PropertyMetadata>;
constructor(
private _name: string,
private _symbol: string
) {
this._owners = new Map();
this._balances = new Map();
this._tokenApprovals = new Map();
this._operatorApprovals = new Map();
this._tokenMetadata = new Map();
}
async mint(
to: string,
tokenId: bigint,
metadata: PropertyMetadata
): Promise<void> {
if (this._owners.has(tokenId)) {
throw new Error('Token already exists');
}
this._owners.set(tokenId, to);
this._tokenMetadata.set(tokenId, metadata);
const balance = this._balances.get(to) || 0n;
this._balances.set(to, balance + 1n);
}
async ownerOf(tokenId: bigint): Promise<string> {
const owner = this._owners.get(tokenId);
if (!owner) {
throw new Error('Token does not exist');
}
return owner;
}
async balanceOf(owner: string): Promise<bigint> {
return this._balances.get(owner) || 0n;
}
async transferFrom(from: string, to: string, tokenId: bigint): Promise<void> {
const owner = await this.ownerOf(tokenId);
const sender = this.getCurrentAccount();
// Check if sender is authorized
const isApproved = this._tokenApprovals.get(tokenId) === sender;
const isOperator = this._operatorApprovals.get(owner)?.has(sender);
if (owner !== sender && !isApproved && !isOperator) {
throw new Error('Not authorized to transfer');
}
// Execute transfer
this._owners.set(tokenId, to);
const fromBalance = this._balances.get(from) || 0n;
this._balances.set(from, fromBalance - 1n);
const toBalance = this._balances.get(to) || 0n;
this._balances.set(to, toBalance + 1n);
// Clear approval
this._tokenApprovals.delete(tokenId);
}
async getPropertyMetadata(tokenId: bigint): Promise<PropertyMetadata> {
const metadata = this._tokenMetadata.get(tokenId);
if (!metadata) {
throw new Error('Token does not exist');
}
return metadata;
}
async tokenURI(tokenId: bigint): Promise<string> {
const metadata = await this.getPropertyMetadata(tokenId);
// Return URI to metadata JSON (typically IPFS)
return `ipfs://QmPropertyMetadata/${tokenId}`;
}
async name(): Promise<string> { return this._name; }
async symbol(): Promise<string> { return this._symbol; }
async approve(approved: string, tokenId: bigint): Promise<void> {
const owner = await this.ownerOf(tokenId);
const sender = this.getCurrentAccount();
if (owner !== sender) {
throw new Error('Not token owner');
}
this._tokenApprovals.set(tokenId, approved);
}
async setApprovalForAll(operator: string, approved: boolean): Promise<void> {
const sender = this.getCurrentAccount();
if (!this._operatorApprovals.has(sender)) {
this._operatorApprovals.set(sender, new Set());
}
if (approved) {
this._operatorApprovals.get(sender)!.add(operator);
} else {
this._operatorApprovals.get(sender)!.delete(operator);
}
}
async safeTransferFrom(from: string, to: string, tokenId: bigint): Promise<void> {
// Check if recipient can receive NFT
// Implementation depends on target contract
await this.transferFrom(from, to, tokenId);
}
async getApproved(tokenId: bigint): Promise<string> {
return this._tokenApprovals.get(tokenId) || '';
}
async isApprovedForAll(owner: string, operator: string): Promise<boolean> {
return this._operatorApprovals.get(owner)?.has(operator) || false;
}
private getCurrentAccount(): string {
return '0x...';
}
}
ERC-1400: Security Token Standard
Purpose and Key Features
ERC-1400 was developed specifically for security tokens, addressing the compliance and regulatory requirements that ERC-20 lacks. This standard introduces partitions (sub-classes of tokens with different rights), document management, forced transfers, issuance and redemption controls, and compliance hooks that execute before transfers to verify regulatory requirements.
The standard is modular, consisting of core token functionality (ERC-1400), partition management (ERC-1410), and document management (ERC-1643). This modularity allows issuers to implement only the features needed for their specific security type and regulatory requirements. ERC-1400 is widely adopted for compliant security token offerings and regulated tokenized assets.
| Feature | ERC-20 | ERC-721 | ERC-1400 | Security Tokens Need |
|---|---|---|---|---|
| Transfer Restrictions | No | No | Yes | Critical |
| Forced Transfers | No | No | Yes | Required for legal compliance |
| Partitions/Share Classes | No | N/A | Yes | Essential for complex structures |
| Document Management | No | No | Yes | Required for disclosure |
| Issuance/Redemption | Custom | Custom | Standardized | Important for lifecycle management |
| Wallet Support | Universal | Wide | Limited | Improving |
// TypeScript interfaces for ERC-1400 security token standard
interface IERC1400 {
// Document management
getDocument(name: string): Promise<{ uri: string; documentHash: string }>;
setDocument(name: string, uri: string, documentHash: string): Promise<void>;
// Token transfers with compliance
transferWithData(to: string, amount: bigint, data: string): Promise<void>;
transferFromWithData(
from: string,
to: string,
amount: bigint,
data: string
): Promise<void>;
// Issuance and redemption
issue(to: string, amount: bigint, data: string): Promise<void>;
redeem(amount: bigint, data: string): Promise<void>;
redeemFrom(from: string, amount: bigint, data: string): Promise<void>;
// Transfer validity
canTransfer(
to: string,
amount: bigint,
data: string
): Promise<{ valid: boolean; statusCode: number; reasonCode: string }>;
// Forced transfers (for legal compliance)
controllerTransfer(
from: string,
to: string,
amount: bigint,
data: string,
operatorData: string
): Promise<void>;
// Controller management
isControllable(): Promise<boolean>;
isOperator(operator: string, tokenHolder: string): Promise<boolean>;
authorizeOperator(operator: string): Promise<void>;
revokeOperator(operator: string): Promise<void>;
}
// Partition-based transfers (ERC-1410)
interface IERC1410 {
// Get balance by partition
balanceOfByPartition(partition: string, tokenHolder: string): Promise<bigint>;
// Get all partitions
partitionsOf(tokenHolder: string): Promise<string[]>;
// Transfer by partition
transferByPartition(
partition: string,
to: string,
amount: bigint,
data: string
): Promise<string>; // returns destination partition
// Operator transfer by partition
operatorTransferByPartition(
partition: string,
from: string,
to: string,
amount: bigint,
data: string,
operatorData: string
): Promise<string>;
// Default partitions for transfers
getDefaultPartitions(tokenHolder: string): Promise<string[]>;
setDefaultPartitions(partitions: string[]): Promise<void>;
}
// Implementation example with partitions for different share classes
class SecurityToken implements IERC1400, IERC1410 {
private _totalSupply: bigint = 0n;
private _balances: Map<string, bigint>;
private _partitionBalances: Map<string, Map<string, bigint>>; // partition -> holder -> balance
private _documents: Map<string, { uri: string; documentHash: string }>;
private _controllers: Set<string>;
private _operators: Map<string, Set<string>>; // holder -> operators
private _complianceService: ComplianceService;
constructor(complianceService: ComplianceService) {
this._balances = new Map();
this._partitionBalances = new Map();
this._documents = new Map();
this._controllers = new Set();
this._operators = new Map();
this._complianceService = complianceService;
}
async issue(to: string, amount: bigint, data: string): Promise<void> {
const sender = this.getCurrentAccount();
// Only controllers can issue
if (!this._controllers.has(sender)) {
throw new Error('Only controllers can issue tokens');
}
// Check compliance
const canTransferResult = await this.canTransfer(to, amount, data);
if (!canTransferResult.valid) {
throw new Error(`Cannot issue: ${canTransferResult.reasonCode}`);
}
// Mint tokens
this._totalSupply += amount;
const balance = this._balances.get(to) || 0n;
this._balances.set(to, balance + amount);
// Emit event (in actual implementation)
}
async canTransfer(
to: string,
amount: bigint,
data: string
): Promise<{ valid: boolean; statusCode: number; reasonCode: string }> {
// Check KYC/AML status
const kycValid = await this._complianceService.checkKYC(to);
if (!kycValid) {
return {
valid: false,
statusCode: 0x50,
reasonCode: 'Receiver not KYC verified'
};
}
// Check accredited investor status for certain partitions
const accreditedRequired = this.parseDataForAccreditationRequirement(data);
if (accreditedRequired) {
const isAccredited = await this._complianceService.checkAccreditation(to);
if (!isAccredited) {
return {
valid: false,
statusCode: 0x51,
reasonCode: 'Receiver not accredited investor'
};
}
}
// Check jurisdiction restrictions
const jurisdiction = await this._complianceService.getJurisdiction(to);
const allowedJurisdictions = ['US', 'UK', 'SG', 'CH'];
if (!allowedJurisdictions.includes(jurisdiction)) {
return {
valid: false,
statusCode: 0x52,
reasonCode: 'Jurisdiction not allowed'
};
}
return {
valid: true,
statusCode: 0x51,
reasonCode: 'Transfer approved'
};
}
async transferByPartition(
partition: string,
to: string,
amount: bigint,
data: string
): Promise<string> {
const from = this.getCurrentAccount();
// Get partition balance
if (!this._partitionBalances.has(partition)) {
throw new Error('Partition does not exist');
}
const partitionMap = this._partitionBalances.get(partition)!;
const fromBalance = partitionMap.get(from) || 0n;
if (fromBalance < amount) {
throw new Error('Insufficient partition balance');
}
// Check transfer validity
const canTransferResult = await this.canTransfer(to, amount, data);
if (!canTransferResult.valid) {
throw new Error(`Transfer not allowed: ${canTransferResult.reasonCode}`);
}
// Execute transfer
partitionMap.set(from, fromBalance - amount);
const toBalance = partitionMap.get(to) || 0n;
partitionMap.set(to, toBalance + amount);
// Return destination partition (can be different in some cases)
return partition;
}
async controllerTransfer(
from: string,
to: string,
amount: bigint,
data: string,
operatorData: string
): Promise<void> {
const operator = this.getCurrentAccount();
// Only controllers can force transfer
if (!this._controllers.has(operator)) {
throw new Error('Only controllers can force transfer');
}
// Execute transfer without checking from's approval
const fromBalance = this._balances.get(from) || 0n;
if (fromBalance < amount) {
throw new Error('Insufficient balance');
}
this._balances.set(from, fromBalance - amount);
const toBalance = this._balances.get(to) || 0n;
this._balances.set(to, toBalance + amount);
// Log forced transfer reason in operatorData
}
async setDocument(name: string, uri: string, documentHash: string): Promise<void> {
const sender = this.getCurrentAccount();
if (!this._controllers.has(sender)) {
throw new Error('Only controllers can set documents');
}
this._documents.set(name, { uri, documentHash });
}
async getDocument(name: string): Promise<{ uri: string; documentHash: string }> {
const doc = this._documents.get(name);
if (!doc) {
throw new Error('Document not found');
}
return doc;
}
// Additional methods implementation...
async transferWithData(to: string, amount: bigint, data: string): Promise<void> {
throw new Error('Not implemented');
}
async transferFromWithData(from: string, to: string, amount: bigint, data: string): Promise<void> {
throw new Error('Not implemented');
}
async redeem(amount: bigint, data: string): Promise<void> {
throw new Error('Not implemented');
}
async redeemFrom(from: string, amount: bigint, data: string): Promise<void> {
throw new Error('Not implemented');
}
async isControllable(): Promise<boolean> {
return this._controllers.size > 0;
}
async isOperator(operator: string, tokenHolder: string): Promise<boolean> {
return this._operators.get(tokenHolder)?.has(operator) || false;
}
async authorizeOperator(operator: string): Promise<void> {
throw new Error('Not implemented');
}
async revokeOperator(operator: string): Promise<void> {
throw new Error('Not implemented');
}
async balanceOfByPartition(partition: string, tokenHolder: string): Promise<bigint> {
return this._partitionBalances.get(partition)?.get(tokenHolder) || 0n;
}
async partitionsOf(tokenHolder: string): Promise<string[]> {
const partitions: string[] = [];
for (const [partition, holders] of this._partitionBalances) {
if ((holders.get(tokenHolder) || 0n) > 0n) {
partitions.push(partition);
}
}
return partitions;
}
async operatorTransferByPartition(
partition: string, from: string, to: string,
amount: bigint, data: string, operatorData: string
): Promise<string> {
throw new Error('Not implemented');
}
async getDefaultPartitions(tokenHolder: string): Promise<string[]> {
return ['default'];
}
async setDefaultPartitions(partitions: string[]): Promise<void> {
throw new Error('Not implemented');
}
private getCurrentAccount(): string {
return '0x...';
}
private parseDataForAccreditationRequirement(data: string): boolean {
// Parse encoded data to determine if accreditation required
return false;
}
}
interface ComplianceService {
checkKYC(address: string): Promise<boolean>;
checkAccreditation(address: string): Promise<boolean>;
getJurisdiction(address: string): Promise<string>;
}
Comparative Analysis of Token Standards
| Aspect | Best Standard | Reasoning |
|---|---|---|
| Tokenized REIT shares | ERC-1400 | Need transfer restrictions, partitions for share classes, regulatory compliance |
| Individual property NFT | ERC-721 | Each property is unique with distinct characteristics and value |
| Commodity-backed tokens | ERC-20 or ERC-1400 | Fungible units, ERC-1400 if securities law applies |
| Fractionalized artwork | ERC-1400 | Fungible shares but with transfer restrictions and compliance needs |
| Revenue-sharing tokens | ERC-1400 | Likely securities requiring compliance features |
Key Takeaways
- ERC-20 provides basic fungible token functionality suitable for simple tokenization but lacks compliance features needed for securities
- ERC-721 enables unique, non-fungible tokens ideal for individual unique assets like specific properties or artworks
- ERC-1400 is purpose-built for security tokens with transfer restrictions, partitions, forced transfers, and document management
- Token standard selection should be based on asset type (fungible vs unique), regulatory classification, and compliance requirements
- Partitions in ERC-1400 enable different share classes, voting rights, or restriction levels within a single token contract
- All standards can be extended with custom functionality while maintaining core interface compatibility
- Widespread wallet and exchange support favors ERC-20 and ERC-721, while ERC-1400 adoption is growing
Review Questions
- What are the six mandatory functions in the ERC-20 standard and what is the purpose of each?
- Explain why ERC-20 is insufficient for compliant security tokenization. What specific features are missing?
- When would you choose ERC-721 over ERC-20 for asset tokenization? Provide specific examples.
- What is the purpose of "partitions" in the ERC-1400 standard? Give a concrete use case for multi-partition tokens.
- Describe the "forced transfer" capability in ERC-1400. Why is this feature critical for securities compliance?
- How does the canTransfer function in ERC-1400 enable automated compliance checking? What types of checks might it perform?
- Compare the wallet ecosystem support for ERC-20, ERC-721, and ERC-1400. What are the implications for token adoption?