3. Uniswap V2实战:核心合约架构、添加/移除流动性、Swap交易流程、TWAP预言机
Uniswap V2,可以说是DeFi世界里的一个里程碑。我当年第一次看它的合约代码时,说实话,被那种极简的设计震撼到了——整个核心逻辑就两个合约,却能支撑起数百亿美金的交易量。今天我们就来拆解它,从架构到实战,把每个环节都讲透。
核心要点:Uniswap V2的核心是常数乘积做市商模型(x*y=k),所有操作都围绕这个公式展开。理解了这个,你就理解了V2的魂。
3.1 核心合约架构
Uniswap V2由两个核心合约组成:UniswapV2Pair 和 UniswapV2Factory。说白了,Factory是工厂,负责创建Pair;Pair是交易对,负责实际做市。
我画了一张架构图,帮你理清它们的关系:
每个Pair合约管理一个交易对,比如ETH/USDC。它内部维护了两个关键变量:reserve0 和 reserve1,分别对应两种代币的库存。嗯,这里要注意——reserve的顺序和代币地址的排序有关,不是你想怎么放就怎么放的。
我的经验:我在做流动性管理时,经常需要同时监控几十个Pair。我习惯写一个脚本,通过Factory的 allPairs() 函数遍历所有Pair,然后批量获取reserve数据。这样比一个个查快多了。
3.2 添加/移除流动性
添加流动性,说白了就是你把两种代币按比例存入Pair,然后获得LP Token作为凭证。移除流动性就是反向操作——销毁LP Token,拿回代币。
3.2.1 添加流动性流程
核心函数是 mint()。但注意,用户不会直接调它,而是通过Router合约来调。Router会先帮用户算好应该存多少代币,再调Pair的mint。
// 简化版添加流动性逻辑
function addLiquidity(
address tokenA,
address tokenB,
uint amountADesired,
uint amountBDesired
) external returns (uint liquidity) {
// 1. 先通过Factory获取Pair地址
address pair = factory.getPair(tokenA, tokenB);
// 2. 计算实际应存入的数量
(uint reserveA, uint reserveB) = getReserves(pair);
uint amountBOptimal = quote(amountADesired, reserveA, reserveB);
// 3. 转账代币到Pair
transfer(tokenA, pair, amountADesired);
transfer(tokenB, pair, amountBOptimal);
// 4. 调用Pair的mint,铸造LP Token
liquidity = IPair(pair).mint(msg.sender);
}
这里有个关键点:滑点保护。你想想看,如果你提交交易时池子里的比例是1:1,但等你交易上链时比例变了,那你的资金就会受损。所以Router合约里有个 amountAMin 和 amountBMin 参数,用来设置你能接受的最小数量。
避坑指南:我曾经在添加流动性时,因为没设置滑点保护,结果遇到MEV机器人抢跑,硬生生多付了5%的代价。从那以后,我每次都会设置至少0.5%的滑点容忍度。
3.2.2 移除流动性流程
移除流动性调用的是 burn() 函数。它会销毁你的LP Token,然后按比例返还两种代币。
// 移除流动性
function removeLiquidity(
address tokenA,
address tokenB,
uint liquidity
) external returns (uint amountA, uint amountB) {
address pair = factory.getPair(tokenA, tokenB);
// 1. 先把LP Token转到Pair合约
transfer(pair, liquidity);
// 2. 调用Pair的burn
(amountA, amountB) = IPair(pair).burn(msg.sender);
}
这里有个细节:burn() 函数会先计算你持有的LP Token占总供应量的比例,然后按这个比例从reserve中取出对应数量的代币返还给你。比例公式是:amount = liquidity / totalSupply * reserve。
3.3 Swap交易流程
Swap是Uniswap最核心的功能。它的原理很简单——通过改变池子里的代币比例,实现价格发现。但背后的实现有不少门道。
我画了个流程图,帮你理解一次完整的Swap:
Swap的核心逻辑在Pair合约的 swap() 函数里。我直接贴一段简化版的代码:
function swap(uint amount0Out, uint amount1Out, address to) external {
// 安全检查:至少有一个输出大于0
require(amount0Out > 0 || amount1Out > 0, "UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT");
// 计算新的reserve
(uint reserve0, uint reserve1) = getReserves();
require(amount0Out < reserve0 && amount1Out < reserve1, "UniswapV2: INSUFFICIENT_LIQUIDITY");
// 先转出代币(先发货)
if (amount0Out > 0) _safeTransfer(token0, to, amount0Out);
if (amount1Out > 0) _safeTransfer(token1, to, amount1Out);
// 再检查是否满足k不变(后收款)
uint balance0 = IERC20(token0).balanceOf(address(this));
uint balance1 = IERC20(token1).balanceOf(address(this));
uint amount0In = balance0 > reserve0 - amount0Out ? balance0 - (reserve0 - amount0Out) : 0;
uint amount1In = balance1 > reserve1 - amount1Out ? balance1 - (reserve1 - amount1Out) : 0;
require(amount0In > 0 || amount1In > 0, "UniswapV2: INSUFFICIENT_INPUT_AMOUNT");
// 核心检查:balance0 * balance1 >= k
uint balance0Adjusted = balance0 * 1000 - amount0In * 3;
uint balance1Adjusted = balance1 * 1000 - amount1In * 3;
require(balance0Adjusted * balance1Adjusted >= uint(reserve0) * uint(reserve1) * 1000**2, "UniswapV2: K");
// 更新reserve
_update(balance0, balance1, reserve0, reserve1);
emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
}
这段代码有个很有意思的设计——先发货,后收款。为什么这么设计?因为这样可以支持闪电贷(Flash Swap)。你想想看,如果你先收到代币,然后在同一笔交易里用这些代币去做其他操作,最后再还回来,是不是很灵活?
关键点:手续费是0.3%,其中0.25%分给流动性提供者,0.05%归协议(如果设置了feeTo)。这个手续费是在 balance0Adjusted 的计算中体现的——减去 amount0In * 3 相当于扣除了0.3%的手续费。
3.4 TWAP预言机
TWAP(Time-Weighted Average Price)是Uniswap V2提供的一个非常实用的功能。它不像V3那样有专门的预言机合约,而是直接在Pair合约里内置了价格累积机制。
说白了,TWAP就是记录一段时间内的价格累积值,然后通过两次取样的差值来计算平均价格。这样可以有效抵抗价格操纵攻击。
3.4.1 核心原理
Pair合约里有两个关键变量:price0CumulativeLast 和 price1CumulativeLast。每次有交易发生时,_update() 函数会更新这两个值:
function _update(uint balance0, uint balance1, uint112 reserve0, uint112 reserve1) private {
// 计算时间间隔
uint32 blockTimestamp = uint32(block.timestamp % 2**32);
uint32 timeElapsed = blockTimestamp - blockTimestampLast;
if (timeElapsed > 0 && reserve0 != 0 && reserve1 != 0) {
// 累积价格 = 上一次累积 + 当前价格 * 时间间隔
price0CumulativeLast += uint(UQ112x112.encode(reserve1).uqdiv(reserve0)) * timeElapsed;
price1CumulativeLast += uint(UQ112x112.encode(reserve0).uqdiv(reserve1)) * timeElapsed;
}
// 更新reserve和时间戳
reserve0 = uint112(balance0);
reserve1 = uint112(balance1);
blockTimestampLast = blockTimestamp;
emit Sync(reserve0, reserve1);
}
这里用到了 UQ112x112 定点数格式,目的是提高精度。我刚开始看的时候也觉得有点绕,但说白了就是为了在整数运算中保留小数精度。
3.4.2 如何计算TWAP
要获取某个时间段内的TWAP,你需要记录两次价格累积值:
// 计算过去1小时的ETH/USDC TWAP
function getTWAP(address pair) external view returns (uint) {
// 第一次取样(假设在1小时前记录)
uint priceCumulative0 = price0CumulativeLast;
uint priceCumulative1 = price1CumulativeLast;
uint32 timestamp = blockTimestampLast;
// 第二次取样(当前)
// 实际使用时,需要从链上获取两次数据
// 这里简化处理
uint32 timeElapsed = block.timestamp - timestamp;
require(timeElapsed > 0, "Time elapsed is zero");
// TWAP = (当前累积 - 历史累积) / 时间间隔
uint twap = (priceCumulative0 - priceCumulative1) / timeElapsed;
return twap;
}
避坑指南:我曾经在实现TWAP预言机时,犯过一个低级错误——直接用当前区块的时间戳和上一次更新的时间戳做差。但问题是,如果池子长时间没有交易,blockTimestampLast 就不会更新,导致时间间隔计算错误。正确的做法是:自己记录第一次取样的时间戳,而不是依赖Pair合约里的时间戳。
3.4.3 实战建议
在实际使用TWAP时,有几点需要注意:
- 取样间隔:我建议至少取1小时以上的时间窗口。太短容易被操纵,太长又不够灵敏。我个人习惯用1小时作为默认值。
- 多次取样:不要只用两次取样,可以取多个时间点的数据做加权平均。比如取过去1小时、2小时、4小时的TWAP,然后按权重合并。
- 异常处理:如果池子流动性很低,TWAP可能不准确。我一般会加一个最小流动性检查,低于某个阈值就直接返回0或者报错。
我的经验:在做做市策略时,我经常用TWAP来判断当前价格是否偏离了合理区间。如果当前价格和TWAP偏差超过2%,我就会调整我的流动性位置。这个方法在震荡行情中特别有效。
3.5 总结
Uniswap V2的设计,说白了就是「少即是多」的典范。它用最少的代码实现了最核心的功能——做市、交易、预言机。每个函数都经过精心设计,没有多余的操作。
我个人觉得,理解V2最好的方式就是去读它的源码。虽然只有几百行,但每一行都值得反复琢磨。当你真正理解了x*y=k这个公式背后的设计哲学,你就能明白为什么V2能成为DeFi世界的基石。
嗯,今天就先聊到这里。记住,纸上得来终觉浅,绝知此事要躬行。去链上试试吧,用Rinkeby测试网跑一遍添加流动性、Swap、移除流动性的完整流程,你会收获更多。
公众号:蓝海资料掘金营,微信deep3321