UniswapV2深入解析系列13:Router流动性管理流程与最佳实践本系列前十二篇完成了工厂合约、Pair实现、CREATE2地址推导与周边工具的铺垫,本篇正式切入Router作为用户入口的完整能力图谱。
本系列前十二篇完成了工厂合约、Pair 实现、CREATE2 地址推导与周边工具的铺垫,本篇正式切入 Router 作为用户入口的完整能力图谱。
全文基于 Solidity 0.8.30 与 Foundry 工具链,代码、脚本与文档均可在仓库中复现,便于读者学习操作。
Router 将多个链上操作汇聚为一次调用:
pairFor
、getReserves
、quote
等纯函数,保证地址推导与比例换算的正确性和可复用性。mint
或 burn
实际更新状态。immutable
关键字降低储存开销。/// @title UniswapV2Router
/// @notice 统一封装流动性管理与兑换逻辑的路由器合约
contract UniswapV2Router {
error FactoryAddressRequired();
/// @dev 工厂引用用于访问 `createPair` 与 `pairs` 映射
IUniswapV2Factory public immutable factory;
/// @notice 初始化路由器并绑定工厂地址
/// @param factoryAddress 已部署的工厂合约地址
constructor(address factoryAddress) {
if (factoryAddress == address(0)) revert FactoryAddressRequired();
factory = IUniswapV2Factory(factoryAddress);
}
}
addLiquidity
为 Router 中最常用的流动性入口,其参数设计体现了“期望值 + 最低容忍值”的双阈值思想:
/// @notice 向指定交易对注入双边流动性
/// @param tokenA tokenA 地址,参与配对的第一种资产
/// @param tokenB tokenB 地址,参与配对的第二种资产
/// @param amountADesired 希望投入的 tokenA 数量(上限)
/// @param amountBDesired 希望投入的 tokenB 数量(上限)
/// @param amountAMin 可接受的最低 tokenA 数量,用于滑点保护
/// @param amountBMin 可接受的最低 tokenB 数量,用于滑点保护
/// @param to LP 代币接收地址
/// @return amountA 实际投入的 tokenA 数量
/// @return amountB 实际投入的 tokenB 数量
/// @return liquidity 铸造出的 LP 代币数量
function addLiquidity(
address tokenA,
address tokenB,
uint256 amountADesired,
uint256 amountBDesired,
uint256 amountAMin,
uint256 amountBMin,
address to
) external returns (uint256 amountA, uint256 amountB, uint256 liquidity) {
// 1. 基础输入校验,提前阻断异常调用场景
if (tokenA == tokenB) revert IdenticalAddresses();
if (to == address(0)) revert InvalidRecipient();
// 2. 查询已存在的交易对,没有则即时通过工厂创建
address pair = factory.getPair(tokenA, tokenB);
if (pair == address(0)) {
pair = factory.createPair(tokenA, tokenB);
}
// 3. 根据池内储备与用户期望,得到实际的投入金额组合
(amountA, amountB) = _calculateLiquidity(
tokenA,
tokenB,
amountADesired,
amountBDesired,
amountAMin,
amountBMin
);
// 4. 将两种代币从调用者账户转入 Pair,等待后续铸造流程
_safeTransferFrom(tokenA, msg.sender, pair, amountA);
_safeTransferFrom(tokenB, msg.sender, pair, amountB);
// 5. 调用 Pair.mint 完成储备更新,并取得新增 LP 份额
liquidity = IUniswapV2Pair(pair).mint(to);
}
tokenA
/tokenB
是否相同以及接收者地址是否为零地址,提前阻断异常调用。factory.getPair(tokenA, tokenB)
查询现有池子,如不存在则立即调用 factory.createPair
。_calculateLiquidity
读取储备并结合期望值、最小值确定最终 amountA
/amountB
。_safeTransferFrom
将两种代币从调用者账户划转至 Pair 合约。IUniswapV2Pair(pair).mint(to)
,由 Pair 更新储备并铸造对应的 LP 份额。mint
内部会更新储备、铸造 LP,并触发 Mint
与 Transfer
事件供前端追踪。_calculateLiquidity
逻辑/// @notice 根据历史储备与期望投入计算平衡后的双边资金
/// @dev 优先以 tokenA 作为基准,若 tokenB 超限则交换判断顺序
/// @param amountADesired tokenA 期望投入上限
/// @param amountBDesired tokenB 期望投入上限
/// @param amountAMin tokenA 可接受下限
/// @param amountBMin tokenB 可接受下限
/// @return amountA 实际投入的 tokenA 数量
/// @return amountB 实际投入的 tokenB 数量
function _calculateLiquidity(
address tokenA,
address tokenB,
uint256 amountADesired,
uint256 amountBDesired,
uint256 amountAMin,
uint256 amountBMin
) internal view returns (uint256 amountA, uint256 amountB) {
// 1. 读取目标交易对的最新储备数据,并按调用顺序返回
(uint112 reserveA, uint112 reserveB) = UniswapV2Library.getReserves(
address(factory),
tokenA,
tokenB
);
// 2. 首次注入时储备为零,直接沿用用户给定的期望值
if (reserveA == 0 && reserveB == 0) {
return (amountADesired, amountBDesired);
}
// 3. 以 amountA 为基准计算另一侧的最优补足金额
uint256 amountBOptimal = UniswapV2Library.quote(amountADesired, reserveA, reserveB);
if (amountBOptimal <= amountBDesired) {
// 校验最优金额是否仍满足用户自定义的最小滑点阈值
if (amountBOptimal < amountBMin) revert InsufficientBAmount();
return (amountADesired, amountBOptimal);
}
// 4. 若 tokenB 超出上限,则换以 amountB 为基准重新匹配
uint256 amountAOptimal = UniswapV2Library.quote(amountBDesired, reserveB, reserveA);
if (amountAOptimal < amountAMin) revert InsufficientAAmount();
return (amountAOptimal, amountBDesired);
}
quote(x, reserveX, reserveY) = x * reserveY / reserveX
:保持储备比例不变,确保 k = reserveX * reserveY
在添加流动性后仍与价格曲线一致。Desired
限定用户愿意投入的最大数量,防止资金被多扣;Min
限定实际成交的最低数量,防止价格突变造成的滑点损失;quote
计算理论值,再结合预期滑点设置 Min
,必要时加上缓冲区以提升成功率。permit
或限额授权,避免无限授权被滥用;如需多账户操作可配合 Permit2
或 Session Key 方案。mint
内部使用锁修饰符防重入,双层防护确保流程安全。IdenticalAddresses()
、InvalidRecipient()
、Insufficient*Amount()
等自定义错误最为常见,可在前端直接捕获选择器并提示用户调整输入。forge snapshot
记录 Gas 基线,添加新功能后对比差异,保持流动性操作的可预估成本。// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import "forge-std/Test.sol";
import {UniswapV2Factory} from "src/core/UniswapV2Factory.sol";
import {UniswapV2Router} from "src/periphery/UniswapV2Router.sol";
import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol";
/// @title RouterAddLiquidityTest
/// @notice 使用 Foundry 验证 Router 添加流动性的关键路径
contract RouterAddLiquidityTest is Test {
UniswapV2Factory private factory;
UniswapV2Router private router;
ERC20Mock private tokenA;
ERC20Mock private tokenB;
/// @notice 部署工厂与路由器,并为测试账户铸造初始代币
function setUp() public {
factory = new UniswapV2Factory(address(this));
router = new UniswapV2Router(address(factory));
tokenA = new ERC20Mock();
tokenB = new ERC20Mock();
tokenA.mint(address(this), 1_000 ether);
tokenB.mint(address(this), 1_000 ether);
tokenA.approve(address(router), type(uint256).max);
tokenB.approve(address(router), type(uint256).max);
}
/// @notice 首次注入应直接使用期望值并成功铸造 LP
function testAddLiquidityBootstrap() public {
(uint256 amountA, uint256 amountB, uint256 liquidity) = router.addLiquidity(
address(tokenA),
address(tokenB),
120 ether,
100 ether,
110 ether,
90 ether,
address(this)
);
assertEq(amountA, 120 ether, "amountA");
assertEq(amountB, 100 ether, "amountB");
assertGt(liquidity, 0, "liquidity");
}
/// @notice 再次注入时应遵循储备比例,返回值需等于重新计算后的最优解
function testAddLiquidityWithExistingReserves() public {
router.addLiquidity(
address(tokenA),
address(tokenB),
120 ether,
100 ether,
110 ether,
90 ether,
address(this)
);
(uint112 reserveA, uint112 reserveB) = UniswapV2Library.getReserves(address(factory), address(tokenA), address(tokenB));
uint256 amountBOptimal = UniswapV2Library.quote(120 ether, reserveA, reserveB);
uint256 expectedAmountA;
uint256 expectedAmountB;
if (amountBOptimal <= 80 ether) {
expectedAmountA = 120 ether;
expectedAmountB = amountBOptimal;
} else {
expectedAmountA = UniswapV2Library.quote(80 ether, reserveB, reserveA);
expectedAmountB = 80 ether;
}
// 当前参数组合下,expectedAmountA = 96 ether,expectedAmountB = 80 ether
(uint256 amountA, uint256 amountB,) = router.addLiquidity(
address(tokenA),
address(tokenB),
120 ether,
80 ether,
90 ether,
70 ether,
address(this)
);
assertApproxEqAbs(amountA, expectedAmountA, 1, "amountA optimal");
assertApproxEqAbs(amountB, expectedAmountB, 1, "amountB optimal");
}
/// @notice 滑点阈值过紧时应触发回滚,便于前端提示用户调整参数
function testAddLiquidityRevertWhenSlippageTooTight() public {
router.addLiquidity(
address(tokenA),
address(tokenB),
120 ether,
100 ether,
110 ether,
90 ether,
address(this)
);
vm.expectRevert(UniswapV2Router.InsufficientBAmount.selector);
router.addLiquidity(
address(tokenA),
address(tokenB),
100 ether,
90 ether,
99 ether,
85 ether,
address(this)
);
}
}
setUp
中统一完成部署、铸币与授权,缩短每个测试函数的重复代码。vm.prank
模拟第三方账户,检查不同调用者的授权与 LP 分配是否正确。vm.expectEmit
验证 Pair 发出的 Transfer
、Mint
事件,确保链上日志可供前端与分析工具消费。# 运行所有 Router 流动性管理相关测试
forge test --match-contract UniswapV2RouterAddLiquidityTest -vvv
# 运行特定的测试函数(示例:首次注入场景)
forge test --match-test testAddLiquidityBootstrap -vvv
# 运行滑点相关的模糊测试(可根据需要调整 fuzz 次数)
forge test --match-test testAddLiquidityRevertWhenSlippageTooTight --fuzz-runs 1000 -vvv
# 生成 Router 测试的 Gas 报告
forge test --gas-report --match-contract UniswapV2RouterAddLiquidityTest
# 运行 Router 测试的覆盖率统计
forge coverage --match-contract UniswapV2RouterAddLiquidityTest
forge test -vv
获取详细调用栈,配合 forge coverage
或 tbuild --coverage
(如在 CI 中)确认覆盖率。forge test
、forge snapshot
,对比储备、Gas 与事件输出的变化,维护可观测基线。https://github.com/RyanWeb31110/uniswapv2_tech
欢迎克隆仓库,使用 Foundry 实际运行与调试上述示例,加深对 Router 流动性管理流程的理解。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!