前置知识:前两节已建立恒定乘积公式
x × y = k的数学直觉,并实现流动性池合约的addLiquidity/removeLiquidity接口与 LP token 发行机制。读者已掌握流动性池的基本运作原理。本节承接:19.3 实现核心 Swap 逻辑,19.4 讨论价格预言与闪电贷防御。
19.3 代币交换(Swap)与手续费用
自动做市商(AMM)的核心功能是让用户在不依赖订单簿的情况下,直接与流动性池进行代币交换。本节从恒定乘积公式出发,推导含手续费的 Swap 数学原理,并给出完整的 Solidity 实现。
19.3.1 恒定乘积公式在 Swap 场景下的变体
回忆 19.1 中建立的恒定乘积公式:
其中 x 和 y 分别是池中两种代币的储备量,k 为常数。该公式的几何直观是一条双曲线:池子越深(TVL 越大),曲线越平缓,单笔交易的滑点越低。
无手续费的理想 Swap:假设用户向池子输入 Δx 个代币 X,期望获得 Δy 个代币 Y。交换完成后,池中 X 的储备变为 x + Δx,Y 的储备变为 y - Δy。由于乘积恒定:
代入 k = x \times y 可解出 Δy:
数值演示:假设池中有 100 ETH 和 300,000 USDC(x=100, y=300000, k=30,000,000)。用户输入 Δx=1 ETH:
用户获得约 2,970.30 USDC。交换后新储备:x'=101, y'=297029.70, k'=101 × 297029.70 ≈ 30,000,000,k 保持不变。
19.3.2 手续费(0.3%)的数学处理与池子增值效应
在 Uniswap V2 中,每笔 Swap 收取 0.3% 的手续费。手续费不直接分配给流动性提供者(LP),而是留在池中,使恒定乘积常数 k 缓慢增大——这是 AMM 最核心的自动复利设计。
含手续费的恒定乘积更新公式:实际进入池子的有效输入为 Δx × (1 - f),其中 f = 0.003。交易后更新的恒定乘积为:
从该公式可解出含手续费的 Δy:
手续费进入池子后,新的恒定乘积大于原值:
数值验证:仍用上面 100 ETH / 300,000 USDC 的池子。用户输入 Δx=1 ETH,有效输入为 1 × (1 - 0.003) = 0.997 ETH:
对比无手续费场景(2,970.30 USDC),含手续费的输出略少(2,970.89 USDC 中的差值即手续费部分)。交换后 k' = 101 × 297029.11 ≈ 30,000,940,k 从 30,000,000 增大到约 30,000,940 —— 这 940 的增量即是 LP 全体共享的增值。
连续多笔交易后,k 单调增长,每个 LP token 对应的底层资产价值也随之增加。
19.3.3 价格冲击与滑点
价格冲击(Price Impact):单笔大额交易导致 AMM 曲线上即时价格偏移的幅度。数学定义为:
价格冲击与池子深度(TVL)成反比——小池子里一笔不大的交易就可能产生显著的冲击。
滑点(Slippage):交易执行价与用户下单时预期价之间的偏差。滑点有两个来源:一是价格冲击(数学必然),二是区块确认延迟期间被其他交易(如 MEV 三明治攻击)抢先导致价格偏移。
- 前端滑点设置档位:0.5%、1%、2% 等,用户根据交易紧急程度选择
- 为什么小池子(低 TVL)滑点更大:因为同样的
Δx在低 TVL 池中占比更大,价格冲击更剧烈
19.3.4 滑点保护的合约实现
Uniswap V2 风格的 Swap 函数接口:
function swapExactTokensForTokens(
uint256 amountIn,
uint256 amountOutMin,
address[] calldata path,
address to,
uint256 deadline
) external returns (uint256[] memory amounts);两层保护机制:
- 最小输出保护:
require(actualAmountOut >= amountOutMin, "INSUFFICIENT_OUTPUT_AMOUNT")—— 如果实际获得的代币少于用户设定的最低值,整笔交易回滚 - 过期保护:
require(block.timestamp <= deadline, "EXPIRED")—— 如果交易在截止时间后被执行,回滚
sequenceDiagram
participant User as 用户
participant Router as 路由合约
participant Pair as Pair池合约
participant Token0 as 代币X
participant Token1 as 代币Y
User->>Router: swapExactTokensForTokens(amountIn, amountOutMin, path, to, deadline)
Router->>Router: 验证deadline未过期
Router->>Token0: transferFrom(user, pair, amountIn)
Router->>Pair: swap(amountOut, to, data)
Pair->>Pair: 计算feeAmount = amountIn * 0.003
Pair->>Pair: 计算actualOut = getAmountOut(amountIn - feeAmount, reserve0, reserve1)
Pair->>Pair: require(actualOut >= amountOutMin)
Pair->>Pair: 更新reserve0, reserve1
Pair->>Token1: transfer(to, actualOut)
Pair->>Pair: 触发Swap事件
Pair-->>Router: 返回actualOut
Router-->>User: 返回输出量
19.3.5 完整 Solidity 实现
以下是含 0.3% 手续费与滑点保护的 Swap 核心函数实现:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SimplePair {
IERC20 public token0;
IERC20 public token1;
uint256 public reserve0;
uint256 public reserve1;
uint256 public kLast; // 记录上次更新时的 k 值
event Swap(
address indexed sender,
uint256 amount0In,
uint256 amount1In,
uint256 amount0Out,
uint256 amount1Out,
address indexed to
);
constructor(address _token0, address _token1) {
token0 = IERC20(_token0);
token1 = IERC20(_token1);
}
function _update(uint256 bal0, uint256 bal1) private {
reserve0 = bal0;
reserve1 = bal1;
}
/// @dev 安全乘除,返回 (x * y) / denominator,无溢出
function _mulDiv(uint256 x, uint256 y, uint256 denominator) internal pure returns (uint256) {
return (x * y) / denominator; // Solidity 0.8+ 自动溢出回滚
}
/// @dev 给定输入量,计算含 0.3% 手续费的输出量
function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut)
public
pure
returns (uint256 amountOut)
{
require(amountIn > 0, "INSUFFICIENT_INPUT_AMOUNT");
require(reserveIn > 0 && reserveOut > 0, "INSUFFICIENT_LIQUIDITY");
uint256 amountInWithFee = amountIn * 997; // 997/1000 = 1 - 0.003
uint256 numerator = amountInWithFee * reserveOut;
uint256 denominator = reserveIn * 1000 + amountInWithFee;
amountOut = numerator / denominator;
}
/// @dev 执行代币交换
function swap(uint256 amount0Out, uint256 amount1Out, address to) external {
require(amount0Out > 0 || amount1Out > 0, "INSUFFICIENT_OUTPUT_AMOUNT");
require(amount0Out < reserve0 && amount1Out < reserve1, "INSUFFICIENT_LIQUIDITY");
// 读取当前余额(快照模式)
uint256 balance0 = token0.balanceOf(address(this));
uint256 balance1 = token1.balanceOf(address(this));
// 检查 k 值是否单调递增(防操纵保护)
if (kLast > 0) {
uint256 currentK = balance0 * balance1;
require(currentK >= kLast, "K");
}
kLast = balance0 * balance1;
// 发送输出代币
if (amount0Out > 0) _safeTransfer(token0, to, amount0Out);
if (amount1Out > 0) _safeTransfer(token1, to, amount1Out);
// 计算实际输入(通过余额差推断)
balance0 = token0.balanceOf(address(this));
balance1 = token1.balanceOf(address(this));
uint256 amount0In = balance0 > reserve0 - amount0Out ? balance0 - (reserve0 - amount0Out) : 0;
uint256 amount1In = balance1 > reserve1 - amount1Out ? balance1 - (reserve1 - amount1Out) : 0;
require(amount0In > 0 || amount1In > 0, "INSUFFICIENT_INPUT_AMOUNT");
// 更新储备量
_update(balance0, balance1);
emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
}
function _safeTransfer(IERC20 token, address to, uint256 value) private {
(bool success, bytes memory data) = address(token).call(
abi.encodeWithSelector(token.transfer.selector, to, value)
);
require(success && (data.length == 0 || abi.decode(data, (bool))), "TRANSFER_FAILED");
}
}swapExactTokensForTokens 路由函数(Router 合约中):
function swapExactTokensForTokens(
uint256 amountIn,
uint256 amountOutMin,
address[] calldata path,
address to,
uint256 deadline
) external returns (uint256[] memory amounts) {
require(block.timestamp <= deadline, "EXPIRED");
amounts = new uint256[](path.length);
amounts[0] = amountIn;
for (uint256 i = 0; i < path.length - 1; i++) {
(address input, address output) = (path[i], path[i + 1]);
(address token0, ) = sortTokens(input, output);
SimplePair pair = SimplePair(getPair(input, output));
(uint256 reserve0, uint256 reserve1) = pair.getReserves();
(uint256 reserveIn, uint256 reserveOut) = input == token0
? (reserve0, reserve1) : (reserve1, reserve0);
amounts[i + 1] = pair.getAmountOut(amounts[i], reserveIn, reserveOut);
require(amounts[i + 1] >= amountOutMin, "INSUFFICIENT_OUTPUT_AMOUNT");
(uint256 amount0Out, uint256 amount1Out) = input == token0
? (uint256(0), amounts[i + 1]) : (amounts[i + 1], uint256(0));
pair.swap(amount0Out, amount1Out, to);
}
}19.3 要点总结:
- AMM 的 Swap 基于恒定乘积公式,手续费
f=0.003后k单调递增,LP 被动受益- 滑点保护通过
minAmountOut(合约层)和deadline(时间层)双层防护getAmountOut算法使用997/1000的比例因子,将手续费处理嵌入输出计算
19.4 价格预言与闪电贷安全预防
在 DeFi 中,价格预言机(Price Oracle)是最关键也最脆弱的安全环节。本节从闪电贷攻击案例出发,推导时间加权平均价(TWAP)作为防御手段的原理与最小实现。
19.4.1 内部价格为何可被操纵
理解闪电贷:闪电贷(Flash Loan)无需抵押,只要在同一笔交易中归还即可。它让任何人瞬间借入巨额资金。
攻击链还原:
sequenceDiagram
participant Attacker as 攻击者
participant FlashLoan as 闪电贷协议
participant PoolA as AMM池A
participant Target as 目标协议(借贷)
Attacker->>FlashLoan: 闪电贷借入1000万tokenA
FlashLoan-->>Attacker: 获得1000万tokenA
Attacker->>PoolA: 大幅swap A→B(价格扭曲100x)
PoolA-->>Attacker: 获得B(此时PoolA价格严重偏离)
Attacker->>Target: 调用借贷/清算函数,读取PoolA瞬价
Target->>PoolA: getReserves() → 读取被扭曲的价格
Target-->>Attacker: 允许超额借贷/触发不合理的清算
Attacker->>PoolA: 反向swap B→A(恢复价格)
Attacker->>FlashLoan: 归还1000万tokenA + 手续费
Attacker-->>Attacker: 净赚利润(Gas成本之外)
攻击者仅需支付 Gas 费和一次 Swap 手续费。无本金门槛即可对大资金协议发起攻击。
真实案例:
- Cream Finance(2021年):闪电贷操纵 AMP 价格,盗取约 1.3 亿美元
- bZx(2020年):闪电贷组合攻击,利用合成资产借贷协议的预言机依赖
- Harvest Finance(2020年):通过 QuickSwap 价格操纵盗取 2400 万 USDC
19.4.2 防御的第一原则:不使用单池瞬价
单池瞬价(Spot Price) 的脆弱性在于它只代表当前时刻单个 AMM 池的价格。一次大额交易即可将其推至极端值。
防御策略分层(从弱到强):
| 方案 | 操纵成本 | 延迟 | 准确性 | 外部依赖 |
|---|---|---|---|---|
| 单池瞬价 | 极低(一次交易) | 无 | 高 | 无 |
| 多池加权 | 中等(需同时操纵多池) | 低 | 较高 | 无 |
| TWAP(本章实现) | 高(需多区块维持) | 中等 | 中 | 无 |
| Chainlink 外源预言机 | 极高(需攻击链下数据源) | 低 | 高 | 有 |
为什么 TWAP 是本章的最佳选择:TWAP 无需外部依赖,直接在 AMM 合约内部实现,成本可控,攻击经济门槛提升显著。
19.4.3 TWAP 的数学原理
连续时间定义:时间加权平均价是对价格函数在时间区间上的积分平均:
离散区块实现:区块链上无法连续积分,因此采用累计价格方案——在每个区块更新时对价格做时间累加。
Uniswap V2 的累计价格机制:
每当储备量变更(swap / mint / burn)时,更新累计价格:
其中 reserve1/reserve0 = y/x 是更新前的即时价格比,Δt = blockTimestamp - blockTimestampLast 是自上次更新以来的秒数。
查询 TWAP:给定一个时间区间 [t1, t2]:
为什么 TWAP 是安全的:攻击者若要在 TWAP 上制造足够的价格偏移,必须在多个连续区块中维持扭曲的价格。操纵成本估算:
例如:要在一个 30 区块(~6 分钟)的时间窗口内保持 10% 的价格偏差,池子 TVL 为 1 亿美元,成本约为 0.1 × 1亿 × 30 = 3亿(需持续输入/输出资金维持偏离)。相比之下,单区块瞬价操纵的成本仅为一个区块的 Swap 手续费。
19.4.4 TWAP 的局限性与折中
- 时滞性:TWAP 反映的是历史平均价。在高波动行情中,链上价格已剧烈变化,但 TWAP 仍停留在旧区间,可能产生延迟套利机会
- 适用范围:Uniswap V2 默认不建议用 TWAP 做抵押品定价;更适合做低风险场景的参考价或安全检查门(如保证清算价格不低于 n 区块平均价)
- 替代方案:Uniswap V3 的 TWAP Oracle 支持从出块者那里注入更精确的价格,但复杂度更高;主流协议多采用 Chainlink 为主、TWAP 为辅的混合方案
19.4.5 最小代码实现
在 Pair 合约的每次状态变更后调用 _update,累计时间加权价格:
// ==== 状态变量 ====
uint256 public price0Cumulative;
uint256 public price1Cumulative;
uint32 public blockTimestampLast;
/// @dev UQ112x112 定点数缩放因子
uint256 private constant Q112 = 2**112;
// ==== 核心更新函数 ====
function _update(uint256 balance0, uint256 balance1, uint32 _reserve0, uint32 _reserve1) private {
// 计算自上次更新以来的时间差(秒)
uint32 blockTimestamp = uint32(block.timestamp % 2**32);
uint32 timeElapsed = blockTimestamp - blockTimestampLast; // 首次可为0
if (timeElapsed > 0 && _reserve0 > 0 && _reserve1 > 0) {
// 累计价格更新(UQ112x112 精度)
// price0Cumulative: 以 token1 计价的 token0 价格的累计和
price0Cumulative += uint256(_reserve1) * timeElapsed / _reserve0;
// price1Cumulative: 以 token0 计价的 token1 价格的累计和
price1Cumulative += uint256(_reserve0) * timeElapsed / _reserve1;
}
reserve0 = balance0;
reserve1 = balance1;
blockTimestampLast = blockTimestamp;
}
// ==== 查询函数(供外部合约调用)====
function getReservesAndCumulative()
external
view
returns (
uint256 _reserve0,
uint256 _reserve1,
uint256 _price0Cumulative,
uint256 _price1Cumulative,
uint32 _blockTimestampLast
)
{
return (reserve0, reserve1, price0Cumulative, price1Cumulative, blockTimestampLast);
}
/// @dev 外部合约调用:计算指定周期的 TWAP
/// @param cumulativeStart 周期起始时的 priceCumulative
/// @param cumulativeNow 周期结束时的 priceCumulative
/// @param period 周期时长(秒)
/// @return twapPrice 时间加权平均价
function consult(
uint256 cumulativeStart,
uint256 cumulativeNow,
uint256 period
) external pure returns (uint256 twapPrice) {
require(period > 0, "PERIOD_ZERO");
twapPrice = (cumulativeNow - cumulativeStart) / period;
}精度说明:使用 UQ112x112 定点数格式(放大 2^112 倍存储),避免整数除法的小数截断误差。当 reserve0 和 reserve1 都使用 uint112 类型时,乘积 reserve1 * timeElapsed 最大值仍在安全范围内,reserve1 / reserve0 的小数部分通过前移 112 位保留。
flowchart TD
A[swap/mint/burn 触发] --> B{状态变更发生?}
B -->|是| C[读取 block.timestamp]
C --> D[计算 timeElapsed = now - blockTimestampLast]
D --> E{timeElapsed > 0 && 有储备量?}
E -->|是| F[price0Cumulative += reserve1 * dt / reserve0]
F --> G[price1Cumulative += reserve0 * dt / reserve1]
G --> H[更新 reserve0, reserve1]
H --> I[更新 blockTimestampLast = now]
E -->|否| H
I --> J[结束]
19.4 要点总结:
- 闪电贷让单笔交易即可操纵 AMM 瞬价,但 TWAP 要求攻击者维持多区块扭曲,经济门槛呈线性增长
- 累计价格机制(
priceCumulative)是 Uniswap V2 的精华设计,实现了零外部依赖的去中心化价格预言- TWAP 并非万能:时滞性使其不适合高频定价场景,应与外源预言机组合使用
本章小结(19.3 ~ 19.4)
- 手续费使
k缓慢增大,LP 被动获益:AMM 的手续费不直接分配,而是通过增大池子恒乘积常数实现 LP token 的增值,这是 Uniswap V2 最核心的自动复利设计。
- 滑点保护是合约层与前端层的双重防线:合约层用
minAmountOut做硬性回滚,前端层帮用户计算合理的滑点容忍值并设置deadline,二者缺一不可。
- 单池瞬价不可信,时间维度是最好的骑兵:闪电贷使资本瞬间放大成为可能,但时间是区块链的天然屏障——TWAP 通过将价格评估从「瞬间」拉长到「区间」,将攻击成本从一次交易扩展到持续多区块的资本占用,从而大幅提升操纵的经济门槛。
下一节预告:19.5 将基于本章的 Swap 与 TWAP 逻辑,构建前端交易面板,完成钱包连接与 Swap UI。
评论
0评论加载中…