09 · ERC-20 / approve 机制 / USDC、WETH 的特殊性
第二层第四篇——闭关 Layer 2。06 说账户有
code+storage;07 说 EVM 怎么跑;08 说合约怎么部署调用。 这一篇把"代币"这件人人以为自己懂、实际全是坑的事讲清楚:token 不在链上,token 在某个合约的 一张 mapping 里;approve / allowance / transferFrom三件套的真身、它为什么是闪电贷能成立的前提; USDC 6-decimals、WETH 是包装合约、USDTtransfer不返 bool 这些"标准里没有的标准"。结构见README.md§1。
0. 一句话本质
ERC-20 token = 某个合约 storage 里一张
mapping(address => uint256) balances的某行。 "转账"= 改两个 mapping 项;"余额"= 一次 SLOAD;"持有 USDC"= USDC 合约 storage 第 9 槽派生的某个 keccak 哈希位上写了一个数字。 approve/allowance/transferFrom 是这套机制为了让"另一份合约能代你花钱"加的二级表——它是 DeFi 一切(包括我们闪电贷收回本金那一步)能跑的前提。
1. 怎么产生的:它解决什么问题
08 + 07 + 06 留下的疑问,全集中在"代币"这件事上:
- 链上原生只有 ETH 一种资产,那 USDC / WETH / 上千种"币"到底"存"在哪?谁记账?
⇒ 不存在链上。一份合约用一张
mapping(address => uint256) balances记账,"持有量" = 那张 mapping 的一行。这份合约本身住在某个地址(USDC =0xA0b8...eB48)。 - 为什么要有
approve?为什么不直接给对方transfer? ⇒ 闪电贷、DEX swap、Aave 借贷——所有"合约代你花钱"的场景,对方必须主动把你的代币 拉走(合约自己不会突然被动收到 ERC-20 token 通知)。approve是你提前给的"信用额度", 对方拿这个额度去transferFrom你的钱。 - 为什么我们闪电贷 callback 里必须先
approve(POOL, amount+premium)?不 approve 会怎么样? ⇒ Aave Pool 在 callback 返回后会用transferFrom(borrower, address(this), amount+premium)把本金 + 5bps 拉回;这一句 transferFrom 要查 allowance:如果你没提前 approve, allowance = 0,transferFrom revert,整个 flashLoanSimple revert(07 §4.4 原子性),你白付一波 gas。 - 为什么大家说
approve(spender, infinite)危险?我看不少 dApp 都让我这么签。 ⇒ 那是个权衡:每次操作都 approve(精确数额) 会多一笔 tx(多 gas + 多 UX 摩擦); 一次 approve(uint256.max) 能省后续 approve 的钱,但 spender 合约一旦有 bug 或被 攻破,你的 token 可被一次抽干。USDC blacklist / WETH 没人管 是另一回事。 - 为什么 USDC 是 6 decimals?我代码里
26869e6表示 USDC、1e18表示 WETH——为什么不统一? ⇒ ERC-20 规定decimals()是合约自己选的。协议根本不管——6 还是 18 都合法。USDC 选 6 是历史 传承(Tether 起步就 6)。这是 token 之间 amount 不能盲目互换的原因。 - USDT
transfer不返 bool?这种"违反标准"的代币怎么处理? ⇒ ERC-20 规定transfer / approve / transferFrom应返回bool。USDT、BNB 老版等 直接返回 void——按标准编译的 Solidity 调它会 revert。OpenZeppelin 出了SafeERC20.safeTransfer(...)用 low-levelcall+ 手动解码兜底处理。 - WETH 是什么?1 ETH = 1 WETH 但为什么我转 ETH 给 WETH 合约能"变成" WETH?
⇒ WETH 是把 ETH 包装成 ERC-20 形态的合约。
WETH.deposit{value: 1 ether}()让合约把 你转入的 ETH 记到balances[msg.sender] += 1e18,没有任何魔法——纯一个映射 + payable 函数。 - EIP-2612 permit 是什么?为什么 Uniswap V3 / 大部分新合约支持?
⇒ 用一份签名代替一次 approve tx;前端拿你签的 EIP-712 数据,连同 swap 一起塞给合约,
合约用
ecrecover校验 → 直接更新 allowance → 然后 transferFrom。省一笔 tx。
→ 这一篇答全部。
2. 它本质在做的"那一件事"
把"资产"从协议级(ETH)下放到合约级:任何人写一份合约 + 一张
balancesmapping, 都能在以太坊上发行一种代币;只要遵守 ERC-20 那 9 个函数 + 2 个事件的协议,全世界的钱包、DEX、借贷协议 都能自动兼容。approve / allowance / transferFrom三件套则是这套系统能扩展到"合约也能花你的钱" 的关键扩展,是 DeFi 一切组合性的水管接头。
→ ERC-20 是迄今为止最重要的 Ethereum Improvement Proposal 之一(顺位仅次于以太坊本身);它解锁了 任何人都能发币这件事——好处坏处全部跟着来。
3. 类比:村庄的"代金券系统"
01–08 的村庄有 ETH(村庄原生货币)。现在多了一类新东西:
┌── 代金券发行机(一份 ERC-20 合约) ──────────────────────┐
│ │
│ 机器内部账本: │
│ balances: Alice → 100 │
│ Bob → 50 │
│ ... │
│ allowances: Alice → { Aave: 100, Uniswap: ∞ } │
│ │
│ 你可以做的操作: │
│ transfer(to, n) 我直接给 to 转 n │
│ approve(spender, n) 我授权 spender 可花我 n │
│ transferFrom(from, to) spender 来这调,按 allowance 扣│
│ balanceOf(addr) 查询余额(STATICCALL) │
│ │
│ 机器还会贴公告: │
│ emit Transfer(from, to, value) 每次转账 │
│ emit Approval(owner, spender, n) 每次授权 │
│ │
│ 代金券**不出离这台机器**: │
│ Alice 拿着她那 100 块代金券去其他机器消费——其实是 │
│ Alice 先 approve 那台机器,对方再来本机 transferFrom。 │
│ 全村流通的不是纸券,是**这台机器上"谁有多少"的记录**。 │
└─────────────────────────────────────────────────────────┘
→ 这就回答了"USDC 在哪"的问题:它住在 0xA0b8...eB48,你的余额是那个合约里一张 mapping 的一行。
USDC 公司能改它(USDC 是可升级代理,08 §4.3 / §7);他们也能把你拉进 blacklist 让你 transfer revert。
→ 这就是为什么 stablecoin 不真"去中心化":发行方就是合约的 owner。
4. 核心关键点(5 条)
-
ERC-20 接口 = 9 函数 + 2 事件 + 3 元数据。
函数 选择器 作用 totalSupply()0x18160ddd总量 balanceOf(addr)0x70a08231查余额(STATICCALL) transfer(to, n)0xa9059cbb我转给 to transferFrom(from, to, n)0x23b872ddspender 调,按 allowance 扣 from approve(spender, n)0x095ea7b3我授权 spender 可花 n allowance(owner, spender)0xdd62ed3e查授权额度 name() / symbol() / decimals()元数据,可选 显示用 Events:
Transfer(from, to, value)/Approval(owner, spender, value)。 -
approve / allowance / transferFrom 三件套 = "另一个合约能花你钱"的协议。
- 步骤 A:你 →
token.approve(spender, n)→ token 合约的allowance[you][spender] = n - 步骤 B:spender 合约干活时调
token.transferFrom(you, recipient, m)→- token 合约检查
allowance[you][spender] >= m balances[you] -= m; balances[recipient] += m; allowance[you][spender] -= m
- token 合约检查
- 没 approve 就 transferFrom = revert(这就是 §1.3 我们闪电贷必须先 approve 的原因)
- 步骤 A:你 →
-
decimals 是显示约定,链上数学全是 base units。
- USDC: 6 decimals → "100 USDC" =
100 * 10^6 = 100,000,000base units - WETH: 18 decimals → "1 ETH" =
1 * 10^18 = 1,000,000,000,000,000,000base units - 合约里所有计算用 base units;只在 UI 显示时除以 10^decimals。
- 这是我们代码里
26869e6(USDC)、1e18(WETH)的来源。
- USDC: 6 decimals → "100 USDC" =
-
storage 布局可被精确计算 →
dealUsdccheatsheet(接 06 §4.4)。 USDC mappingbalances在 storage slot 9。要把地址A的余额改为X:targetSlot = keccak256(abi.encode(A, uint256(9))) anvil_setStorageAt(USDC, targetSlot, bytes32(X))这就是 Foundry
deal(usdc, addr, amount)内部做的事;只对开发环境(anvil fork)有效, 主网不可能。 -
token 的"不标准"是常态。
- USDT / 老版 BNB:
transfer / approve / transferFrom返回void,不是bool→ 标准编译的调用会 revert - Fee-on-transfer 代币:
transfer(to, n)实际到账n - fee→ 调用方按n算账会错 - Rebase 代币(如 AMPL):
balanceOf(addr)会随时间变化,每个块都不同 - Blacklist 代币(USDC / USDT):
transfer调用者或目标在黑名单 → revert - 应对:调真实代币用
SafeERC20(OpenZeppelin);写新合约设计交互时考虑这些边界。
- USDT / 老版 BNB:
5. 必须先认识的前置名词(小词典)
| 名词 | 一句话 | 类比 |
|---|---|---|
| token / 代币 | 部署在某地址的 ERC-20 合约 + 一张 mapping balances | 一家代金券发行机 |
| token contract address | 这份代币合约本身住的地址,例如 USDC = 0xA0b8…eB48 |
发行机的固定位置 |
| balances | mapping(address => uint256) 持有量记录 |
发行机的账本 |
| allowances | mapping(address => mapping(address => uint256)) 授权记录 |
发行机的"信用额度"表 |
| approve(spender, amount) | 给 spender 可花你最多 amount 的额度 | 给某店发一张限额信用卡 |
| allowance(owner, spender) | 当前 spender 还能花 owner 多少 | 卡里剩多少额度 |
| transferFrom(from, to, amount) | spender 调,扣 from 的 balance 给 to,allowance 同步减 | 店家刷你那张卡 |
| decimals | 显示用的小数位数;链上数学是 base units 整数 | 标价 vs 标分 |
| Transfer / Approval event | 每次转账 / 授权各 emit 一次 | 每次交易留收据 |
| base units | 链上真实计算单位(USDC 用 6 位 = 微 USDC) | 分(vs 元) |
| WETH | 把 ETH 包装成 ERC-20 形态的合约;1 ETH ⇄ 1 WETH | ETH 的代金券化身 |
| EIP-2612 / permit | 用 EIP-712 签名直接更新 allowance,省一笔 approve tx | 提前签好的授权书 |
| SafeERC20 | OpenZeppelin 库,兼容不返 bool / fee-on-transfer 等不标准 token | 防呆通用刷卡器 |
| blacklist | 中心化代币(USDC/USDT)能拉黑某地址,封住其 transfer | 发行方可冻结某账户 |
| rebase / fee-on-transfer | 余额会自动变 / 转账会被自动扣手续费 | 余额会蒸发的代金券 |
6. 一个完整过程走一遍:闪电贷收回本金那一刻
把项目里我们闪电贷的"approve 然后等 Aave transferFrom"这一拍展开到字节级:
设定
- 借出:26,869 USDC(amount =
26869 * 1e6 = 26869000000base units) - premium:5 bps =
26869 * 0.0005 * 1e6 = 13434500base units - owed =
26869013434500 - USDC 合约:
0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 - Aave Pool:
0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2
时间轴
─── T0: Arbitrageur.executeOperation 入口(Aave 已把 USDC 转给我)────
我的 balances[Arbitrageur] = 26869000000
─── T1: 我做两次 swap,赚回利润,最终 balance > owed ───
我的 balances[Arbitrageur] = 27,000,000,000 (示例: 多了 0.131 USDC 利润)
─── T2: 我准备 approve Aave 把 owed 拉回 ───
我执行:
USDC.approve(POOL, owed)
这一行编译后的 calldata:
0x095ea7b3 ← approve selector
00000000…000087870Bca3F3fD6335C3F4ce8392D69350B4fA4E2 ← POOL (32B)
000000000000000000000000000000000000000000000000000186B0AA13F4 ← owed (32B)
EVM:
- CALL USDC (0xA0b8…)
- USDC 合约内部 dispatch: 选择器命中 approve_impl
- 更新 storage: allowances[Arbitrageur][POOL] = 26869013434500
- LOG Approval(Arbitrageur, POOL, 26869013434500)
- return true (32B)
─── T3: executeOperation 返回 true ───
─── T4: Aave Pool 在 flashLoanSimple 后续逻辑里做 ───
USDC.transferFrom(Arbitrageur, POOL, owed)
EVM:
- CALL USDC
- 选择器 0x23b872dd 命中 transferFrom_impl
- 检查 allowances[Arbitrageur][POOL] >= owed ✅
- balances[Arbitrageur] -= owed (27,000,000,000 - 26,869,013,434,500/1e6 ≈ 利润留我)
- balances[POOL] += owed
- allowances[Arbitrageur][POOL] -= owed (变 0,干净,无残额遗患)
- LOG Transfer(Arbitrageur, POOL, owed)
- return true
─── T5: Aave 检查回收金额 == owed ─── 通过 ─── flashLoanSimple 成功返回 ───
─── T6: tx 落账,receipt.status = 1,gasUsed = 487,252(07 §8)───
关键观察:
- 如果 T2 省略,T4 的 transferFrom 会因 allowance = 0 而 revert,整个 tx 回滚(07 §4.4), 我们白付 200k+ gas。这是为什么我们 Arbitrageur 第一行套利逻辑后必须调 approve。
- infinite approve 优化:如果我们
approve(POOL, type(uint256).max)一次性,下次同一对 路径不用再 approve(减少每次一个 SSTORE)。代价:Pool 一旦被攻破,我们的 USDC 任凭它拉。 我们项目场景下 owner 信得过,且每次都精确 approve(owed) 也行(多花一点 gas 换严格控权)。- T4 末
allowances[Arbitrageur][POOL] -= owed变 0 是 ERC-20 标准的规定;这是 "用完归零"的良好性质。infinite approve 反过来会触发"if allowance == max 就不扣"的优化分支。
7. 常见误区(逐条拍死)
| 误区 | 实情 |
|---|---|
| "USDC / WETH / DAI 存在链上某处" | 错。它们各自住在某个合约地址;"持有量"是该合约 storage 一张 mapping 的一项 |
| "代币转账靠链发广播给所有持有人" | 错。Transfer 是事件(LOG,08 §4.4),写在 receipt;状态变更就是 mapping 两项的加减 |
| "USDC 有 18 decimals" | 错。USDC = 6 decimals。WETH 才 18。盲目按 18 算会差 12 个数量级 |
| "1 USDC 等于 1 USDT 等于 1 DAI" | 票面价类似但它们是三个独立合约,互相之间不能直接转,要换得过 DEX |
| "approve 是确认我会去转账" | 反了。approve 是让别人用 transferFrom 来扣我 |
| "approve(infinite) 因为 dApp 让我签就是安全的" | 不是。dApp 是为省你后续 gas + UX 摩擦才默认推 infinite。后果是 spender 合约一旦有 bug 你的 token 可被一次抽干。重要资产建议精确 approve |
| "approve 改额度可以直接调一次新数额" | race condition:从 N 改到 M 期间,spender 可同时花掉 N + M(双花)。OpenZeppelin 早期推荐先 approve(0) 再 approve(M)。EIP-20 后来在文档里强调"小心";USDC 等代币干脆禁止非零→非零(强制先归零) |
| "WETH 是另一种 ETH" | WETH 就是一份合约,余额是 mapping 的一项;deposit/withdraw 是 ETH ↔ mapping 之间的双向换皮 |
| "所有 ERC-20 都返 bool" | USDT 不返。SafeERC20 存在的全部理由 |
| "decimals 是协议规定的" | 每个合约自己说。协议不验证。Tether 当年选 6 → USDC 跟着 6 → 历史延续;DAI / WETH 选 18 |
| "blockExplorer 显示的余额是从某个 token 总注册表读的" | 错。explorer 知道哪个地址是哪个 token(人工 / 社区维护的列表),然后逐个去那些合约调 balanceOf 给你拼出来。链上没有"我有哪些 token"的全表 |
| "permit 是 EIP-20 的一部分" | 不是。EIP-2612 是后来加的可选扩展。只有支持的代币能用(DAI、USDC v2.1+ 等支持;USDT 不支持) |
8. 与本项目实践的对应
把项目里跟 ERC-20 沾边的每个写法接回模型:
我们 amount 数字的真身
26869e6是 26,869 USDC × 10^6(USDC decimals = 6)。换 WETH 表达就是1e18。 这是 §4.3 base units 的直接体现。liquidationCall(..., 50_000e6, ...)表达 50,000 USDC(debt to cover)。- 不能把这两种 amount 互换:USDC 50_000e6 ≈ 5 万美元;WETH 50_000e6 ≈ 5×10^-8 ETH 几乎为零。 这是为什么 ERC-20 库(OpenZeppelin / SafeERC20)从不为你做"统一单位"——故意让你显式处理。
dealUsdc 与 06 §4.4 储存槽
dealUsdc(addr, amount)(Foundry cheat)= 直接写 USDC 合约 storage 第 9 槽派生的 keccak 槽位。 这是 §4.4 给的公式:keccak256(abi.encode(addr, uint256(9)))→setStorageAt(USDC, slot, amount)。- 我们 12/12 测试里所有种钱步骤都是这样:绕过 Circle、绕过转账、直接改 USDC 的账本。 这只在 anvil fork 环境合法(08 §4.3 + 06 §4.5 共同的"主网无法做到这件事")。
我们 4 份合约里所有 approve / transferFrom 模式
Arbitrageur.executeOperation内:IERC20(asset).approve(POOL, owed)后由 Aave transferFrom(§6 走过一遍)MultiPoolArbitrageur多路径之间:IERC20(tokenIn).approve(router, amountIn)让 router transferFromLiquidationBot.executeOperation:先 approve POOL 做 liquidationCall(POOL 内 transferFrom 你抵债的 USDC), swap 抵押物时再 approve router;最后 approve POOL 把闪电贷本金 + premium 收回——一笔 tx 里 3 次 approve, 这是 897,176 gas(07 §8)里 SSTORE 占大头的原因之一。FlashLoanHelloWorld.executeOperation:仅 approve POOL 一次(最简形式)→ 359,816 gas。
balanceOf 是 STATICCALL(08 §4.3)
IERC20(token).balanceOf(addr)view 函数 → 编译器自动 STATICCALL → 只读、不能改 storageComparePools.s.sol大量使用——所以可以纯只读"读出当前状态"做对比。
USDC / WETH 的"特殊性"在我们项目里出现的地方
- USDC blacklist:理论上 Circle 能拉黑我们合约让 USDC transfer revert。这是 stablecoin 不真去中心化的实证。 我们 fork 测试里不会发生,但部署到主网这是一类真实风险(虽极罕见)。
- WETH wrapping:我们 Uni V2 swap 用 WETH 而非 ETH,因为 V2 pair 只跟 ERC-20 对话,ETH 必须先
WETH.deposit{value: x}()包成 WETH 才能进入流动性池。 - USDT 我们没用——它的非标准返回 + USDT 池流动性集中在 V3 → 我们 V2 demo 没选择它。
若用,必须改成
SafeERC20.safeTransfer。
09 解锁的下一篇
- 10 DEX/AMM:
x*y=k、getAmountOut、0.3% 手续费——所有计算输入是 base units(§4.3), 输出也是 base units;getAmountOut 接两边的 reserves(balances mapping 的两个项)和 amountIn 算出 amountOut。 → 我们 wei-exact 验证的根。
9. 自检问题(答得出才算过)
- "USDC 在链上"这句话准确吗?严格地说 USDC 余额"在哪"?
- 为什么我们 Arbitrageur 闪电贷 callback 里必须先 approve POOL,否则会发生什么?精确到选择器和 storage 改动写出 transferFrom 内部流程。
- USDC 是 6 decimals、WETH 是 18 decimals——
getAmountOut(amountIn=1e6, reserveIn=1e9, reserveOut=1e18)的结果在概念上有什么含义?为什么 decimal 数字不能盲目套? approve(N) → spender 花了 K (K<N) → 我想改成 M,直接approve(M)有什么风险?现代解法是什么?- WETH 是另一种 ETH 吗?
WETH.deposit{value: 1 ether}()里 EVM 层到底发生了什么? - 一个不返 bool 的代币(如 USDT)用标准
IERC20(usdt).transfer(...)调会发生什么?OpenZeppelin 的SafeERC20怎么救? - infinite approve 的具体风险是什么?什么场景下值得这么做、什么场景下应当精确 approve?
dealUsdccheat 在主网能复现吗?为什么?
── Q&A / 更正记录区(按日期追加)──
规范见
README.md§0。质疑/反馈/纠错按日期累积,不抹错误轨迹。
-
(2026-05-20 建立。暂无 Q&A。)
-
(2026-07-27) 问:链上"币"(代币)的产生和约定或限制是怎样的。
答(补充代币铸造机制与供给规则的可信度边界,未更正原文;完整版含约束模式对比表见
../total/Q&A/2026-07-27-链上账本手记.html第二节 "代币(合约层铸造)"tab,与原生币协议层发行的对比也在同一节):ERC-20 代币不是"链自带"的东西,只是一份合约自己维护的
mapping(address=>uint256) balances+ 一个totalSupply变量。转账是"A 减、B 加";铸币(mint)是只加不减——把某地址余额和 totalSupply 同时加一个数,凭空生成;销毁(burn)反过来只减不加。这两个操作在 EVM 层面和普通加减法没有区别, 特殊之处全在"谁被允许调用、调用时检查什么条件",而这些检查只存在于这份合约自己的代码里:function mint(address to, uint256 amount) external onlyOwner { require(totalSupply() + amount <= CAP, "exceeds cap"); _mint(to, amount); }常见约束模式,可信度层层递减:一次性铸完无 mint 函数(最强承诺,部署后彻底没有铸币入口)>
ERC20Capped(上限只是一个require,每次铸币都检查)>AccessControl(MINTER_ROLE角色化, 可以给多签/DAO)> 单一onlyOwner(私钥泄露=无限铸币,很多 rug pull 的机制)。关键结论:
ERC20Capped的"总量上限"不是链给的保证,只是合约里的一行require——如果合约是 可升级代理(proxy)、实现被换掉,这行检查可以直接消失。代币供给规则的可信度 = 这份合约代码(以及谁能升级它)的可信度,不是链本身赋予的,这跟原生币(比特币 2100 万上限、以太坊发行曲线) 写死在协议共识规则里、改需要说服全网硬分叉,是完全不同的两个可信度层级——审计报告、是否可升级、 owner 是不是多签,比"白皮书写了多少总量"更重要。