第二层第四篇——闭关 Layer 2。06 说账户有 code + storage;07 说 EVM 怎么跑;08 说合约怎么部署调用。 这一篇把"代币"这件人人以为自己懂、实际全是坑的事讲清楚:token 不在链上,token 在某个合约的 一张 mapping 里approve / allowance / transferFrom 三件套的真身、它为什么是闪电贷能成立的前提; USDC 6-decimals、WETH 是包装合约、USDT transfer 不返 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 留下的疑问,全集中在"代币"这件事上:

  1. 链上原生只有 ETH 一种资产,那 USDC / WETH / 上千种"币"到底"存"在哪?谁记账? ⇒ 不存在链上。一份合约用一张 mapping(address => uint256) balances 记账,"持有量" = 那张 mapping 的一行。这份合约本身住在某个地址(USDC = 0xA0b8...eB48)。
  2. 为什么要有 approve?为什么不直接给对方 transfer ⇒ 闪电贷、DEX swap、Aave 借贷——所有"合约代你花钱"的场景,对方必须主动把你的代币 拉走(合约自己不会突然被动收到 ERC-20 token 通知)。approve 是你提前给的"信用额度", 对方拿这个额度去 transferFrom 你的钱。
  3. 为什么我们闪电贷 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。
  4. 为什么大家说 approve(spender, infinite) 危险?我看不少 dApp 都让我这么签。 ⇒ 那是个权衡:每次操作都 approve(精确数额) 会多一笔 tx(多 gas + 多 UX 摩擦); 一次 approve(uint256.max) 能省后续 approve 的钱,但 spender 合约一旦有 bug 或被 攻破,你的 token 可被一次抽干。USDC blacklist / WETH 没人管 是另一回事。
  5. 为什么 USDC 是 6 decimals?我代码里 26869e6 表示 USDC、1e18 表示 WETH——为什么不统一? ⇒ ERC-20 规定 decimals() 是合约自己选的。协议根本不管——6 还是 18 都合法。USDC 选 6 是历史 传承(Tether 起步就 6)。这是 token 之间 amount 不能盲目互换的原因
  6. USDT transfer 不返 bool?这种"违反标准"的代币怎么处理? ⇒ ERC-20 规定 transfer / approve / transferFrom 应返回 bool。USDT、BNB 老版等 直接返回 void——按标准编译的 Solidity 调它会 revert。OpenZeppelin 出了 SafeERC20.safeTransfer(...) 用 low-level call + 手动解码兜底处理。
  7. WETH 是什么?1 ETH = 1 WETH 但为什么我转 ETH 给 WETH 合约能"变成" WETH? ⇒ WETH 是把 ETH 包装成 ERC-20 形态的合约。WETH.deposit{value: 1 ether}() 让合约把 你转入的 ETH 记到 balances[msg.sender] += 1e18没有任何魔法——纯一个映射 + payable 函数。
  8. EIP-2612 permit 是什么?为什么 Uniswap V3 / 大部分新合约支持? ⇒ 用一份签名代替一次 approve tx;前端拿你签的 EIP-712 数据,连同 swap 一起塞给合约, 合约用 ecrecover 校验 → 直接更新 allowance → 然后 transferFrom。省一笔 tx

→ 这一篇答全部。


2. 它本质在做的"那一件事"

把"资产"从协议级(ETH)下放到合约级:任何人写一份合约 + 一张 balances mapping, 都能在以太坊上发行一种代币;只要遵守 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 条)

  1. ERC-20 接口 = 9 函数 + 2 事件 + 3 元数据

    函数 选择器 作用
    totalSupply() 0x18160ddd 总量
    balanceOf(addr) 0x70a08231 查余额(STATICCALL)
    transfer(to, n) 0xa9059cbb 我转给 to
    transferFrom(from, to, n) 0x23b872dd spender 调,按 allowance 扣 from
    approve(spender, n) 0x095ea7b3 我授权 spender 可花 n
    allowance(owner, spender) 0xdd62ed3e 查授权额度
    name() / symbol() / decimals() 元数据,可选 显示用

    Events: Transfer(from, to, value) / Approval(owner, spender, value)

  2. 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
    • 没 approve 就 transferFrom = revert(这就是 §1.3 我们闪电贷必须先 approve 的原因)
  3. decimals 是显示约定,链上数学全是 base units

    • USDC: 6 decimals → "100 USDC" = 100 * 10^6 = 100,000,000 base units
    • WETH: 18 decimals → "1 ETH" = 1 * 10^18 = 1,000,000,000,000,000,000 base units
    • 合约里所有计算用 base units;只在 UI 显示时除以 10^decimals。
    • 这是我们代码里 26869e6(USDC)、1e18(WETH)的来源。
  4. storage 布局可被精确计算 → dealUsdc cheatsheet(接 06 §4.4)。 USDC mapping balances 在 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)有效, 主网不可能。

  5. 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);写新合约设计交互时考虑这些边界。

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 = 26869000000 base units)
  • premium:5 bps = 26869 * 0.0005 * 1e6 = 13434500 base 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)───

关键观察

  1. 如果 T2 省略,T4 的 transferFrom 会因 allowance = 0 而 revert,整个 tx 回滚(07 §4.4), 我们白付 200k+ gas。这是为什么我们 Arbitrageur 第一行套利逻辑后必须调 approve。
  2. infinite approve 优化:如果我们 approve(POOL, type(uint256).max) 一次性,下次同一对 路径不用再 approve(减少每次一个 SSTORE)。代价:Pool 一旦被攻破,我们的 USDC 任凭它拉。 我们项目场景下 owner 信得过,且每次都精确 approve(owed) 也行(多花一点 gas 换严格控权)。
  3. 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.executeOperationIERC20(asset).approve(POOL, owed) 后由 Aave transferFrom(§6 走过一遍)
  • MultiPoolArbitrageur 多路径之间IERC20(tokenIn).approve(router, amountIn) 让 router transferFrom
  • LiquidationBot.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 → 只读、不能改 storage
  • ComparePools.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=kgetAmountOut、0.3% 手续费——所有计算输入是 base units(§4.3), 输出也是 base units;getAmountOut 接两边的 reserves(balances mapping 的两个项)和 amountIn 算出 amountOut。 → 我们 wei-exact 验证的根。

9. 自检问题(答得出才算过)

  1. "USDC 在链上"这句话准确吗?严格地说 USDC 余额"在哪"?
  2. 为什么我们 Arbitrageur 闪电贷 callback 里必须先 approve POOL,否则会发生什么?精确到选择器和 storage 改动写出 transferFrom 内部流程。
  3. USDC 是 6 decimals、WETH 是 18 decimals——getAmountOut(amountIn=1e6, reserveIn=1e9, reserveOut=1e18) 的结果在概念上有什么含义?为什么 decimal 数字不能盲目套?
  4. approve(N) → spender 花了 K (K<N) → 我想改成 M,直接 approve(M) 有什么风险?现代解法是什么?
  5. WETH 是另一种 ETH 吗?WETH.deposit{value: 1 ether}() 里 EVM 层到底发生了什么?
  6. 一个不返 bool 的代币(如 USDT)用标准 IERC20(usdt).transfer(...) 调会发生什么?OpenZeppelin 的 SafeERC20 怎么救?
  7. infinite approve 的具体风险是什么?什么场景下值得这么做、什么场景下应当精确 approve?
  8. dealUsdc cheat 在主网能复现吗?为什么?

── 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,每次铸币都检查)> AccessControlMINTER_ROLE 角色化, 可以给多签/DAO)> 单一 onlyOwner(私钥泄露=无限铸币,很多 rug pull 的机制)。

    关键结论ERC20Capped 的"总量上限"不是链给的保证,只是合约里的一行 require——如果合约是 可升级代理(proxy)、实现被换掉,这行检查可以直接消失。代币供给规则的可信度 = 这份合约代码(以及谁能升级它)的可信度,不是链本身赋予的,这跟原生币(比特币 2100 万上限、以太坊发行曲线) 写死在协议共识规则里、改需要说服全网硬分叉,是完全不同的两个可信度层级——审计报告、是否可升级、 owner 是不是多签,比"白皮书写了多少总量"更重要。