ERC4626 是以太坊社区定义的代币化金库标准接口,目前主流的收益聚合协议、借贷协议和 LST 类项目基本都已支持。对前端开发者来说,它的最大价值在于接口统一:只要拿到一个 Vault 合约地址,就能用同一套 React 代码完成存入、取出、余额查询和收益计算,不需要针对每个协议单独适配。本文将从接口解读、合约调用封装、精度处理到组件落地,完整讲清楚如何在 React 应用中集成 ERC4626 Vault。

一、ERC4626 核心接口解读:前端需要读哪些数据
ERC4626 继承自 ERC20,也就是说 Vault 合约本身就是一个代币,用户的存款余额以 share(份额)的形式记录。理解这一点非常重要:界面上展示的存入金额和可提取金额是两个不同的概念,前者是 share 余额,后者要通过汇率换算成 asset 数量。
前端最常读取的方法包括以下几类。只读方法:asset() 返回底层资产代币地址;totalAssets() 返回金库持有的资产总量;balanceOf(address) 返回用户的份额余额;convertToAssets(shares) 和 convertToShares(assets) 用于份额与资产的双向换算;previewDeposit() 等预览方法可以在不发送交易的情况下估算结果。写方法主要有四个:deposit(assetAmount, receiver)、mint(shareAmount, receiver)、withdraw(assetAmount, owner, receiver) 和 redeem(shareAmount, owner, receiver)。
需要注意 maxDeposit 与 maxMint 这类上限方法,部分金库设有存款上限或临时暂停状态,前端在渲染输入框时应主动调用这些方法做校验,避免用户提交交易后直接 revert,白白消耗 gas。此外金库的汇率会随收益增长而上升,所以页面数据需要定期刷新或依赖事件驱动更新。
二、用 viem 与 wagmi 封装 Vault 合约调用
在 React 中对接 ERC4626,推荐使用 wagmi 钩子加 viem 的方案。由于 ERC4626 的 ABI 是固定的,可以直接复用一份标准 ABI 常量,不必为每个协议单独维护 ABI 文件。下面给出一段基于 viem 的封装示例:
import { createPublicClient, http, parseAbi } from 'viem'
import { mainnet } from 'viem/chains'
// 标准 ERC4626 ABI,前端只需读取和调用常用方法
const ERC4626_ABI = parseAbi([
'function asset() view returns (address)',
'function totalAssets() view returns (uint256)',
'function balanceOf(address) view returns (uint256)',
'function convertToAssets(uint256) view returns (uint256)',
'function previewDeposit(uint256) view returns (uint256)',
'function deposit(uint256 assets, address receiver) returns (uint256)',
'function withdraw(uint256 assets, address owner, address receiver) returns (uint256)',
'event Deposit(address indexed sender, address indexed owner, uint256 assets, uint256 shares)'
])
const client = createPublicClient({
chain: mainnet,
transport: http('https://127.0.0.1:8545')
})
// 读取金库基础信息
export async function fetchVaultInfo(vaultAddress, userAddress) {
const asset = await client.readContract({
address: vaultAddress,
abi: ERC4626_ABI,
functionName: 'asset'
})
const totalAssets = await client.readContract({
address: vaultAddress,
abi: ERC4626_ABI,
functionName: 'totalAssets'
})
const shares = await client.readContract({
address: vaultAddress,
abi: ERC4626_ABI,
functionName: 'balanceOf',
args: [userAddress]
})
// 用户资产 = 份额换算成底层资产
const userAssets = await client.readContract({
address: vaultAddress,
abi: ERC4626_ABI,
functionName: 'convertToAssets',
args: [shares]
})
return { asset, totalAssets, shares, userAssets }
}写操作要走钱包签名,wagmi 提供了 useWriteContract 这样的钩子。一个典型的坑是:deposit 之前必须先确认用户对底层 asset 代币的授权额度是否足够,否则交易会失败。正确的流程是先调用 asset 合约的 allowance 查询授权额度,不足时提示用户 approve,等授权交易确认后再发起 deposit。withdraw 方向则不需要授权,因为消耗的是用户自己持有的 share。
另一个容易被忽略的细节是时序问题。交易确认上链后,链上状态可能因为节点同步延迟还没反映到你连接的 RPC 节点,建议在确认回调里延迟几百毫秒再触发数据刷新,或者干脆依赖区块号变化来重新拉取数据,体验会稳定很多。
三、精度处理与金额展示:bigInt 不能直接运算
合约返回的金额都是 bigInt 类型,携带资产自身的小数位,USDT 是 6 位,大多数代币是 18 位。前端展示时必须除以 10 的 decimals 次方,而且这个除法要用字符串或 bigInt 库处理,直接转 Number 会在金额较大时丢失精度。推荐用 viem 自带的 formatUnits 和 parseUnits:
import { formatUnits, parseUnits } from 'viem'
const shares = 1234567890000000000n // 用户份额,18 位小数
const assets = 1250000000000000000n // 换算后的资产数量
// 格式化为可读字符串,保留 4 位展示
const display = Number(formatUnits(assets, 18)).toFixed(4)
console.log(display) // 1.2500
// 用户输入金额转成 bigInt 再提交合约
const inputAmount = '100.5'
const depositAmount = parseUnits(inputAmount, 18)收益率的计算也是常见需求。由于份额恒定而资产随收益增长,apy 展示不能只看 convertToAssets 的即时值,需要定期采样汇率变化再做年化换算。一个简单做法是每隔固定时间记录一次 convertToAssets(1e18) 的值,存入本地数组后按时间加权计算变化率。精确的 APY 计算涉及复利模型,如果只是做展示,用最近 24 小时的汇率变化年化即可满足大部分场景。
还有一点值得强调:deposit 时用户指定 asset 数量,份额由合约计算;而 mint 模式刚好相反,用户指定想获得的份额数量。界面上最好明确区分这两种模式,并在提交前用 previewDeposit 展示预计获得的份额,减少用户的心理不确定性。
四、可复用的 VaultCard 组件实现
最后把上面的内容整合成一个可以直接放进页面的组件。组件接收金库地址,内部完成数据读取、存款和取款:
import { useState } from 'react'
import { useAccount, useReadContract, useWriteContract } from 'wagmi'
import { formatUnits, parseUnits, erc20Abi } from 'viem'
const vaultAbi = [
{ inputs: [], name: 'asset', outputs: [{ type: 'address' }], stateMutability: 'view', type: 'function' },
{ inputs: [{ type: 'address' }], name: 'balanceOf', outputs: [{ type: 'uint256' }], stateMutability: 'view', type: 'function' },
{ inputs: [{ type: 'uint256' }], name: 'convertToAssets', outputs: [{ type: 'uint256' }], stateMutability: 'view', type: 'function' },
{ inputs: [{ type: 'uint256' }, { type: 'address' }], name: 'deposit', outputs: [{ type: 'uint256' }], stateMutability: 'nonpayable', type: 'function' },
{ inputs: [{ type: 'uint256' }, { type: 'address' }, { type: 'address' }], name: 'withdraw', outputs: [{ type: 'uint256' }], stateMutability: 'nonpayable', type: 'function' }
]
export function VaultCard({ vaultAddress }) {
const { address } = useAccount()
const [amount, setAmount] = useState('')
const { writeContractAsync } = useWriteContract()
const { data: assetAddress } = useReadContract({
address: vaultAddress, abi: vaultAbi, functionName: 'asset'
})
const { data: shares } = useReadContract({
address: vaultAddress, abi: vaultAbi, functionName: 'balanceOf',
args: [address], query: { enabled: !!address }
})
const { data: assetsOfUser } = useReadContract({
address: vaultAddress, abi: vaultAbi, functionName: 'convertToAssets',
args: [shares ?? 0n], query: { enabled: !!shares }
})
async function handleDeposit() {
if (!assetAddress || !address) return
const value = parseUnits(amount, 18)
// 先授权再存款
await writeContractAsync({
address: assetAddress, abi: erc20Abi,
functionName: 'approve', args: [vaultAddress, value]
})
await writeContractAsync({
address: vaultAddress, abi: vaultAbi,
functionName: 'deposit', args: [value, address]
})
setAmount('')
}
async function handleWithdraw() {
if (!shares || !address) return
await writeContractAsync({
address: vaultAddress, abi: vaultAbi,
functionName: 'withdraw', args: [assetsOfUser, address, address]
})
}
return (
<div className="vault-card">
<p>底层资产:{assetAddress}</p>
<p>我的份额:{shares ? formatUnits(shares, 18) : '0'}</p>
<p>折合资产:{assetsOfUser ? formatUnits(assetsOfUser, 18) : '0'}</p>
<input value={amount} onChange={e => setAmount(e.target.value)} placeholder="存款金额" />
<button onClick={handleDeposit}>存入</button>
<button onClick={handleWithdraw}>全部取出</button>
</div>
)
}这个组件还有几处可以继续打磨的地方。一是授权与存款是两笔交易,体验上可以检测已有授权额度,够用就直接存款;二是异常处理,实际项目里要给 writeContractAsync 加 try catch,并区分用户拒绝签名和链上执行失败两种情况;三是取款时如果金库流动性不足,withdraw 会 revert,可以改用 redeem 按份额取出,或提前查询 maxWithdraw 给出提示。
整体来说,ERC4626 的标准化让前端从每个协议写一套适配变成了一套代码接入所有金库。把上面这套读取、换算、授权和交易的逻辑封装成 hook 与组件,你的 React 应用就能以很低的成本支持任意符合标准的 Vault,后续接入新协议时只需要传入新的合约地址。