3. Uniswap V2实战:核心合约架构、添加/移除流动性、Swap交易流程、TWAP预言机

Uniswap V2,可以说是DeFi世界里的一个里程碑。我当年第一次看它的合约代码时,说实话,被那种极简的设计震撼到了——整个核心逻辑就两个合约,却能支撑起数百亿美金的交易量。今天我们就来拆解它,从架构到实战,把每个环节都讲透。

核心要点:Uniswap V2的核心是常数乘积做市商模型(x*y=k),所有操作都围绕这个公式展开。理解了这个,你就理解了V2的魂。

3.1 核心合约架构

Uniswap V2由两个核心合约组成:UniswapV2PairUniswapV2Factory。说白了,Factory是工厂,负责创建Pair;Pair是交易对,负责实际做市。

我画了一张架构图,帮你理清它们的关系:

用户/交易者 (EOA / 合约) UniswapV2Factory 创建Pair / 管理FeeTo UniswapV2Pair ETH/USDC UniswapV2Pair ETH/DAI UniswapV2Pair USDC/DAI createPair() swap / addLiquidity 部署 部署 部署 核心数据结构 • reserve0 / reserve1 • price0CumulativeLast • price1CumulativeLast • kLast (手续费累计) • totalSupply (LP Token) • balanceOf / allowance 核心函数 • mint() → 添加流动性 • burn() → 移除流动性 • swap() → 交易

每个Pair合约管理一个交易对,比如ETH/USDC。它内部维护了两个关键变量:reserve0reserve1,分别对应两种代币的库存。嗯,这里要注意——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合约里有个 amountAMinamountBMin 参数,用来设置你能接受的最小数量。

避坑指南:我曾经在添加流动性时,因为没设置滑点保护,结果遇到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 指定输入/输出代币 ② Router计算 getAmountsOut() ③ 转账到Pair transferFrom() ④ Pair.swap() 更新reserve ⑤ 输出代币到用户 safeTransfer() ⑥ 完成Swap 触发Sync事件 核心公式:x * y = k 输入Δx → 输出Δy = y - k / (x + Δx) 实际输出 = Δy * (1 - 0.3%) // 扣除手续费

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合约里有两个关键变量:price0CumulativeLastprice1CumulativeLast。每次有交易发生时,_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