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。 - 授权流程:先
approve后transferFrom;ERC-721 还有setApprovalForAll一次授权所有。 - Permit(EIP-2612):USDC 等 Token 支持离线签名授权,省一次交易和 Gas。
答案
ERC(Ethereum Request for Comments) 是以太坊上智能合约的标准接口规范。它定义了代币合约必须实现的方法和事件,使得钱包、DApp、交易所等能以统一方式与各种代币交互。
EIP vs ERC
| 概念 | 全称 | 说明 |
|---|---|---|
| EIP | Ethereum Improvement Proposal | 以太坊改进提案,涵盖核心协议、网络、接口等所有层面 |
| ERC | Ethereum Request for Comments | EIP 中专门针对应用层标准的子类别,主要定义合约接口 |
ERC 是 EIP 的子集。例如 ERC-20 的正式编号是 EIP-20,但因为它定义的是应用层合约接口标准,所以习惯上称为 ERC-20。
ERC-20:同质化代币标准
ERC-20 是最基础也是使用最广泛的代币标准,定义了同质化代币(Fungible Token) 的接口。同质化意味着每个代币完全相同、可互换,就像人民币的每一元钱价值相等。
常见 ERC-20 代币:USDT、USDC、DAI、WETH、UNI、LINK 等。
接口定义
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 完成操作:
前端交互要点:精度处理
ERC-20 代币通常有 18 位小数(decimals = 18),但并非所有代币都是。USDT 和 USDC 是 6 位小数。链上存储的是最小单位的整数值,前端必须做精度转换。
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 在以太坊金额计算中可能导致资金损失。始终使用 BigInt 或 ethers.parseUnits / formatUnits 进行转换。
ERC-721:非同质化代币(NFT)
ERC-721 定义了非同质化代币(Non-Fungible Token) 的标准。每个 NFT 都有唯一的 tokenId,不可互换,适用于数字艺术品、游戏道具、域名等场景。
接口定义
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 元数据文件,格式如下:
{
"name": "CryptoPunk #3100",
"description": "One of 9 Alien CryptoPunks.",
"image": "ipfs://QmXkY5...",
"attributes": [
{ "trait_type": "Type", "value": "Alien" },
{ "trait_type": "Accessory", "value": "Headband" }
]
}
前端展示 NFT
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 |
接口定义(核心方法)
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
);
}
前端批量查询示例
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-20 | ERC-721 | ERC-1155 |
|---|---|---|---|
| 代币类型 | 同质化(FT) | 非同质化(NFT) | FT + NFT 混合 |
| 唯一性 | 每个代币相同 | 每个 tokenId 唯一 | 按 ID 区分,可相同可唯一 |
| 余额 | balanceOf(address) | balanceOf(address) 返回数量 | balanceOf(address, id) |
| 转账 | transfer / transferFrom | safeTransferFrom | safeTransferFrom / 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 费用。
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 };
}
- 用户无需支付 approve 的 Gas 费用
- 授权和操作可以在一笔交易中完成(由协议合约发起)
- 改善用户体验:从"两步操作"变为"签名 + 一步操作"
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 网关配置 */
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 竞态条件:
- 无限授权风险:很多 DApp 为了用户体验,会请求
approve(spender, type(uint256).max)即无限额度授权。一旦合约被攻击或存在漏洞,攻击者可以转走用户所有代币 - 竞态条件:如果用户先
approve(spender, 100)再改为approve(spender, 50),在第二笔交易确认前,spender 可以先花掉 100,然后再花 50,总共花 150。安全做法是先approve(spender, 0)再设置新额度 - 缓解方案:使用 ERC-2612 Permit(链下签名授权)、或使用
increaseAllowance/decreaseAllowance替代直接approve
Q2: ERC-721 和 ERC-1155 的主要区别是什么?
答案:
| 对比维度 | ERC-721 | ERC-1155 |
|---|---|---|
| 每个合约 | 只能管理一种 NFT 集合 | 可以管理多种代币(FT + NFT 混合) |
| 批量转账 | 不支持,每次只能转一个 NFT | 原生支持 safeBatchTransferFrom |
| Gas 消耗 | 转 10 个 NFT 需要 10 笔交易 | 批量转 10 个只需 1 笔交易 |
| 元数据 | 每个 token 独立的 tokenURI | 通过 URI 模板 {id} 替换统一管理 |
| 适用场景 | 每个代币有独立身份的场景(艺术品、PFP) | 游戏道具、门票等需要混合管理的场景 |
Q3: 前端处理 ERC-20 代币余额时,为什么不能直接用 Number 类型?
答案:
两个原因:
- 精度丢失:JavaScript 的
Number是 IEEE 754 双精度浮点数,最大安全整数是2^53 - 1(约 9000 万亿)。而 ERC-20 余额的最小单位是uint256,18 位小数的代币中 1 个代币就是10^18,远超Number.MAX_SAFE_INTEGER - 浮点精度问题:
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 类型化数据签名:
- 用户在钱包中对一段包含
(owner, spender, value, nonce, deadline)的结构化数据签名(链下操作,不消耗 Gas) - 签名结果
(v, r, s)传给 DApp - DApp 合约在一笔交易中调用
permit(owner, spender, value, deadline, v, r, s)完成授权,然后立即执行后续操作 - 合约通过
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 统一了金库的存取接口(deposit、withdraw、redeem 等),让不同协议的收益金库可以互相组合,大大简化了 DeFi 应用的开发和集成。
Q10: 前端展示 NFT 时,遇到 IPFS 网关响应慢怎么办?
答案:
常用优化策略:
- 多网关降级:按优先级尝试多个 IPFS 网关(Cloudflare、Pinata、公共网关),任一成功即返回
- CDN 缓存:将已解析的图片 URL 缓存到本地或 CDN,避免每次都请求 IPFS
- 骨架屏占位:NFT 图片加载时展示骨架屏或低分辨率缩略图
- 使用专用网关:Pinata、Infura 等付费网关比公共网关稳定得多
- 预取缩略图:后端定期爬取 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 事件时可以通过检查 from 和 to 来区分普通转账、铸造和销毁:
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}`);
}
});
相关链接
- EIP-20: Token Standard
- EIP-721: Non-Fungible Token Standard
- EIP-1155: Multi Token Standard
- EIP-2612: Permit Extension for ERC-20
- EIP-4626: Tokenized Vault Standard
- EIP-165: Standard Interface Detection
- OpenZeppelin Contracts - ERC-20
- OpenZeppelin Contracts - ERC-721
- OpenZeppelin Contracts - ERC-1155
- ethers.js 文档
- IPFS 文档