跳到主要内容

ERC 标准

问题

什么是 ERC 标准?ERC-20、ERC-721、ERC-1155 分别解决什么问题?前端如何与这些代币合约交互?

面试速答版

什么是 ERC 标准? ERC 是以太坊的应用层接口规范(属于 EIP 子集):

  • 规定合约必须实现哪些函数、抛哪些事件,钱包/交易所/DApp 才能用统一方式交互。
  • 最常见的三类:ERC-20(同质化代币)、ERC-721(NFT)、ERC-1155(半同质化)。

ERC-20/721/1155 各自解决什么?

  • ERC-20:可分割、可互换的代币(USDC、UNI),核心方法 transfer/approve/transferFrom,事件 Transfer/Approval;前端要按 decimals 处理精度。
  • ERC-721:每个 Token 唯一不可分(CryptoPunks、ENS 域名),靠 tokenId 区分,元数据通过 tokenURI 指向 JSON。
  • ERC-1155:一份合约管多种 Token,既能同质化又能 NFT,支持批量转账省 Gas,游戏/链游道具常用。

前端怎么和这些代币合约交互?

  • 精度:ERC-20 链上存的是整数,前端 formatUnits(value, decimals) 显示,输入用 parseUnits 转回;不要用 Number 算,必须 BigInt
  • 元数据:NFT tokenURI 拿 JSON,里面 image/name/attributes;HTTP 链接易失效,最好走 IPFS。
  • 授权流程:先 approvetransferFrom;ERC-721 还有 setApprovalForAll 一次授权所有。
  • Permit(EIP-2612):USDC 等 Token 支持离线签名授权,省一次交易和 Gas。

答案

ERC(Ethereum Request for Comments) 是以太坊上智能合约的标准接口规范。它定义了代币合约必须实现的方法和事件,使得钱包、DApp、交易所等能以统一方式与各种代币交互。

EIP vs ERC

概念全称说明
EIPEthereum Improvement Proposal以太坊改进提案,涵盖核心协议、网络、接口等所有层面
ERCEthereum Request for CommentsEIP 中专门针对应用层标准的子类别,主要定义合约接口
关系说明

ERC 是 EIP 的子集。例如 ERC-20 的正式编号是 EIP-20,但因为它定义的是应用层合约接口标准,所以习惯上称为 ERC-20。

ERC-20:同质化代币标准

ERC-20 是最基础也是使用最广泛的代币标准,定义了同质化代币(Fungible Token) 的接口。同质化意味着每个代币完全相同、可互换,就像人民币的每一元钱价值相等。

常见 ERC-20 代币:USDT、USDC、DAI、WETH、UNI、LINK 等。

接口定义

IERC20.sol
interface IERC20 {
// ---- 查询方法 ----
// 代币总供应量
function totalSupply() external view returns (uint256);
// 查询某地址的代币余额
function balanceOf(address account) external view returns (uint256);
// 查询 owner 授权给 spender 的额度
function allowance(address owner, address spender) external view returns (uint256);

// ---- 转账方法 ----
// 直接转账:调用者 → 接收者
function transfer(address to, uint256 amount) external returns (bool);
// 授权:允许 spender 代替调用者花费指定额度
function approve(address spender, uint256 amount) external returns (bool);
// 代理转账:从 from 转到 to(需要 from 事先 approve)
function transferFrom(address from, address to, uint256 amount) external returns (bool);

// ---- 事件 ----
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}

授权转账流程

在 DeFi 中,用户通常需要先 approve 合约,再由合约调用 transferFrom 完成操作:

前端交互要点:精度处理

decimals 精度陷阱

ERC-20 代币通常有 18 位小数(decimals = 18),但并非所有代币都是。USDT 和 USDC 是 6 位小数。链上存储的是最小单位的整数值,前端必须做精度转换。

erc20-interact.ts
import { ethers } from 'ethers';

const provider = new ethers.JsonRpcProvider('https://mainnet.infura.io/v3/YOUR_KEY');
const ERC20_ABI = [
'function balanceOf(address) view returns (uint256)',
'function decimals() view returns (uint8)',
'function symbol() view returns (string)',
'function transfer(address to, uint256 amount) returns (bool)',
'function approve(address spender, uint256 amount) returns (bool)',
];

// USDC 合约地址(decimals = 6)
const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
const usdcContract = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);

async function getFormattedBalance(userAddress: string) {
// 链上返回的是最小单位的整数,如 1000000 代表 1 USDC
const rawBalance = await usdcContract.balanceOf(userAddress);
const decimals = await usdcContract.decimals();

// formatUnits:链上原始值 → 人类可读字符串
// 1000000 → "1.0"(6位小数)
const formatted = ethers.formatUnits(rawBalance, decimals);

console.log(`余额: ${formatted} USDC`);
return formatted;
}

async function sendUSDC(signer: ethers.Signer, to: string, amount: string) {
const contract = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, signer);
const decimals = await contract.decimals();

// parseUnits:人类可读字符串 → 链上原始值
// "100.5" → 100500000n(6位小数)
const rawAmount = ethers.parseUnits(amount, decimals);

const tx = await contract.transfer(to, rawAmount);
await tx.wait();
console.log('转账成功:', tx.hash);
}
常见错误

不要用 JavaScript 浮点数直接计算代币金额!0.1 + 0.2 !== 0.3 在以太坊金额计算中可能导致资金损失。始终使用 BigIntethers.parseUnits / formatUnits 进行转换。

ERC-721:非同质化代币(NFT)

ERC-721 定义了非同质化代币(Non-Fungible Token) 的标准。每个 NFT 都有唯一的 tokenId,不可互换,适用于数字艺术品、游戏道具、域名等场景。

接口定义

IERC721.sol
interface IERC721 {
// 查询 tokenId 的持有者
function ownerOf(uint256 tokenId) external view returns (address);
// 查询地址持有的 NFT 数量
function balanceOf(address owner) external view returns (uint256);
// 获取 tokenId 的元数据 URI
function tokenURI(uint256 tokenId) external view returns (string);

// 安全转账(接收方如果是合约,会检查是否实现了 onERC721Received)
function safeTransferFrom(address from, address to, uint256 tokenId) external;
// 授权单个 NFT
function approve(address to, uint256 tokenId) external;
// 授权/取消某地址操作自己所有 NFT 的权限
function setApprovalForAll(address operator, bool approved) external;

event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
}

元数据标准

每个 NFT 通过 tokenURI 指向一个 JSON 元数据文件,格式如下:

NFT Metadata JSON
{
"name": "CryptoPunk #3100",
"description": "One of 9 Alien CryptoPunks.",
"image": "ipfs://QmXkY5...",
"attributes": [
{ "trait_type": "Type", "value": "Alien" },
{ "trait_type": "Accessory", "value": "Headband" }
]
}

前端展示 NFT

nft-display.ts
import { ethers } from 'ethers';

const ERC721_ABI = [
'function tokenURI(uint256 tokenId) view returns (string)',
'function ownerOf(uint256 tokenId) view returns (address)',
'function balanceOf(address owner) view returns (uint256)',
];

interface NFTMetadata {
name: string;
description: string;
image: string;
attributes?: Array<{ trait_type: string; value: string }>;
}

/** 将 ipfs:// URI 转换为可访问的 HTTPS 网关地址 */
function resolveIPFS(uri: string): string {
if (uri.startsWith('ipfs://')) {
// 常用公共网关,生产环境建议使用 Pinata/Infura 等专用网关
return uri.replace('ipfs://', 'https://ipfs.io/ipfs/');
}
// data URI 或 https 直接返回
return uri;
}

async function fetchNFTMetadata(
contractAddress: string,
tokenId: number,
provider: ethers.Provider
): Promise<NFTMetadata> {
const contract = new ethers.Contract(contractAddress, ERC721_ABI, provider);

// 1. 获取 tokenURI
const tokenURI = await contract.tokenURI(tokenId);

// 2. 处理 Base64 编码的 data URI
if (tokenURI.startsWith('data:application/json;base64,')) {
const json = atob(tokenURI.split(',')[1]);
return JSON.parse(json);
}

// 3. 处理 IPFS 或 HTTP URI
const url = resolveIPFS(tokenURI);
const response = await fetch(url);
const metadata: NFTMetadata = await response.json();

// 4. 图片地址也可能是 IPFS,需要二次解析
metadata.image = resolveIPFS(metadata.image);

return metadata;
}

ERC-1155:多代币标准

ERC-1155 是一个多代币标准,可以在单个合约中同时管理同质化代币和非同质化代币。最典型的应用场景是游戏:金币(FT)和装备(NFT)可以在同一个合约中定义。

核心特性

特性说明
混合代币同一合约可包含 FT 和 NFT
批量操作一次交易转移多种代币,节省 Gas
共享元数据通过 URI 模板 https://api.example.com/{id}.json 统一管理
安全转账接收合约必须实现 onERC1155Received

接口定义(核心方法)

IERC1155.sol(部分)
interface IERC1155 {
// 查询某地址持有某 ID 代币的数量
function balanceOf(address account, uint256 id) external view returns (uint256);
// 批量查询余额
function balanceOfBatch(
address[] calldata accounts,
uint256[] calldata ids
) external view returns (uint256[] memory);

// 安全转账单个代币
function safeTransferFrom(
address from, address to, uint256 id, uint256 amount, bytes calldata data
) external;
// 批量安全转账(一次转多种代币)
function safeBatchTransferFrom(
address from, address to,
uint256[] calldata ids,
uint256[] calldata amounts,
bytes calldata data
) external;

// 授权操作所有代币
function setApprovalForAll(address operator, bool approved) external;

event TransferSingle(
address indexed operator, address indexed from,
address indexed to, uint256 id, uint256 value
);
event TransferBatch(
address indexed operator, address indexed from,
address indexed to, uint256[] ids, uint256[] values
);
}

前端批量查询示例

erc1155-batch.ts
import { ethers } from 'ethers';

const ERC1155_ABI = [
'function balanceOf(address account, uint256 id) view returns (uint256)',
'function balanceOfBatch(address[] accounts, uint256[] ids) view returns (uint256[])',
'function uri(uint256 id) view returns (string)',
];

async function getGameInventory(
contractAddress: string,
playerAddress: string,
provider: ethers.Provider
) {
const contract = new ethers.Contract(contractAddress, ERC1155_ABI, provider);

// 游戏道具 ID:0=金币(FT), 1=银币(FT), 2=传说之剑(NFT), 3=龙鳞盾(NFT)
const itemIds = [0, 1, 2, 3];

// 批量查询:一次 RPC 调用获取所有道具数量,比逐个查询节省网络开销
const balances = await contract.balanceOfBatch(
itemIds.map(() => playerAddress), // 每个 ID 对应同一个玩家地址
itemIds
);

const inventory = itemIds.map((id, i) => ({
itemId: id,
balance: balances[i].toString(),
}));

console.log('玩家背包:', inventory);
// [{ itemId: 0, balance: "5000" }, { itemId: 2, balance: "1" }, ...]
}

ERC-20 vs ERC-721 vs ERC-1155 对比

特性ERC-20ERC-721ERC-1155
代币类型同质化(FT)非同质化(NFT)FT + NFT 混合
唯一性每个代币相同每个 tokenId 唯一按 ID 区分,可相同可唯一
余额balanceOf(address)balanceOf(address) 返回数量balanceOf(address, id)
转账transfer / transferFromsafeTransferFromsafeTransferFrom / safeBatchTransferFrom
批量操作不支持不支持原生支持
元数据无(仅 name/symbol)tokenURI(tokenId)uri(id) 模板
Gas 效率单次操作高效每次一个 NFT批量操作显著省 Gas
典型场景货币、DeFi 代币艺术品、PFP、域名游戏道具、门票系统

常见扩展标准

ERC-2612:Permit(无 Gas Approve)

传统 ERC-20 的 approve + transferFrom 需要两笔交易。ERC-2612 引入了 permit 方法,用户通过链下签名完成授权,DApp 合约在一笔交易中同时完成授权和转账,省去一笔 Gas 费用。

permit-example.ts
import { ethers } from 'ethers';

async function signPermit(
signer: ethers.Signer,
tokenAddress: string,
spender: string,
value: bigint,
deadline: number
) {
const domain = {
name: 'USD Coin', // 代币名称
version: '2', // 合约版本
chainId: 1, // 主网
verifyingContract: tokenAddress,
};

const types = {
Permit: [
{ name: 'owner', type: 'address' },
{ name: 'spender', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
};

const owner = await signer.getAddress();
// nonce 需要从合约查询
const nonce = 0; // 实际需调用 contract.nonces(owner)

const message = { owner, spender, value, nonce, deadline };

// EIP-712 类型化签名,用户在钱包中可以看到签名的具体内容
const signature = await signer.signTypedData(domain, types, message);
const { v, r, s } = ethers.Signature.from(signature);

return { v, r, s, deadline };
}
Permit 的优势
  1. 用户无需支付 approve 的 Gas 费用
  2. 授权和操作可以在一笔交易中完成(由协议合约发起)
  3. 改善用户体验:从"两步操作"变为"签名 + 一步操作"

ERC-4626:代币化金库标准

ERC-4626 定义了收益型金库(Yield Vault) 的标准接口,用于存入资产获取收益份额(如存入 USDC 获取 aUSDC)。它统一了 DeFi 协议的存取接口:

方法说明
deposit(assets, receiver)存入资产,铸造份额
withdraw(assets, receiver, owner)按资产数量取出
redeem(shares, receiver, owner)按份额数量赎回
convertToShares(assets)预览资产对应的份额
convertToAssets(shares)预览份额对应的资产

前端实战:IPFS 与 Metadata 处理

NFT 的图片和元数据通常存储在 IPFS 上,前端需要将 ipfs:// 协议转为可访问的 HTTPS 地址。

ipfs-resolver.ts
/** IPFS 网关配置 */
const IPFS_GATEWAYS = [
'https://ipfs.io/ipfs/',
'https://cloudflare-ipfs.com/ipfs/',
'https://gateway.pinata.cloud/ipfs/',
];

/**
* 解析各种格式的 IPFS URI
* 支持:ipfs://Qm...、ipfs://ipfs/Qm...、https://ipfs.io/ipfs/Qm...
*/
function resolveIPFSUri(uri: string, gatewayIndex = 0): string {
if (!uri) return '';

// 已经是 HTTP(S) URL
if (uri.startsWith('http://') || uri.startsWith('https://')) {
return uri;
}

// data URI(Base64 编码的 SVG 等)
if (uri.startsWith('data:')) {
return uri;
}

// ipfs:// 协议
if (uri.startsWith('ipfs://')) {
const cid = uri.replace('ipfs://', '').replace('ipfs/', '');
return `${IPFS_GATEWAYS[gatewayIndex]}${cid}`;
}

// 纯 CID(以 Qm 或 bafy 开头)
if (uri.startsWith('Qm') || uri.startsWith('bafy')) {
return `${IPFS_GATEWAYS[gatewayIndex]}${uri}`;
}

return uri;
}

/**
* 带降级的 IPFS 请求:如果一个网关失败,自动切换到下一个
*/
async function fetchFromIPFS(uri: string): Promise<Response> {
for (let i = 0; i < IPFS_GATEWAYS.length; i++) {
const url = resolveIPFSUri(uri, i);
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(10_000), // 10 秒超时
});
if (response.ok) return response;
} catch {
console.warn(`网关 ${IPFS_GATEWAYS[i]} 请求失败,尝试下一个...`);
}
}
throw new Error(`所有 IPFS 网关均失败: ${uri}`);
}

更多智能合约交互的细节可参考智能合约交互,ethers.js 和 viem 的用法可参考 ethers.js 与 viem


常见面试问题

Q1: ERC-20 的 approve + transferFrom 机制有什么安全风险?

答案

主要风险是授权额度过大approve 竞态条件

  1. 无限授权风险:很多 DApp 为了用户体验,会请求 approve(spender, type(uint256).max) 即无限额度授权。一旦合约被攻击或存在漏洞,攻击者可以转走用户所有代币
  2. 竞态条件:如果用户先 approve(spender, 100) 再改为 approve(spender, 50),在第二笔交易确认前,spender 可以先花掉 100,然后再花 50,总共花 150。安全做法是先 approve(spender, 0) 再设置新额度
  3. 缓解方案:使用 ERC-2612 Permit(链下签名授权)、或使用 increaseAllowance / decreaseAllowance 替代直接 approve

Q2: ERC-721 和 ERC-1155 的主要区别是什么?

答案

对比维度ERC-721ERC-1155
每个合约只能管理一种 NFT 集合可以管理多种代币(FT + NFT 混合)
批量转账不支持,每次只能转一个 NFT原生支持 safeBatchTransferFrom
Gas 消耗转 10 个 NFT 需要 10 笔交易批量转 10 个只需 1 笔交易
元数据每个 token 独立的 tokenURI通过 URI 模板 {id} 替换统一管理
适用场景每个代币有独立身份的场景(艺术品、PFP)游戏道具、门票等需要混合管理的场景

Q3: 前端处理 ERC-20 代币余额时,为什么不能直接用 Number 类型?

答案

两个原因:

  1. 精度丢失:JavaScript 的 Number 是 IEEE 754 双精度浮点数,最大安全整数是 2^53 - 1(约 9000 万亿)。而 ERC-20 余额的最小单位是 uint256,18 位小数的代币中 1 个代币就是 10^18,远超 Number.MAX_SAFE_INTEGER
  2. 浮点精度问题0.1 + 0.2 !== 0.3,在金额计算中不可接受

正确做法是始终使用 BigInt 处理链上原始值,只在展示时使用 ethers.formatUnits 转为字符串。

Q4: NFT 的元数据为什么要存储在 IPFS 上而不是链上?

答案

  • 链上存储极其昂贵:以太坊存储 1KB 数据约需 640,000 Gas(按 30 gwei 约 0.02 ETH),存一张图片可能要数百 ETH
  • IPFS 是内容寻址:同一内容永远对应同一个 CID(哈希),保证元数据不可篡改
  • 去中心化:IPFS 数据可由多个节点存储,不依赖单一服务器

但 IPFS 也有风险:如果没有节点 Pin 该内容,数据可能会丢失。因此重要的 NFT 项目通常使用 Pinata、Arweave 等服务确保持久化存储。

Q5: 什么是 safeTransferFrom?为什么比 transferFrom 更安全?

答案

safeTransferFrom 在转账完成后会检查接收地址:如果接收方是合约地址,会调用该合约的 onERC721Received(ERC-721)或 onERC1155Received(ERC-1155)方法。如果接收合约没有实现这个方法,交易会回滚,防止 NFT 被永久锁死在不支持 NFT 的合约中。

Q6: ERC-2612 Permit 的签名是如何工作的?

答案

ERC-2612 基于 EIP-712 类型化数据签名

  1. 用户在钱包中对一段包含 (owner, spender, value, nonce, deadline) 的结构化数据签名(链下操作,不消耗 Gas)
  2. 签名结果 (v, r, s) 传给 DApp
  3. DApp 合约在一笔交易中调用 permit(owner, spender, value, deadline, v, r, s) 完成授权,然后立即执行后续操作
  4. 合约通过 ecrecover 验证签名者确实是 owner,并通过 nonce 防止重放攻击

Q7: ERC-1155 的 URI 模板中 {id} 是如何替换的?

答案

ERC-1155 规范要求 {id} 替换为64 位十六进制字符串(不含 0x 前缀,左侧补零)。例如 ID 为 1 的代币:

模板: https://api.example.com/tokens/{id}.json
实际: https://api.example.com/tokens/0000000000000000000000000000000000000000000000000000000000000001.json

前端替换逻辑:

function resolveERC1155URI(template: string, tokenId: bigint): string {
const hexId = tokenId.toString(16).padStart(64, '0');
return template.replace('{id}', hexId);
}

Q8: 前端如何判断一个合约实现了哪种 ERC 标准?

答案

通过 ERC-165 supportsInterface 方法查询:

const ERC165_ABI = ['function supportsInterface(bytes4 interfaceId) view returns (bool)'];
const contract = new ethers.Contract(address, ERC165_ABI, provider);

// ERC-721 的 interfaceId
const isERC721 = await contract.supportsInterface('0x80ac58cd');
// ERC-1155 的 interfaceId
const isERC1155 = await contract.supportsInterface('0xd9b67a26');

注意 ERC-20 没有强制要求实现 ERC-165,所以无法通过这种方式检测。通常的做法是尝试调用 decimals() 方法来判断是否为 ERC-20。

Q9: ERC-4626 解决了什么问题?

答案

在 ERC-4626 之前,每个 DeFi 协议(Aave、Compound、Yearn 等)的金库接口各不相同,前端和聚合器需要为每个协议写专门的适配代码。ERC-4626 统一了金库的存取接口(depositwithdrawredeem 等),让不同协议的收益金库可以互相组合,大大简化了 DeFi 应用的开发和集成。

Q10: 前端展示 NFT 时,遇到 IPFS 网关响应慢怎么办?

答案

常用优化策略:

  1. 多网关降级:按优先级尝试多个 IPFS 网关(Cloudflare、Pinata、公共网关),任一成功即返回
  2. CDN 缓存:将已解析的图片 URL 缓存到本地或 CDN,避免每次都请求 IPFS
  3. 骨架屏占位:NFT 图片加载时展示骨架屏或低分辨率缩略图
  4. 使用专用网关:Pinata、Infura 等付费网关比公共网关稳定得多
  5. 预取缩略图:后端定期爬取 NFT 图片生成缩略图存到 CDN,前端优先展示缩略图

Q11: 为什么 USDT 的 approve 方法和标准 ERC-20 不同?

答案

USDT(Tether)的合约实现了一个非标准的安全措施:如果当前授权额度不为 0,必须先调用 approve(spender, 0) 将额度归零,才能设置新额度。直接从非零额度改为另一个非零额度会交易失败。这是为了防止前面提到的 approve 竞态条件问题,但与 ERC-20 标准不完全兼容,前端在处理 USDT 授权时需要特别适配。

Q12: ERC-20 的 Transfer 事件中,from 为零地址代表什么?

答案

from 为零地址(0x0000...0000)表示这是一次铸造(Mint) 操作,即新代币被创建。同理,to 为零地址表示销毁(Burn)。前端监听 Transfer 事件时可以通过检查 fromto 来区分普通转账、铸造和销毁:

contract.on('Transfer', (from, to, value) => {
if (from === ethers.ZeroAddress) {
console.log(`铸造了 ${ethers.formatUnits(value, 18)} 代币`);
} else if (to === ethers.ZeroAddress) {
console.log(`销毁了 ${ethers.formatUnits(value, 18)} 代币`);
} else {
console.log(`${from} 转账 ${ethers.formatUnits(value, 18)}${to}`);
}
});

相关链接