跳到主要内容

智能合约交互

问题

前端 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 机制、事件监听等多个知识点。

智能合约基础概念

智能合约的三个核心特性:

特性说明
不可修改合约代码部署后无法更改(除非使用代理模式)
确定性执行相同输入必定产生相同输出
交易触发只有链上交易才能改变合约状态,不会自动执行
合约 vs 普通地址

普通地址(EOA)由私钥控制,合约地址由代码控制。合约没有私钥,无法主动发起交易,只能被动响应调用。

ABI 详解

ABI(Application Binary Interface) 是前端与合约之间的"接口协议",它描述了合约有哪些函数、参数类型、返回值类型。

ABI 的结构

ERC-20 部分 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 字节对齐的方式编码参数:

ABI 编码示例
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),调用可能失败或产生意外行为。升级合约后务必同步更新前端的 ABI 文件。

合约调用分类

合约交互分为两种:读操作写操作

对比项读操作(Call)写操作(Transaction)
是否消耗 Gas不消耗消耗 Gas
是否改变链上状态不改变改变
是否需要签名不需要需要钱包签名
返回值直接返回结果返回交易哈希
执行速度即时返回需等待区块确认
Solidity 标识view / pure无修饰符 / payable
读操作 vs 写操作
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

读操作不需要签名,因此可以只用 Provider 而不需要 Signer。但写操作必须使用 Signer(关联钱包)。

ERC-20 Token 交互完整流程

ERC-20 是最常见的 Token 标准,掌握它的交互流程是前端 Web3 开发的基本功。

查询余额 — balanceOf

查询 Token 余额
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 检查和撤销不必要的授权
无限授权 vs 精确授权
// ❌ 无限授权:最大风险
await tokenContract.approve(spender, ethers.MaxUint256);

// ✅ 精确授权:只授权实际需要的数额
await tokenContract.approve(spender, ethers.parseUnits('100', 18));

转账 — transfertransferFrom

两种转账方式
// 直接转账:用户自己将 Token 转给别人
await tokenContract.transfer(toAddress, amount);

// 代理转账:合约代替用户转移 Token(需要提前 approve)
await dexContract.transferFrom(userAddress, dexAddress, amount);

事件(Event)与日志

Solidity 合约通过 Event 发出日志,前端可以实时监听查询历史事件。事件存储在交易收据的 logs 中,比存储在合约 storage 中便宜得多。

Solidity Event 定义

Transfer 事件定义
// indexed 参数可被用于过滤查询
event Transfer(
address indexed from,
address indexed to,
uint256 value
);

indexed 参数与 Topics

每个日志条目包含 topicsdata 两部分:

字段内容说明
topics[0]事件签名的哈希keccak256("Transfer(address,address,uint256)")
topics[1]第一个 indexed 参数from 地址
topics[2]第二个 indexed 参数to 地址
dataindexed 参数(ABI 编码)value 金额
为什么 indexed 最多 3 个?

每个日志最多有 4 个 topics,其中 topics[0] 被事件签名占用,所以 indexed 参数最多 3 个。indexed 参数可被高效过滤,但不适合存储大量数据(如字符串)。

实时监听事件

监听 Transfer 事件
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');

查询历史事件

查询历史 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:

Solidity 自定义错误
// 定义自定义错误
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,提前发现可能的失败:

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 请求。

使用 Multicall 批量查询余额
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 };
});
}
Multicall 的优势

假设需要查询 20 个 Token 余额:

  • 无 Multicall:20 次 RPC 请求,网络往返延迟累积
  • 有 Multicall:1 次 RPC 请求,节点内部批量执行

对于需要展示 Token 列表、资产组合的 DApp,Multicall 是必备优化。

合约代理模式简介

由于合约部署后不可修改,升级合约需要使用代理模式(Proxy Pattern):用户始终调用的是代理合约(地址不变),代理通过 delegatecall 将调用转发到实现合约(可更换)。

前端需关注 Implementation Address

使用代理模式的合约,在 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 来:

  1. 编码调用数据:将函数名和参数编码为 EVM 能理解的 calldata
  2. 解码返回值:将合约返回的二进制数据解码为可读的 JavaScript 值
  3. 生成类型安全的接口:ethers.js / viem 根据 ABI 自动生成带类型提示的合约方法

没有 ABI,前端就需要手动计算函数选择器、手动编码参数,非常容易出错。

Q2: 合约的 calltransaction 有什么区别?

答案

特性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,这就需要两步授权机制:

  1. 用户调用 approve(spenderAddress, amount):授权 DEX 合约可以从自己账户转移最多 amount 数量的 Token
  2. 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 编码存储。不能直接过滤,但适合存储大量数据

值类型(addressuint256)的 indexed 参数直接存储原值;引用类型(stringbytes)的 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 将调用转发到实现合约,让合约逻辑可以升级而地址不变。前端需要注意:

  1. ABI 要用实现合约的:代理合约本身只有 fallback 函数,真正的业务 ABI 在实现合约上
  2. 在 Etherscan 上查看:需要点击 "Read as Proxy" / "Write as Proxy"
  3. 关注升级事件:合约升级后 ABI 可能变化,需要同步更新前端
  4. 获取实现地址:可以读取 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 机制

相关链接