Uniswap v4

引言

0.1 DeFi 的三次范式跃迁

在理解 Uniswap v4 之前,我们必须先理解 DeFi 到底经历了什么变化。DeFi(去中心化金融)从最初的简单借贷和交易,到如今的复杂金融基础设施,其演进反映了区块链技术的成熟和用户需求的增长。

阶段 代表 本质
DeFi 1.0 Uniswap v1 / MakerDAO 单一金融产品
DeFi 2.0 Uniswap v3 / Aave v3 高效但刚性的金融协议
DeFi 3.0 Uniswap v4 金融基础设施 / 可编程内核

DeFi 1.0 时代,协议专注于解决特定问题,如 Uniswap v1 的代币交换或 MakerDAO 的稳定币借贷。这些产品虽然开创性,但功能单一,无法扩展到更复杂的金融场景。

DeFi 2.0 引入了效率提升,如 Uniswap v3 的集中流动性,允许 LP 在特定价格区间提供流动性,减少了资本浪费。然而,这些协议仍然高度刚性,所有逻辑都硬编码在合约中,难以适应市场变化或添加新功能。

现在,DeFi 3.0 以 Uniswap v4 为代表,标志着从“产品”向“基础设施”的转变。v4 不再是一个固定的 DEX,而是提供了一个可编程的内核,允许开发者通过 Hooks 自定义金融逻辑。这不仅提高了灵活性,还开启了无限的可能性,如动态费用、限价单、MEV 防护等。

V1/V2/V3 的 Uniswap,本质上都是:

“一个写死规则的交易所”

而 v4 的定位则完全不同:

“一个结算安全、行为外包的金融操作系统内核”

这种转变意味着开发者可以构建更丰富的 DeFi 应用,而无需从头开发 AMM 核心,从而加速创新并降低风险。


0.2 v4 的核心含义

v4 将 AMM 协议拆成了两层:

  • 不可变、安全、极端保守的 Core(PoolManager)
  • 完全外包、可插拔、可被替换的 Strategy(Hooks)

这种架构设计深受操作系统内核启发。PoolManager 就像 Linux 内核,负责最核心的资源管理和安全保障,确保资产结算的正确性和原子性。它不关心具体的金融逻辑是什么,只保证交易的执行是安全的。

Hooks 则类似于内核模块或插件,允许外部开发者注入自定义逻辑。这些逻辑可以是动态费用调整、限价单执行、风险控制等。通过 Hooks,v4 实现了高度的可扩展性:同一个 PoolManager 可以支持无数种不同的金融产品,而无需修改核心代码。

这是一种非常 Web2 的思想:

  • 内核 ≈ Linux Kernel
  • Hook ≈ Kernel Module / Plugin

在 Web2 中,操作系统提供基础服务,应用通过 API 扩展功能。v4 将这一理念带入 DeFi,使得协议不再是封闭的,而是开放的生态系统。开发者可以专注于创新金融产品,而 Uniswap 提供底层保障。


0.3 为什么说 v4 不是 DEX,而是「链上金融物理层」

用 v3 和 v4 做一个对比。

在 v3 时代:

  • Uniswap 是 流动性提供者
  • Router 是 交易入口
  • LP 是 被动收益者

Uniswap v3 主要关注于提供高效的代币交换服务,通过集中流动性和动态费用来优化资本利用率。LP 通过提供流动性赚取手续费,但收益受制于无常损失和市场波动。

在 v4 时代:

  • Uniswap 是 清算引擎
  • Hook 是 金融逻辑执行者
  • LP / Trader 是 系统参与者

v4 的角色发生了根本转变。它不再局限于交换功能,而是成为一个通用的金融结算平台。PoolManager 负责所有资产的原子结算,确保交易的安全性和一致性。而具体的金融行为,如限价单、杠杆交易或衍生品,则通过 Hooks 实现。

换句话说:

v4 不关心你”做什么金融产品”,
它只关心:你的资产如何安全结算。

这种设计使得 v4 成为区块链上的“金融物理层”,类似于 TCP/IP 协议栈中的网络层,提供基础传输服务,而上层应用可以无限扩展。开发者可以基于 v4 构建复杂的 DeFi 协议,如期权市场、杠杆农场或跨链桥,而无需担心底层结算的风险。


第一章:V3 的极限与不可修补性

1.1 V3 的设计成就(也是它的天花板)

Uniswap v3 的集中流动性是一次伟大的工程突破:

  • Tick-based 定价
  • 流动性区间
  • 虚拟流动性

v3 引入了 Tick 系统,将价格范围划分为离散的区间,LP 可以选择在特定区间提供流动性。这不仅提高了资本效率,还允许更精细的价格控制。虚拟流动性概念进一步优化了数学模型,使池子能够处理更大范围的价格变动。

但它有一个 致命前提

所有行为都必须被硬编码进合约

这意味着任何新功能,如动态费用或限价单,都需要通过治理或分叉来实现,导致开发周期长、风险高。

这带来了几个无法修补的问题:

❌ 1. 固定费用模型

  • 无法根据波动率、交易规模动态调整
  • LP 在高波动时被系统性剥削(IL)

固定费用无法适应市场条件,高波动期手续费可能不足以补偿 LP 的无常损失。

❌ 2. 行为不可扩展

  • 限价单?
  • TWAMM?
  • 波动率控制?

👉 只能重新 fork 一套 Uniswap

每次添加新功能都需要创建新合约,增加了碎片化和复杂性。

❌ 3. 合约碎片化严重

每个池子都是一个合约:

ETH/USDC 0.05%  → Pool A
ETH/USDC 0.3% → Pool B
ETH/DAI 0.3% → Pool C

1000 个池 = 1000 个合约

这不仅增加了部署成本,还使得跨池操作复杂,Gas 费用高昂。


第二章:Singleton —— 从”楼房集市”到”中央水管系统”

2.1 架构本质:一个合约管理一切

Uniswap v4 的核心是一个合约:

contract PoolManager { ... }

代码说明PoolManager 是 v4 的核心合约,它使用 Singleton 模式,通过一个合约实例管理所有流动性池的状态,而不是像 v3 那样为每个池子部署单独合约。这大大降低了 Gas 成本,因为避免了多次外部调用。

以下是一个简化的 PoolManager swap 方法示例:

function swap(PoolKey memory key, IPoolManager.SwapParams memory params) external returns (BalanceDelta delta) {
// 验证权限
require(msg.sender == address(router) || msg.sender == address(hooks[key.hooks]));

// 获取池子状态
PoolId id = key.toId();
PoolState storage pool = pools[id];

// 执行 AMM 计算(简化)
uint256 amountOut = calculateSwap(pool, params);

// 更新池子状态
pool.sqrtPriceX96 = newSqrtPrice;
pool.tick = newTick;

// 记录债务变化
delta = BalanceDelta({amount0: int256(params.amountIn), amount1: -int256(amountOut)});

// 调用 Hooks
if (address(key.hooks) != address(0)) {
key.hooks.afterSwap(key, params, delta);
}

return delta;
}

代码说明:这个 swap 方法展示了 v4 的核心逻辑:验证调用者、计算交换量、更新状态、记录债务,并调用 Hooks。实际代码更复杂,包括 slippage 检查和事件发射。

所有池子都变成了:

mapping(PoolId => PoolState)

代码说明:这里使用一个映射(mapping)来存储所有池子的状态,PoolId 是池子的唯一标识符,PoolState 包含池子的所有必要数据,如流动性、价格等。这种设计使得跨池操作更加高效。

Singleton 架构的核心优势在于集中管理。它允许 PoolManager 在一次交易中处理多个池子的操作,而无需外部调用。这不仅节省 Gas,还提高了交易的原子性:要么全部成功,要么全部回滚。

这就是 Singleton


2.2 PoolId:池子的”DNA”

在 v4 中,一个池子的身份不是地址,而是一个 哈希

PoolId = keccak256(PoolKey)

代码说明PoolId 通过对 PoolKey 进行 keccak256 哈希计算得到,确保每个池子有唯一的标识符。这使得池子可以动态创建,而不需要预先部署合约。

struct PoolKey {
Currency currency0;
Currency currency1;
uint24 fee;
int24 tickSpacing;
IHooks hooks;
}

代码说明PoolKey 结构体定义了池子的关键参数,包括两个代币(currency0 和 currency1)、手续费(fee)、tick 间距(tickSpacing)和 Hooks 合约地址。Hooks 地址作为参数的一部分,使得不同 Hooks 的池子被视为不同池子。

📌 关键点

Hooks 地址是 PoolKey 的一部分

这意味着:

  • ETH/USDC + 动态费率 Hook ≠ ETH/USDC + 限价单 Hook
  • 它们是 两个完全不同的池子

2.3 为什么 Singleton 会极大降低 Gas

v3 跨池 swap:

Router → Pool A (external call)
→ Pool B (external call)
→ Pool C (external call)

v4 跨池 swap:

Router → PoolManager
├─ swap(poolA)
├─ swap(poolB)
└─ swap(poolC)

📉 省掉了:

  • 外部调用成本
  • Context 切换
  • Calldata 重复解析

2.4 EIP-1153:Transient Storage 是 v4 的”黑科技核心”

什么是 Transient Storage?

  • 类似 storage

  • 但:

    • 只在当前 tx 存在
    • 不写入状态树
    • tx 结束即清空

Gas 对比:

操作 Gas
SSTORE ~20,000
TSTORE ~100

v4 用它干了什么?

  • swap 中的临时余额
  • Flash Accounting 的欠账
  • Hook 间状态传递

👉 否则 Singleton 根本跑不动

以下是使用 Transient Storage 的示例代码:

// 使用 EIP-1153 Transient Storage
bytes32 constant DELTA_SLOT = keccak256("delta");

function recordDelta(address user, address token, int256 amount) internal {
bytes32 slot = keccak256(abi.encode(user, token, DELTA_SLOT));
int256 current = int256(tload(slot));
tstore(slot, bytes32(uint256(current + amount)));
}

function getDelta(address user, address token) internal view returns (int256) {
bytes32 slot = keccak256(abi.encode(user, token, DELTA_SLOT));
return int256(tload(slot));
}

代码说明tstoretload 是 Transient Storage 的操作函数,用于记录和读取临时债务。数据只在当前交易中存在,结束后自动清空,避免了状态树的写入开销。


第三章:Flash Accounting —— 延迟支付的结算革命

3.1 传统 AMM 的致命低效

在 v3:

tokenIn.transferFrom(user, pool, amount);
tokenOut.transfer(user, amountOut);

每一步都是:

  • ERC20 call
  • balance update
  • allowance check

传统 AMM 在每次交易中都需要立即执行代币转账。这涉及到多次外部调用 ERC20 合约,检查余额和授权,导致高 Gas 成本和潜在的重入风险。此外,多次转账还可能引入不一致性,如果某一步失败,整个交易可能部分执行。


3.2 v4 的思路:先记账,后结算

v4 在 swap 过程中:

User 欠 Pool +100 USDC
Pool 欠 User -0.05 ETH

只记录在 Transient Storage 中。

v4 采用延迟结算策略:在交易执行期间,不立即转账代币,而是记录净债务变化。这利用了 Transient Storage,只在当前交易中有效,避免了状态树的写入。用户和池子之间的债务用正负数表示,确保最终净额正确。


3.3 统一结算点(Settlement Phase)

在交易结束前:

净额:
User → Pool : 100 USDC
Pool → User : 0.05 ETH

只发生一次真实转账

以下是简化的结算逻辑代码:

function settle() external {
// 计算净债务
int256 netToken0 = deltas[msg.sender][address(token0)];
int256 netToken1 = deltas[msg.sender][address(token1)];

// 执行转账
if (netToken0 > 0) {
token0.transferFrom(msg.sender, address(this), uint256(netToken0));
} else if (netToken0 < 0) {
token0.transfer(msg.sender, uint256(-netToken0));
}

if (netToken1 > 0) {
token1.transferFrom(msg.sender, address(this), uint256(netToken1));
} else if (netToken1 < 0) {
token1.transfer(msg.sender, uint256(-netToken1));
}

// 清空债务记录
delete deltas[msg.sender][address(token0)];
delete deltas[msg.sender][address(token1)];
}

代码说明settle 函数在交易结束后执行,计算并结算净债务。正数表示用户欠池子,负数表示池子欠用户。只执行必要的转账,然后清空 Transient Storage 中的记录。

所有操作完成后,PoolManager 计算净债务,并执行最终的代币转账。这减少了外部调用次数,提高了效率,并确保了原子性:如果结算失败,整个交易回滚。


3.4 Flash Accounting = 原生 Flash Loan

如果某账户在交易中出现:

-1000 USDC

只要在 settlement 前补回即可。

📌 不需要单独的 FlashLoan 合约


3.5 审计视角:Flash Accounting 的危险边界

⚠️ 关键安全原则

在 Hook 中,
任何依赖 ERC20.balanceOf 的逻辑都是错误的

原因:

  • 余额尚未结算
  • 只是”账面幻象”

典型漏洞模式

uint balance = token.balanceOf(address(this));
require(balance >= x); // ❌ 永远不可信

第四章:Hooks 的本质 ——“被允许插队的外部代码”

4.1 Hooks 不是插件,而是同步执行的一部分

很多人第一次听 Hooks,会以为它类似:

“交易完成后调用一个 callback”

这是完全错误的理解

在 Uniswap v4 中:

Hooks 是 PoolManager 交易流程的同步组成部分

也就是说:

  • Hook 不是异步
  • Hook 不能失败
  • Hook 一旦 revert,整个 swap revert

👉 从语义上看,Hook 和核心 AMM 逻辑 处于同一原子事务中

Hooks 的同步特性意味着它们与 AMM 计算紧密集成,不能独立失败。这确保了交易的完整性,但也要求 Hook 开发者小心处理错误:任何 revert 都会终止整个操作。此外,Hooks 运行在未结算状态下,必须避免依赖实时余额,以防逻辑错误。


4.2 Hooks 的真实执行位置(这个非常重要)

一个 v4 swap 的真实顺序是:

Router
└─ PoolManager.swap()
├─ beforeSwap() ← Hook
├─ AMM 核心计算
├─ afterSwap() ← Hook
└─ Flash Accounting Settlement

📌 关键事实

Hook 运行时:

  • 资产尚未真实转移
  • balanceOf 全是幻觉
  • 所有”钱”都只是记账

第五章:8 个 Hooks 的精确定义

5.1 Hook 接口总览

interface IHooks {
function beforeInitialize(...) external returns (bytes4);
function afterInitialize(...) external returns (bytes4);

function beforeAddLiquidity(...) external returns (bytes4);
function afterAddLiquidity(...) external returns (bytes4);

function beforeRemoveLiquidity(...) external returns (bytes4);
function afterRemoveLiquidity(...) external returns (bytes4);

function beforeSwap(...) external returns (bytes4);
function afterSwap(...) external returns (bytes4);
}

代码说明IHooks 接口定义了 8 个 Hook 函数,每个函数在池子生命周期的不同阶段被调用。before* 函数在操作前执行,可以修改参数或拒绝操作;after* 函数在操作后执行,用于后续处理。返回值 bytes4 是函数选择器,用于验证实现。

以下是一个简单的 Hook 实现示例,实现动态费用:

contract DynamicFeeHook is IHooks {
uint24 public dynamicFee;

function beforeSwap(PoolKey calldata key, IPoolManager.SwapParams calldata params) external returns (bytes4) {
// 根据波动率调整费用
uint256 volatility = getVolatility(key);
if (volatility > 100) {
dynamicFee = 5000; // 5%
} else {
dynamicFee = 300; // 0.3%
}
return IHooks.beforeSwap.selector;
}

function afterSwap(PoolKey calldata key, IPoolManager.SwapParams calldata params, BalanceDelta delta) external returns (bytes4) {
// 记录统计数据
emit SwapExecuted(key.toId(), params.amountIn, uint256(-delta.amount1));
return IHooks.afterSwap.selector;
}

// 其他 Hook 函数实现为默认
function beforeInitialize(...) external pure returns (bytes4) { return IHooks.beforeInitialize.selector; }
// ... 省略其他
}

代码说明:这个 Hook 在 beforeSwap 中根据波动率动态调整手续费,在 afterSwap 中发射事件记录交易。其他 Hook 函数返回默认选择器,表示不执行额外逻辑。


5.2 beforeInitialize / afterInitialize

池子出生那一刻能干什么?

beforeInitialize

调用时机:

  • PoolKey 已确定
  • PoolState 尚未写入

适合做的事:

  • 参数白名单检查
  • 禁止某些 token 组合
  • Hook 自身初始化校验
require(token0 != token1);
require(allowed[msg.sender]);

代码说明:在 beforeInitialize 中,可以使用 require 语句检查条件。例如,确保两个代币不同(避免无效池子),或检查调用者是否在允许列表中。这可以防止恶意或无效的池子创建。

参数: PoolKey calldata key - 包含池子的所有配置信息。

返回值: bytes4 - 必须是 IHooks.beforeInitialize.selector

安全注意: 不能修改 PoolKey 或写入任何状态,只能进行校验。如果校验失败,整个初始化 revert。


afterInitialize

调用时机:

  • PoolState 已创建
  • Tick = initial tick

典型用途:

  • 初始化外部仓位
  • 设置策略状态

⚠️ 审计点

  • 是否偷偷铸币
  • 是否外部转账

代码说明afterInitialize 在池子状态创建后调用,可以执行初始化逻辑,如设置外部合约的状态或铸造初始代币。

参数: PoolKey calldata key - 池子配置。

返回值: bytes4 - 必须是 IHooks.afterInitialize.selector

安全注意: 可以执行外部调用,但需小心重入。审计时检查是否滥用权限,如铸造代币给攻击者。


5.3 beforeAddLiquidity / afterAddLiquidity

LP 的”入场安检”


beforeAddLiquidity

非常危险的 Hook 点

可以:

  • 拒绝 LP(KYC / NFT Gate)
  • 设置 LP 锁仓条件
  • 动态调整可存入范围
require(block.timestamp >= unlockTime[msg.sender]);

代码说明:这个 Hook 可以根据条件拒绝流动性添加。例如,检查用户是否持有特定 NFT,或时间锁仓到期。

参数: PoolKey calldata key, IPoolManager.ModifyLiquidityParams calldata params - 池子配置和流动性参数。

返回值: bytes4 - 必须是 IHooks.beforeAddLiquidity.selector

安全注意: 过度限制可能导致 LP 无法退出。恶意 Hook 可永久锁仓资金。

🚨 攻击面

  • 恶意 Hook 永久阻止 LP 取钱
  • Hook 利用 revert 实现软 Rug

afterAddLiquidity

最常见的”吸血 Hook”发生地

可以:

  • 把 LP 的流动性转去 ERC-4626
  • 铸奖励代币
  • 建立二次衍生仓位

但也可以:

token.transfer(owner, amount); // 合法但恶意

代码说明:在流动性添加后,可以执行奖励逻辑,如铸造治理代币或存入收益农场。

参数: PoolKey calldata key, IPoolManager.ModifyLiquidityParams calldata params, BalanceDelta delta - 包括流动性变化。

返回值: bytes4 - 必须是 IHooks.afterAddLiquidity.selector

安全注意: 检查是否转移 LP 资金到外部合约。审计奖励机制是否公平。

📌 协议不会阻止你

v4 的设计哲学:
不限制作恶,只保证结算安全


5.4 beforeRemoveLiquidity / afterRemoveLiquidity

“你以为你能走?”


beforeRemoveLiquidity

常见用途:

  • 提前退出罚金
  • 强制冷却期
  • LP 生命周期控制

🚨 高风险设计

  • Hook 设置一个永远不满足的条件
  • LP 永久被锁死

代码说明:可以实施退出限制,如收取罚金或要求等待期。

参数: PoolKey calldata key, IPoolManager.ModifyLiquidityParams calldata params - 流动性移除参数。

返回值: bytes4 - 必须是 IHooks.beforeRemoveLiquidity.selector

安全注意: 确保条件合理,否则 LP 资金被锁。检查是否与治理结合使用。


afterRemoveLiquidity

可以:

  • 清算外部仓位
  • 计算最终收益

❌ 但如果 Hook 写成:

revert("Not allowed");

LP 永远拿不回钱

代码说明:在移除后,可以执行清理逻辑,如关闭外部仓位。

参数: PoolKey calldata key, IPoolManager.ModifyLiquidityParams calldata params, BalanceDelta delta - 包括移除的流动性。

返回值: bytes4 - 必须是 IHooks.afterRemoveLiquidity.selector

安全注意: 不能 revert 来阻止移除,只能执行后续逻辑。审计外部调用是否安全。


5.5 beforeSwap —— v4 中最强、最危险的 Hook


beforeSwap 能干什么?

  • 修改 fee
  • 拒绝交易
  • 插入限价单
  • 执行 TWAMM
  • MEV 防护
  • Oracle 校验
if (volatility > threshold) {
feeBps = highFee;
}

代码说明:Hook 可以动态调整手续费,或实现复杂逻辑如限价单。

参数: PoolKey calldata key, IPoolManager.SwapParams calldata params - 交换参数。

返回值: bytes4 - 必须是 IHooks.beforeSwap.selector

安全注意: 不能依赖未更新的状态。修改参数需谨慎。


beforeSwap 不能假设什么?

balanceOf
❌ 交易最终价格
❌ Tick 一定会移动
❌ 交易一定完成


经典攻击模式 ①:价格幻觉攻击

uint price = getPriceFromPoolState();
require(price > x);

但:

  • swap 尚未发生
  • Tick 尚未更新

👉 逻辑永远是错的


5.6 afterSwap ——“收尾,但不是结算”

afterSwap 执行时:

  • AMM 状态已更新
  • 钱仍未结算
  • Flash Accounting 仍在

适合做:

  • 统计
  • 触发器
  • 风险监控

❌ 不适合:

  • 再次 swap(可能 reentrancy)
  • 依赖真实余额

代码说明:用于记录统计或触发外部事件。

参数: PoolKey calldata key, IPoolManager.SwapParams calldata params, BalanceDelta delta - 包括交换结果。

返回值: bytes4 - 必须是 IHooks.afterSwap.selector

安全注意: 避免重入。不能修改结算。


第六章:Hooks 的安全模型 ——“信任的彻底转移”

6.1 v3 vs v4 信任模型对比

v3 v4
用户信任 协议 协议 + Hook
LP 风险 IL IL + Hook 风险
攻击面 固定 无限扩展

6.2 PoolManager 的责任边界

PoolManager 只保证:

  • 资产不会被直接偷走
  • 结算规则不被破坏
  • 池子之间不互相污染

它不保证:

  • Hook 是善意的
  • 策略是合理的
  • LP 能拿回钱

第七章:Hooks 的 6 类典型漏洞

7.1 重入(Reentrancy)

  • Hook → 外部协议 → 回调 → PoolManager

⚠️ Flash Accounting 状态未结算


7.2 依赖未结算余额

token.balanceOf(address(this))

永远不可信


7.3 永久锁仓(Soft Rug)

  • beforeRemoveLiquidity 永远 revert
  • afterRemoveLiquidity 吞钱

7.4 恶意 fee / 滑点操纵

  • beforeSwap 动态提高 fee
  • 针对特定地址歧视

7.5 Oracle 操纵

  • 使用弱 TWAP
  • 使用可被操纵的外部价格

7.6 Hook 升级风险

  • 可升级 Hook = 治理后门
  • LP 资产暴露在治理攻击中

关于 Uniswap v4 攻击模型

第一章:v4 攻击面的根本变化

1.1 从协议漏洞到协议允许的作恶

在 Uniswap v2 / v3 的时代,安全模型非常清晰:

  • 协议逻辑固定
  • 行为空间受限
  • 攻击 ≈ 利用 bug 或数学边界

换句话说:

只要协议代码没 bug,用户基本安全


v4 的范式转移

Uniswap v4 的核心改变并不在于 AMM 数学,而在于权力下放

  • 核心 AMM 逻辑被极度压缩(PoolManager)
  • 几乎所有”策略性行为”被移交给 Hook

这意味着:

“是否安全”不再是 Uniswap 决定的,而是 Hook 作者决定的


攻击不再需要违反协议假设

在 v4 中,以下行为:

  • 阻止用户移除流动性
  • 对某些用户收取极高费用
  • 在 swap 后抽取资产
  • 在特定条件下 revert 交易

全部是合法行为

⚠️ 合法 ≠ 安全

1.2 攻击者模型的根本变化

传统 DeFi 攻击者:

  • 外部套利者
  • 闪电贷操作者
  • MEV bot

v4 新增攻击者:

1️⃣ Hook 作者(最强)

Hook 作者拥有:

  • 对所有 swap / mint / burn 的同步控制权
  • 可访问未结算状态
  • 可执行外部调用

这在安全模型中,等价于半个协议管理员


2️⃣ 治理攻击者

如果 Hook 可升级:

function upgradeHook(address newHook) external onlyGov;

那么治理被攻破 ≈ 池子被完全接管。


3️⃣ 项目方 Rug

最危险的一类:

  • 初期 Hook 看似无害
  • TVL 上来后升级 Hook
  • LP 被锁、被抽血、无法逃离

📌 这在 v4 中不需要任何漏洞


第二章:Flash Accounting —— 所有错觉的源头

2.1 Flash Accounting 的真实含义

在 v4 中,Uniswap 为了极致 gas 优化,引入:

延迟结算模型(Deferred Settlement)

整个交易流程中:

Swap / Mint / Burn

Hook 回调

Hook 回调

……

统一结算

期间:

mapping(address token => mapping(address user => int256 delta));

代码说明:这个映射记录了每个用户对每个代币的净债务或债权。int256 delta 可以是正数(欠款)或负数(债权),在交易结束时统一结算,避免了多次转账调用。


2.2 关键安全误区:余额 ≠ 状态

几乎所有 DeFi 开发者都会犯这个错:

uint bal = token.balanceOf(address(this));

在 v4 中:

  • bal 是旧余额
  • 不反映 swap/mint/burn 的任何变化

2.3 攻击路径:基于余额检查的逻辑绕过

场景假设

Hook 设计者想限制 swap 后某个条件:

function afterSwap(...) {
require(token.balanceOf(address(this)) >= threshold);
}

攻击流程

  1. 攻击者发起 swap
  2. swap 尚未结算
  3. afterSwap 中 balance 未变
  4. require 被绕过
  5. 结算发生

📌 没有重入
📌 没有 bug
📌 只是时间认知错误

以下是易受攻击的 Hook 代码示例:

function afterSwap(PoolKey calldata key, IPoolManager.SwapParams calldata params, BalanceDelta delta) external {
uint256 balance = IERC20(token).balanceOf(address(this));
require(balance >= minReserve, "Insufficient reserve");

// 执行奖励逻辑
rewardToken.mint(msg.sender, rewardAmount);
}

代码说明:这个 Hook 错误地使用了 balanceOf,在 Flash Accounting 下余额尚未更新,导致条件检查失效,可能允许不当的奖励发放。


2.4 Flash Accounting + 回调的致命组合

当 Hook 中出现:

ERC777(token).transfer(...)

就会发生:

  1. Hook 执行
  2. ERC777 回调
  3. 回调中再次触发 PoolManager
  4. 原交易尚未结算

结果:

多个逻辑层认为自己在”第一层执行”


第三章:Hook 主导型攻击(核心)

3.1 为什么 Hook 是最强攻击向量

Hook 的权限本质是:

对 AMM 状态机的同步中断权

它可以:

  • 拒绝任何状态转换
  • 修改参数
  • 执行外部逻辑

3.2 永久锁仓攻击(LP 视角)

恶意 Hook

function beforeRemoveLiquidity(...) external returns (bytes4) {
revert("Liquidity removal disabled");
}

代码说明:这个 Hook 函数通过 revert 永久阻止流动性移除,导致 LP 无法取回资金。这是一种”软 Rug”攻击,利用协议允许的合法行为实现恶意目的。

后果分析

  • LP 无法退出
  • 无治理兜底
  • 无 emergency withdraw

📌 这是协议设计允许的行为


3.3 afterAddLiquidity 抽血模型

function afterAddLiquidity(...) external {
uint skim = computeSkim();
token0.transfer(owner, skim);
}

为什么危险?

  • LP 看到的池子参数是正常的
  • 资金流失是渐进的
  • 前端无法显示真实 APR

3.4 歧视性 Fee 攻击(MEV 合谋)

if (tx.origin == routerX) {
feeBps = 5000;
}

攻击特性

  • 针对聚合器
  • 针对大额用户
  • 普通用户不触发

📌 极难被发现
📌 极适合 MEV 合谋


第四章:Hook × Oracle × 清算 —— 资金清空级攻击模型

4.1 攻击背景:为什么 v4 特别容易被”假价格”击穿

在 v2 / v3 中:

  • Oracle ≈ AMM 本身
  • 清算价格来源单一
  • 价格异常 ≈ 池子异常

在 v4 中:

  • Oracle 通常来自外部模块

  • Hook 可以修改:

    • 是否允许清算
    • 价格 fallback 行为
    • 清算激励结构

👉 价格、清算、权限被解耦


4.2 致命设计模式:price == 0 → price = 1

常见代码

(uint price, uint updatedAt, bool allowLiquidations) = getCollateralPrice();

if (price == 0) {
price = 1; // 防止除零
}

开发者的直觉是:

“价格为 0 不可能真实存在,
我只是防止 revert。”

但在清算逻辑中:

price 是除数,而不是显示值


4.3 清算公式在低价下的数学灾难

uint collateralRewardValue =
repayAmount * (10000 + liqIncentiveBps) / 10000;

uint internalCollateralReward =
collateralRewardValue * 1e18 / price;

代码说明:这段代码计算清算奖励。首先计算带激励的偿还价值,然后除以价格得到内部抵押奖励。当 price = 1 时,除法结果极大,可能导致攻击者获得全部抵押。

price = 1 时:

  • internalCollateralReward ≈ repayAmount × 1e18
  • 即:repay 单位债务,按”1 wei = 1 whole collateral”结算

随后:

internalCollateralReward =
min(internalCollateralReward, collateralBalance);

➡️ 单次极小 repay,直接吃掉全部抵押


4.4 完整攻击线路

攻击前提

  1. Oracle 极度过时(staleness)
  2. price 被 Hook / Oracle fallback 改为 1
  3. allowLiquidations == true
  4. 清算公式未设置下限保护

攻击步骤

1️⃣ 等待 Oracle 进入极度过期状态

updatedAt << block.timestamp - STALENESS_UNWIND_DURATION

2️⃣ 调用 liquidate(),repay 极小金额

liquidate(
borrower,
repayAmount = 1,
minCollateralOut = 0
);

3️⃣ 内部计算

internalCollateralReward ≈ 1e18
→ min(collateralBalance)
→ 全部抵押

4️⃣ 攻击者获得全部抵押

5️⃣ 只偿还极少债务

第五章:Hook × Flash Accounting —— 状态错觉攻击

v4 中最容易被忽视、但最普遍的攻击模型


5.1 攻击核心:Hook 运行在”未结算世界”

在 v4 中,Hook 执行时:

  • swap 已”逻辑发生”
  • token 尚未转账
  • balanceOf 是旧值

👉 Hook 看到的是”平行宇宙”


5.2 典型错误代码

function afterSwap(...) external {
uint bal = token.balanceOf(address(this));
require(bal >= minRequired);
}

开发者误以为:

“swap 已完成”

但事实是:

只是 AMM 状态推进了,钱还没动


5.3 攻击模型:条件绕过型抽血

攻击流程

  1. Hook 设定一个 balance 门槛

  2. 攻击者构造 swap

  3. afterSwap 中:

    • balance 未变
    • 条件通过
  4. Hook 执行奖励 / 转账逻辑

  5. 最后统一结算

📌 Hook 自己骗了自己


5.4 Flash Accounting + ERC777 的复合地狱

危险代码

IERC777(token).transfer(to, amount);

代码说明:使用 ERC777 代币的 transfer 函数会触发接收者的回调函数。如果回调中再次调用 PoolManager,可能导致重入攻击,尤其在 Flash Accounting 未结算的状态下。

ERC777 会:

  • 在 transfer 中回调接收方
  • 回调中可再次调用 PoolManager

结果:

Hook 在一个尚未结算的交易里,被多次重入

而开发者通常认为:

“我没有 external call 到 PoolManager”

但事实是:

你调用的 token 帮你 call 了


5.5 攻击效果

  • 状态错乱
  • delta 累加异常
  • fee 计算被绕
  • LP 资产被重复计算

📌 不是传统 reentrancy
📌 是 v4 独有的时间错位攻击