智能合约交互
问题
前端 DApp 如何与智能合约交互?ABI 是什么?ERC-20 Token 的授权和转账流程是怎样的?
前端 DApp 怎么调智能合约? 核心是「ABI 编码 + RPC 调用」:
- view/pure 函数走
eth_call(不上链、不耗 Gas),代码里contract.balanceOf(addr)。 - 写函数走
eth_sendTransaction,需要钱包签名、付 Gas,返回交易 hash,要等确认才生效。 - 事件可用
getLogs查历史,或WebSocket实时订阅;alchemy/Infura 限制查询区块跨度,常需分段。
ABI 是什么? ABI 是「合约接口描述」,本质是 JSON:
- 定义了函数名、参数、返回值、事件结构。
- 调用时 SDK 会算出函数选择器(
keccak256("transfer(address,uint256)")前 4 字节)拼到 calldata 头部,后面接 ABI 编码后的参数。 - 事件表明以
keccak256(eventSignature)为 topic[0],indexed 参数上事件 topics,其他装在 data 里。
ERC-20 授权与转账流程是怎样的? 标准的 Approve → transferFrom 两步:
- 首次与某合约交互,先调
token.approve(spender, amount)——让合约能调用transferFrom转走你账户里的 Token。 - 业务合约在其函数里调
token.transferFrom(user, contract, amount)完成转账。 - 前端要先
allowance(user, spender)查额度,不够才弹 Approve;质量高的 DApp 不要用MaxUint256无限额度,防 spender 合约被控后资产被抽。 - 现代方案可用 Permit(EIP-2612) 或 Permit2 把两步压缩为一次签名 + 一次交易。
答案
智能合约(Smart Contract) 是部署在区块链上的程序,一旦部署就不可修改,由交易触发执行。前端 DApp 通过 JSON-RPC 与节点通信,再由节点将交易广播到链上执行合约逻辑。
理解智能合约交互是前端 Web3 开发的核心能力,涉及 ABI 编码、Gas 机制、事件监听等多个知识点。
智能合约基础概念
智能合约的三个核心特性:
| 特性 | 说明 |
|---|---|
| 不可修改 | 合约代码部署后无法更改(除非使用代理模式) |
| 确定性执行 | 相同输入必定产生相同输出 |
| 交易触发 | 只有链上交易才能改变合约状态,不会自动执行 |
普通地址(EOA)由私钥控制,合约地址由代码控制。合约没有私钥,无法主动发起交易,只能被动响应调用。
ABI 详解
ABI(Application Binary Interface) 是前端与合约之间的"接口协议",它描述了合约有哪些函数、参数类型、返回值类型。
ABI 的结构
[
{
"type": "function",
"name": "balanceOf",
"inputs": [{ "name": "account", "type": "address" }],
"outputs": [{ "name": "", "type": "uint256" }],
"stateMutability": "view"
},
{
"type": "function",
"name": "transfer",
"inputs": [
{ "name": "to", "type": "address" },
{ "name": "amount", "type": "uint256" }
],
"outputs": [{ "name": "", "type": "bool" }],
"stateMutability": "nonpayable"
},
{
"type": "event",
"name": "Transfer",
"inputs": [
{ "name": "from", "type": "address", "indexed": true },
{ "name": "to", "type": "address", "indexed": true },
{ "name": "value", "type": "uint256", "indexed": false }
]
}
]
函数选择器(Function Selector)
EVM 通过函数签名的 Keccak-256 哈希前 4 字节来识别调用的是哪个函数:
import { ethers } from 'ethers';
// transfer(address,uint256) 的选择器
const selector = ethers.id('transfer(address,uint256)').slice(0, 10);
console.log(selector); // 0xa9059cbb
参数编码
ABI 使用 32 字节对齐的方式编码参数:
import { ethers } from 'ethers';
const abiCoder = ethers.AbiCoder.defaultAbiCoder();
// 编码 transfer(address, uint256) 的参数
const encoded = abiCoder.encode(
['address', 'uint256'],
['0xAbC...123', ethers.parseEther('1.0')]
);
// 完整 calldata = 函数选择器 + 编码后的参数
// 0xa9059cbb + 000000000000000000000000abc...123 + 0000...0de0b6b3a7640000
如果使用了错误的 ABI(比如旧版本的 ABI),调用可能失败或产生意外行为。升级合约后务必同步更新前端的 ABI 文件。
合约调用分类
合约交互分为两种:读操作和写操作。
| 对比项 | 读操作(Call) | 写操作(Transaction) |
|---|---|---|
| 是否消耗 Gas | 不消耗 | 消耗 Gas |
| 是否改变链上状态 | 不改变 | 改变 |
| 是否需要签名 | 不需要 | 需要钱包签名 |
| 返回值 | 直接返回结果 | 返回交易哈希 |
| 执行速度 | 即时返回 | 需等待区块确认 |
| Solidity 标识 | view / pure | 无修饰符 / payable |
import { ethers } from 'ethers';
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const contract = new ethers.Contract(contractAddress, abi, signer);
// ✅ 读操作:直接返回结果,不消耗 Gas
const balance = await contract.balanceOf(userAddress);
console.log('余额:', ethers.formatEther(balance));
// ✅ 写操作:返回交易对象,需要等待确认
const tx = await contract.transfer(toAddress, ethers.parseEther('1.0'));
console.log('交易哈希:', tx.hash);
// 等待交易被打包进区块
const receipt = await tx.wait();
console.log('交易确认,区块号:', receipt.blockNumber);
读操作不需要签名,因此可以只用 Provider 而不需要 Signer。但写操作必须使用 Signer(关联钱包)。
ERC-20 Token 交互完整流程
ERC-20 是最常见的 Token 标准,掌握它的交互流程是前端 Web3 开发的基本功。
查询余额 — balanceOf
import { ethers } from 'ethers';
const ERC20_ABI = [
'function balanceOf(address) view returns (uint256)',
'function decimals() view returns (uint8)',
'function symbol() view returns (string)',
];
async function getTokenBalance(
provider: ethers.Provider,
tokenAddress: string,
userAddress: string
) {
const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
// 并行查询余额、精度和符号
const [balance, decimals, symbol] = await Promise.all([
contract.balanceOf(userAddress),
contract.decimals(),
contract.symbol(),
]);
// 将 wei 单位转换为人类可读的格式
const formatted = ethers.formatUnits(balance, decimals);
console.log(`${formatted} ${symbol}`); // 例如: "100.5 USDT"
return { balance, decimals, symbol, formatted };
}
授权流程 — approve + allowance
当用户想让 DEX 或其他合约代替自己转移 Token 时,需要先授权(approve):
const ERC20_ABI = [
'function allowance(address owner, address spender) view returns (uint256)',
'function approve(address spender, uint256 amount) returns (bool)',
'function transferFrom(address from, address to, uint256 amount) returns (bool)',
];
async function ensureAllowance(
tokenContract: ethers.Contract,
owner: string,
spender: string,
requiredAmount: bigint
) {
// 1. 查询当前授权额度
const currentAllowance: bigint = await tokenContract.allowance(owner, spender);
if (currentAllowance >= requiredAmount) {
console.log('授权额度充足,无需再次授权');
return;
}
// 2. 授权额度不足,发起 approve 交易
const tx = await tokenContract.approve(spender, requiredAmount);
await tx.wait();
console.log('授权成功');
}
无限授权 vs 精确授权
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 无限授权 | approve(spender, MaxUint256) | 只需授权一次,后续操作无需再次签名 | 如果合约有漏洞,所有 Token 可能被盗 |
| 精确授权 | approve(spender, exactAmount) | 风险可控,每次只授权需要的额度 | 每次操作前都需要一笔额外授权交易 |
无限授权意味着被授权的合约地址可以随时转走你的所有 Token。历史上多次安全事件(如 BadgerDAO 攻击)都与无限授权有关。建议:
- 对于知名、经过审计的协议(如 Uniswap),无限授权通常可接受
- 对于不确定安全性的协议,使用精确授权
- 定期通过 Revoke.cash 检查和撤销不必要的授权
// ❌ 无限授权:最大风险
await tokenContract.approve(spender, ethers.MaxUint256);
// ✅ 精确授权:只授权实际需要的数额
await tokenContract.approve(spender, ethers.parseUnits('100', 18));
转账 — transfer 与 transferFrom
// 直接转账:用户自己将 Token 转给别人
await tokenContract.transfer(toAddress, amount);
// 代理转账:合约代替用户转移 Token(需要提前 approve)
await dexContract.transferFrom(userAddress, dexAddress, amount);
事件(Event)与日志
Solidity 合约通过 Event 发出日志,前端可以实时监听或查询历史事件。事件存储在交易收据的 logs 中,比存储在合约 storage 中便宜得多。
Solidity Event 定义
// indexed 参数可被用于过滤查询
event Transfer(
address indexed from,
address indexed to,
uint256 value
);
indexed 参数与 Topics
每个日志条目包含 topics 和 data 两部分:
| 字段 | 内容 | 说明 |
|---|---|---|
topics[0] | 事件签名的哈希 | keccak256("Transfer(address,address,uint256)") |
topics[1] | 第一个 indexed 参数 | from 地址 |
topics[2] | 第二个 indexed 参数 | to 地址 |
data | 非 indexed 参数(ABI 编码) | value 金额 |
每个日志最多有 4 个 topics,其中 topics[0] 被事件签名占用,所以 indexed 参数最多 3 个。indexed 参数可被高效过滤,但不适合存储大量数据(如字符串)。
实时监听事件
import { ethers } from 'ethers';
const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
// 监听所有 Transfer 事件
contract.on('Transfer', (from, to, value, event) => {
console.log(`${from} → ${to}: ${ethers.formatEther(value)}`);
console.log('交易哈希:', event.log.transactionHash);
});
// 只监听发送到指定地址的转账
const filter = contract.filters.Transfer(null, myAddress);
contract.on(filter, (from, to, value) => {
console.log(`收到 ${ethers.formatEther(value)} 代币,来自 ${from}`);
});
// 取消监听
contract.removeAllListeners('Transfer');
查询历史事件
async function getTransferHistory(
contract: ethers.Contract,
address: string,
fromBlock: number,
toBlock: number | string = 'latest'
) {
// 查询从指定地址发出的转账
const sentFilter = contract.filters.Transfer(address, null);
const sentEvents = await contract.queryFilter(sentFilter, fromBlock, toBlock);
// 查询转入指定地址的转账
const receivedFilter = contract.filters.Transfer(null, address);
const receivedEvents = await contract.queryFilter(receivedFilter, fromBlock, toBlock);
return { sent: sentEvents, received: receivedEvents };
}
大多数 RPC 节点限制单次查询的区块范围(通常 2000~10000 个区块)。查询大范围历史事件时需要分批查询,或使用 The Graph 等索引服务。
合约错误处理
Revert Reason 解析
当合约执行失败时,会抛出 revert 错误。前端需要正确解析错误信息:
async function safeContractCall(contract: ethers.Contract) {
try {
const tx = await contract.transfer(toAddress, amount);
await tx.wait();
} catch (error: any) {
if (error.code === 'CALL_EXCEPTION') {
// 合约 revert 错误
console.error('合约执行失败:', error.reason);
// error.reason 可能是 "ERC20: insufficient balance"
} else if (error.code === 'ACTION_REJECTED') {
// 用户在钱包中拒绝了交易
console.log('用户取消了交易');
} else if (error.code === 'INSUFFICIENT_FUNDS') {
// Gas 不足
console.error('ETH 余额不足以支付 Gas');
}
}
}
自定义错误(Custom Errors)
Solidity 0.8.4+ 支持自定义错误,比字符串 revert 更省 Gas:
// 定义自定义错误
error InsufficientBalance(uint256 available, uint256 required);
function transfer(address to, uint256 amount) external {
if (balanceOf(msg.sender) < amount) {
revert InsufficientBalance(balanceOf(msg.sender), amount);
}
}
try {
await contract.transfer(to, amount);
} catch (error: any) {
// ethers.js v6 自动解析自定义错误
if (error.revert) {
const { name, args } = error.revert;
if (name === 'InsufficientBalance') {
console.error(
`余额不足:当前 ${args.available},需要 ${args.required}`
);
}
}
}
Gas 估算
在发送交易前可以先估算 Gas,提前发现可能的失败:
async function estimateAndSend(contract: ethers.Contract) {
try {
const gasEstimate = await contract.transfer.estimateGas(toAddress, amount);
console.log('预估 Gas:', gasEstimate.toString());
// 发送交易时设置 Gas 上限(通常加 20% 余量)
const tx = await contract.transfer(toAddress, amount, {
gasLimit: (gasEstimate * 120n) / 100n,
});
await tx.wait();
} catch (error) {
// estimateGas 失败通常意味着交易一定会 revert
console.error('交易将会失败,请检查参数');
}
}
Multicall 批量调用
前端经常需要一次性读取多个合约数据(如用户持有的多个 Token 余额)。逐个请求效率很低,Multicall 合约可以将多个 call 打包成一次 RPC 请求。
import { ethers } from 'ethers';
// Multicall3 合约地址(大多数 EVM 链上相同)
const MULTICALL3_ADDRESS = '0xcA11bde05977b3631167028862bE2a173976CA11';
const MULTICALL3_ABI = [
'function aggregate3(tuple(address target, bool allowFailure, bytes callData)[] calls) view returns (tuple(bool success, bytes returnData)[])',
];
async function batchBalanceOf(
provider: ethers.Provider,
tokenAddresses: string[],
userAddress: string
) {
const multicall = new ethers.Contract(MULTICALL3_ADDRESS, MULTICALL3_ABI, provider);
const erc20Interface = new ethers.Interface(['function balanceOf(address) view returns (uint256)']);
// 构造批量调用参数
const calls = tokenAddresses.map((token) => ({
target: token,
allowFailure: true,
callData: erc20Interface.encodeFunctionData('balanceOf', [userAddress]),
}));
// 一次 RPC 请求获取所有结果
const results = await multicall.aggregate3(calls);
return results.map((result: any, i: number) => {
if (!result.success) return { token: tokenAddresses[i], balance: 0n };
const balance = erc20Interface.decodeFunctionResult('balanceOf', result.returnData)[0];
return { token: tokenAddresses[i], balance };
});
}
假设需要查询 20 个 Token 余额:
- 无 Multicall:20 次 RPC 请求,网络往返延迟累积
- 有 Multicall:1 次 RPC 请求,节点内部批量执行
对于需要展示 Token 列表、资产组合的 DApp,Multicall 是必备优化。
合约代理模式简介
由于合约部署后不可修改,升级合约需要使用代理模式(Proxy Pattern):用户始终调用的是代理合约(地址不变),代理通过 delegatecall 将调用转发到实现合约(可更换)。
使用代理模式的合约,在 Etherscan 上需要点击 "Read as Proxy" 才能看到真实的 ABI。前端获取 ABI 时要注意获取的是实现合约的 ABI,而不是代理合约本身的 ABI。
常见代理标准:
- EIP-1967:Transparent Proxy(OpenZeppelin)
- EIP-1822:UUPS Proxy
- EIP-2535:Diamond Proxy(多面代理)
常见面试问题
Q1: 什么是 ABI?前端为什么需要 ABI?
答案:
ABI(Application Binary Interface)是智能合约的接口描述,定义了合约有哪些函数、参数类型和返回值类型。前端需要 ABI 来:
- 编码调用数据:将函数名和参数编码为 EVM 能理解的 calldata
- 解码返回值:将合约返回的二进制数据解码为可读的 JavaScript 值
- 生成类型安全的接口:ethers.js / viem 根据 ABI 自动生成带类型提示的合约方法
没有 ABI,前端就需要手动计算函数选择器、手动编码参数,非常容易出错。
Q2: 合约的 call 和 transaction 有什么区别?
答案:
| 特性 | Call(读) | Transaction(写) |
|---|---|---|
| Gas 消耗 | 无 | 有 |
| 钱包签名 | 不需要 | 需要 |
| 改变链上状态 | 不能 | 能 |
| 返回值 | 直接返回函数结果 | 返回交易哈希(tx.hash) |
| 等待时间 | 即时 | 需等区块确认 |
在 ethers.js 中,调用 view/pure 函数会自动使用 call,调用其他函数会自动发送 transaction。也可以用 contract.transfer.staticCall(...) 强制以 call 模式模拟写操作,用于预检交易是否会成功。
Q3: 解释 ERC-20 的 approve + transferFrom 流程,为什么不直接用 transfer?
答案:
transfer 只能由 Token 持有者自己调用。但在 DEX 场景中,Swap 合约需要代替用户转移 Token,这就需要两步授权机制:
- 用户调用
approve(spenderAddress, amount):授权 DEX 合约可以从自己账户转移最多amount数量的 Token - DEX 合约内部调用
transferFrom(userAddress, dexAddress, amount):DEX 代替用户完成转移
这种设计将"授权"和"执行"分离,用户始终保持对 Token 的控制权,只有被授权的合约才能转移用户的 Token。
Q4: 无限授权有什么风险?如何防范?
答案:
无限授权(approve(spender, MaxUint256))意味着被授权的地址可以随时转走用户持有的所有该 Token。风险包括:
- 合约存在漏洞被黑客利用
- 项目方作恶(rug pull)
- 合约的 admin key 被盗
防范措施:
- 对不信任的协议使用精确授权
- 使用 EIP-2612
permit(链下签名授权,避免单独的授权交易) - 定期通过 Revoke.cash 等工具检查并撤销不必要的授权
- 关注合约是否经过审计
Q5: 什么是函数选择器?如何计算?
答案:
函数选择器是函数签名的 Keccak-256 哈希的前 4 字节,EVM 通过它来识别调用的是哪个函数。
// "transfer(address,uint256)" 的选择器
ethers.id('transfer(address,uint256)').slice(0, 10); // "0xa9059cbb"
注意:签名中不能有空格,参数只用类型名不用参数名,uint 要写全 uint256。
理论上不同函数可能产生相同的选择器(哈希碰撞),但 Solidity 编译器会在编译时检查并报错。
Q6: 如何在前端监听合约事件?有哪些注意事项?
答案:
// 实时监听
contract.on('Transfer', (from, to, value) => { /* ... */ });
// 带过滤条件的监听(只监听转入我的地址)
const filter = contract.filters.Transfer(null, myAddress);
contract.on(filter, callback);
// 查询历史事件
const events = await contract.queryFilter(filter, fromBlock, toBlock);
注意事项:
- WebSocket Provider 支持实时推送,HTTP Provider 需要轮询
- 大范围历史查询需分批(RPC 节点有区块范围限制)
- 链重组(reorg)可能导致事件被撤销,建议等待多个区块确认
- 组件卸载时务必
removeAllListeners()避免内存泄漏
Q7: indexed 和非 indexed 参数有什么区别?
答案:
- indexed 参数:存储在日志的
topics数组中,最多 3 个。可以被高效过滤和检索,适合地址、ID 等需要查询的字段 - 非 indexed 参数:存储在日志的
data字段中,ABI 编码存储。不能直接过滤,但适合存储大量数据
值类型(address、uint256)的 indexed 参数直接存储原值;引用类型(string、bytes)的 indexed 参数存储的是 Keccak-256 哈希,无法还原原值。
Q8: estimateGas 有什么作用?失败意味着什么?
答案:
estimateGas 在节点本地模拟执行交易,返回预估的 Gas 消耗量。它的重要作用:
- 预检交易:如果
estimateGas失败,说明交易一定会 revert,可以提前告知用户 - 设置 Gas 上限:避免设置过高浪费 Gas 或过低导致 Out of Gas
- 提升用户体验:在用户签名前就发现问题
estimateGas 失败的常见原因:余额不足、授权额度不足、不满足合约的 require 条件、合约暂停等。
Q9: Multicall 的原理是什么?什么场景下需要使用?
答案:
Multicall 是一个部署在链上的辅助合约,它接收一组 (target, calldata) 参数,在合约内部循环调用每一个目标合约,然后将所有结果一次性返回。本质上是把 N 次 RPC 请求合并为 1 次。
适用场景:
- 查询用户多个 Token 的余额
- 同时获取多个交易对的价格
- 批量读取 NFT 元数据
- 任何需要并行读取多个合约数据的场景
注意 Multicall 只适合读操作(call),不适合写操作。
Q10: 什么是合约代理模式?前端开发需要注意什么?
答案:
代理模式通过 delegatecall 将调用转发到实现合约,让合约逻辑可以升级而地址不变。前端需要注意:
- ABI 要用实现合约的:代理合约本身只有
fallback函数,真正的业务 ABI 在实现合约上 - 在 Etherscan 上查看:需要点击 "Read as Proxy" / "Write as Proxy"
- 关注升级事件:合约升级后 ABI 可能变化,需要同步更新前端
- 获取实现地址:可以读取 EIP-1967 标准的 storage slot(
0x360894...)来获取当前实现合约地址
Q11: 如何处理合约交互中的用户体验?
答案:
良好的合约交互 UX 应包括:
- 交易前:
estimateGas预检、显示预估 Gas 费、检查余额和授权额度 - 交易中:显示"等待钱包签名" → "交易已提交" → "等待确认"三个阶段的状态
- 交易后:显示交易结果、区块确认数、链接到区块浏览器
- 错误处理:解析 revert reason 并展示人类可读的错误信息,区分用户取消和合约失败
Q12: ethers.js 和 viem 在合约交互上有什么区别?
答案:
两者都支持完整的合约交互,主要区别见 ethers.js 与 viem 对比。核心差异:
- ethers.js:面向对象风格,
new Contract(address, abi, signerOrProvider)创建实例后直接调用方法 - viem:函数式风格,读操作用
readContract()、写操作用writeContract(),每次传入完整参数
viem 的类型推断更强(从 ABI 自动推断参数和返回值类型),ethers.js 的生态更成熟。
Q13: 交易发送后如何确保最终被确认?
答案:
交易提交后可能遇到以下情况:
- Pending 过久:Gas Price 太低,需要加速(重新发送相同 nonce、更高 Gas 的交易)或取消(发送相同 nonce 的空交易)
- 链重组(Reorg):交易所在区块被回滚。通常等待 12-20 个区块确认可认为最终性
// 等待指定数量的区块确认
const receipt = await tx.wait(12); // 等待 12 个区块确认
对于涉及资金的重要操作,建议至少等待足够的区块确认后再更新前端 UI。更多交易相关知识见交易与 Gas 机制。