P105_PoolLP_合约简介


文档摘要

本节作者:@愚指导 这一讲将会实现 合约中涉及到 LP(流动性提供者)相关的接口,包括添加流动性、移除流动性、提取代币等。 合约简介 合约是教程中最复杂的一个合约,它由 合约创建,可能会有多个 合约。在我们的课程设计中,每个代币对可能有多个 合约,每个 合约就是一个交易池,每个交易池都有自己的价格上下限和手续费。 这和 Uniswap V2 以及 Uniswap V3 都不一样,Uniswap 的交易池只有交易对+手续费属性,而我们的交易池还有价格上下限属性。我们的代码更多是参考了 Uniswap V3,所以这其实是让我们的开发更简单了,因为我们只需要考虑这个固定范围内的流动性管理和交易即可,而在 Uniswap V3 中,你需要在一个交易池里面去管理在不同价格区间内的流动性。

本节作者:@愚指导

这一讲将会实现 Pool 合约中涉及到 LP(流动性提供者)相关的接口,包括添加流动性、移除流动性、提取代币等。

合约简介

Pool 合约是教程中最复杂的一个合约,它由 Factory 合约创建,可能会有多个 Pool 合约。在我们的课程设计中,每个代币对可能有多个 Pool 合约,每个 Pool 合约就是一个交易池,每个交易池都有自己的价格上下限和手续费。

这和 Uniswap V2 以及 Uniswap V3 都不一样,Uniswap 的交易池只有交易对+手续费属性,而我们的交易池还有价格上下限属性。我们的代码更多是参考了 Uniswap V3,所以这其实是让我们的开发更简单了,因为我们只需要考虑这个固定范围内的流动性管理和交易即可,而在 Uniswap V3 中,你需要在一个交易池里面去管理在不同价格区间内的流动性。在后面的实现中,你会发现我们大量参考了 Uniswap V3 的代码,但是实际上我们只是采用了它的很少一部分逻辑,这让我们的课程更容易学习。

Uniswap V3 的交易池合约代码在 UniswapV3Pool.sol,你可以参考这个代码来更好地理解我们的代码,或者说是参考我们的课程来学习 Uniswap V3 的代码。当然,Uniswap V2 的代码 UniswapV2Pair.sol 你也可以参考。

在这一讲中,我们先实现 LP 相关接口,交易接口会放到下一讲中实现。

合约开发

完整的代码在 demo-contract/contracts/wtfswap/Pool.sol 中。

1. 添加流动性

添加流动性是调用 mint 方法,在我们的设计中,mint 方法定义如下:

function mint( address recipient, uint128 amount, bytes calldata data ) external returns (uint256 amount0, uint256 amount1);

我们传入要添加流动性 amount,以及 data,这个 data 是用来在回调函数中传递参数的,后面会再讲。recipient 可以指定讲流动性的权益赋予谁。这里需要注意的是 amount 是流动性,而不是要 mint 的代币,至于流动性如何计算,我们在 PositionManager 的章节中讲解,这一讲中先不具体展开。但是在我们这一讲的实现中,我们需要基于传入的 amount 计算出 amount0amount1,并返回这两个值。amount0amount1 分别是两个代币的数量,另外还需要在 mint 方法中调用我们定义的回调函数 mintCallback,以及修改 Pool 合约中的一些状态。

首先,我们参考 Uniswap V3 的代码来写一个 _modifyPosition 的方法,这是一个 priviate 的函数,只有合约内部可以调用,在该方法中修改交易池整体的流动性 liquidity 并计算返回 amount0amount1

首先我们需要定义 Position 结构体,用来记录 LP 的流动性信息:

struct Position { // 该 Position 拥有的流动性 uint128 liquidity; // 可提取的 token0 数量 uint128 tokensOwed0; // 可提取的 token1 数量 uint128 tokensOwed1; } // 用一个 mapping 来存放所有 Position 的信息 mapping(address => Position) public positions;

然后实现 _modifyPosition 方法用来在 mintburn 时修改 positions 中的信息:

function _modifyPosition( ModifyPositionParams memory params ) private returns (int256 amount0, int256 amount1) { // 通过新增的流动性计算 amount0 和 amount1 // 参考 UniswapV3 的代码 amount0 = SqrtPriceMath.getAmount0Delta( sqrtPriceX96, TickMath.getSqrtPriceAtTick(tickUpper), params.liquidityDelta ); amount1 = SqrtPriceMath.getAmount1Delta( TickMath.getSqrtPriceAtTick(tickLower), sqrtPriceX96, params.liquidityDelta ); Position storage position = positions[params.owner]; // 修改 liquidity liquidity = LiquidityMath.addDelta(liquidity, params.liquidityDelta); position.liquidity = LiquidityMath.addDelta( position.liquidity, params.liquidityDelta ); } function mint( address recipient, uint128 amount, bytes calldata data ) external override returns (uint256 amount0, uint256 amount1) { require(amount > 0, "Mint amount must be greater than 0"); // 基于 amount 计算出当前需要多少 amount0 和 amount1 (int256 amount0Int, int256 amount1Int) = _modifyPosition( ModifyPositionParams({ owner: recipient, liquidityDelta: int128(amount) }) ); }

相比 Uniswap V3 的 _modifyPosition,我们的代码要简单许多。整个交易池都固定在一个价格区间内,mint 也只能在这个价格区间内 mint。所以我们只需要取 Uniswap V3 中的部分代码即可,在 Uniswap V3 中,计算流动性时的上下限是参数动态传入的 params.tickLowerparams.tickUpper,而我们的代码中是固定的 tickLowertickUpper

另外计算过程中会用到 SqrtPriceMath 库,这个库是 Uniswap V3 中的一个工具库,也需要你在我们的合约代码中引入,改库还依赖了其它几个库,也需要一并引入,其中 FullMath.solTickMath.sol 因为依赖于 solidity <0.8.0; 版本,但是我们课程用的是 0.8.0+,所以我们使用 Uniswap V4 的代码,Uniswap V4 还没有正式发布,但是它的一些基础库已经给予 solidity 0.8 版本的支持,所以我们可以直接使用。0.8 版本的 solidity 在一些数学运算上和 0.7 有一些差异,尤其是对溢出的处理上,这里先不展开了。

当然你也可以直接复制课程提供的代码,不用自己去修改,我们的代码中已经做了这些修改。你可以直接引入下面代码:

import "./libraries/SqrtPriceMath.sol"; import "./libraries/TickMath.sol"; import "./libraries/LiquidityMath.sol"; import "./libraries/LowGasSafeMath.sol"; import "./libraries/TransferHelper.sol";

其中 LowGasSafeMath 是下面我们会用到的一个库,它是为了避免在计算过程中出现溢出导致的错误(实际上,在 Solidity 0.8 以后,会默认开启溢出与下溢检查,这并不是必须的,你可以查看这篇文章了解更多),你需要在合约中加上如下内容使用它:

contract Pool is IPool { + using LowGasSafeMath for uint256;

关于 using 的关键词语法,你可以查看《库合约》 这篇文章了解更多。

amount0amount1 计算完成后需要调用 mintCallback 回调方法,LP 需要在这个回调方法中将对应的代币转入到 Pool 合约中,所以调用 Pool 合约 mint 方法的也需要是一个合约,并且在合约中定义好 mintCallback 方法,我们未来会在 PositionManager 合约中实现相关逻辑。

完整的 mint 方法代码如下:

function mint( address recipient, uint128 amount, bytes calldata data ) external override returns (uint256 amount0, uint256 amount1) { require(amount > 0, "Mint amount must be greater than 0"); // 基于 amount 计算出当前需要多少 amount0 和 amount1 (int256 amount0Int, int256 amount1Int) = _modifyPosition( ModifyPositionParams({ owner: recipient, liquidityDelta: int128(amount) }) ); amount0 = uint256(amount0Int); amount1 = uint256(amount1Int); uint256 balance0Before; uint256 balance1Before; if (amount0 > 0) balance0Before = balance0(); if (amount1 > 0) balance1Before = balance1(); // 回调 mintCallback IMintCallback(msg.sender).mintCallback(amount0, amount1, data); if (amount0 > 0) require(balance0Before.add(amount0) <= balance0(), "M0"); if (amount1 > 0) require(balance1Before.add(amount1) <= balance1(), "M1"); emit Mint(msg.sender, recipient, amount, amount0, amount1); }

我们需要在最后检查一下对应的 token 是否到账,确认后触发一个 Mint 事件。

这里需要注意的是,我们还需要添加 balance0balance1 两个方法,它们也是参考了 Uniswap V3 的代码,不过我们做了一点小的调整,把 V3 中定义的 IERC20Minimal 改为使用 @openzeppelin/contracts/token/ERC20/IERC20.sol,当然,真实的项目中使用 IERC20Minimal 会一定程度上降低合约的大小,但是我们课程中直接使用 @openzeppelin 下的合约会更简单,也让大家可以借此来了解 OpenZeppelin 相关的库。

具体代码如下:

/// @dev Get the pool's balance of token0 /// @dev This function is gas optimized to avoid a redundant extcodesize check in addition to the returndatasize /// check function balance0() private view returns (uint256) { (bool success, bytes memory data) = token0.staticcall( abi.encodeWithSelector(IERC20.balanceOf.selector, address(this)) ); require(success && data.length >= 32); return abi.decode(data, (uint256)); } /// @dev Get the pool's balance of token1 /// @dev This function is gas optimized to avoid a redundant extcodesize check in addition to the returndatasize /// check function balance1() private view returns (uint256) { (bool success, bytes memory data) = token1.staticcall( abi.encodeWithSelector(IERC20.balanceOf.selector, address(this)) ); require(success && data.length >= 32); return abi.decode(data, (uint256)); }

这样,我们的 mint 方法就完成了。

2. 移除流动性

接下来,让我们继续实现我们之前定义好的 burn 方法:

function burn( uint128 amount ) external returns (uint256 amount0, uint256 amount1);

mint 类似,它也需要传入一个 amount,只是它不需要有回调,另外提取代币是放到 collect 中操作的。在 burn 方法中,我们只是把流动性移除,并计算出要退回给 LP 的 amount0amount1,记录在合约状态中。

完整的代码如下,我们还是会继续用到上面的 _modifyPosition 方法,只不过参数中的 liquidityDelta 变成了负数:

function burn( uint128 amount ) external override returns (uint256 amount0, uint256 amount1) { require(amount > 0, "Burn amount must be greater than 0"); require( amount <= positions[msg.sender].liquidity, "Burn amount exceeds liquidity" ); // 修改 positions 中的信息 (int256 amount0Int, int256 amount1Int) = _modifyPosition( ModifyPositionParams({ owner: msg.sender, liquidityDelta: -int128(amount) }) ); // 获取燃烧后的 amount0 和 amount1 amount0 = uint256(-amount0Int); amount1 = uint256(-amount1Int); if (amount0 > 0 || amount1 > 0) { ( positions[msg.sender].tokensOwed0, positions[msg.sender].tokensOwed1 ) = ( positions[msg.sender].tokensOwed0 + uint128(amount0), positions[msg.sender].tokensOwed1 + uint128(amount1) ); } emit Burn(msg.sender, amount, amount0, amount1); }

我们在 Position 中定义了 tokensOwed0tokensOwed1,用来记录 LP 可以提取的代币数量,这个代币数量是在 collect 中提取的,接下来就让我们继续实现 collect 方法。

3. 提取代币

提取代币是调用 collect 方法,我们定义如下:

function collect( address recipient, uint128 amount0Requested, uint128 amount1Requested ) external returns (uint128 amount0, uint128 amount1);

和 Uniswap V3 的代码逻辑类似,完整的代码如下:

function collect( address recipient, uint128 amount0Requested, uint128 amount1Requested ) external override returns (uint128 amount0, uint128 amount1) { // 获取当前用户的 position Position storage position = positions[msg.sender]; // 把钱退给用户 recipient amount0 = amount0Requested > position.tokensOwed0 ? position.tokensOwed0 : amount0Requested; amount1 = amount1Requested > position.tokensOwed1 ? position.tokensOwed1 : amount1Requested; if (amount0 > 0) { position.tokensOwed0 -= amount0; TransferHelper.safeTransfer(token0, recipient, amount0); } if (amount1 > 0) { position.tokensOwed1 -= amount1; TransferHelper.safeTransfer(token1, recipient, amount1); } emit Collect(msg.sender, recipient, amount0, amount1); }

在代码中,我们引入了 Uniswap V3 代码中的 TransferHelper 库来做转账,将 token 发送给传入的 recipient 地址。至此,基础的逻辑就实现完成了。

合约测试

接下来,我们补充一些单元测试。因为创建 Pool 需要对应一个交易对,所以我们先创建一个满足 ERC20 规范的代币合约。关于 ERC20 规范,你可以参考这篇文章

我们在 demo-contract/contracts/wtfswap 中新建一个 test-contracts/TestToken.sol 的文件,内容如下:

// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract TestToken is ERC20 { uint256 private _nextTokenId = 0; constructor() ERC20("TestToken", "TK") {} function mint(address recipient, uint256 quantity) public payable { _mint(recipient, quantity); } }

具体的合约代码你可以参考这里,这个合约我们实现了一个可以随意 mint 的代币合约,用于测试。

接着,我们新建 demo-contract/test/wtfswap/Pool.test.js 文件,编写测试代码:

import { loadFixture } from "@nomicfoundation/hardhat-toolbox-viem/network-helpers"; import { expect } from "chai"; import hre from "hardhat"; import { TickMath, encodeSqrtRatioX96 } from "@uniswap/v3-sdk"; describe("Pool", function () { async function deployFixture() { // 初始化一个池子,价格上限是 40000,下限是 1,初始化价格是 10000,费率是 0.3% const factory = await hre.viem.deployContract("Factory"); const tokenA = await hre.viem.deployContract("TestToken"); const tokenB = await hre.viem.deployContract("TestToken"); const token0 = tokenA.address < tokenB.address ? tokenA : tokenB; const token1 = tokenA.address < tokenB.address ? tokenB : tokenA; const tickLower = TickMath.getTickAtSqrtRatio(encodeSqrtRatioX96(1, 1)); const tickUpper = TickMath.getTickAtSqrtRatio(encodeSqrtRatioX96(40000, 1)); // 以 1,000,000 为基底的手续费费率,Uniswap v3 前端界面支持四种手续费费率(0.01%,0.05%、0.30%、1.00%),对于一般的交易对推荐 0.30%,fee 取值即 3000; const fee = 3000; const publicClient = await hre.viem.getPublicClient(); await factory.write.createPool([ token0.address, token1.address, tickLower, tickUpper, fee, ]); const createEvents = await factory.getEvents.PoolCreated(); const poolAddress: `0x${string}` = createEvents[0].args.pool || "0x"; const pool = await hre.viem.getContractAt("Pool" as string, poolAddress); // 计算一个初始化的价格,按照 1 个 token0 换 10000 个 token1 来算,其实就是 10000 const sqrtPriceX96 = encodeSqrtRatioX96(10000, 1); await pool.write.initialize([sqrtPriceX96]); return { token0, token1, factory, pool, publicClient, tickLower, tickUpper, fee, sqrtPriceX96: BigInt(sqrtPriceX96.toString()), }; } it("pool info", async function () { const { pool, token0, token1, tickLower, tickUpper, fee, sqrtPriceX96 } = await loadFixture(deployFixture); expect(((await pool.read.token0()) as string).toLocaleLowerCase()).to.equal( token0.address ); expect(((await pool.read.token1()) as string).toLocaleLowerCase()).to.equal( token1.address ); expect(await pool.read.tickLower()).to.equal(tickLower); expect(await pool.read.tickUpper()).to.equal(tickUpper); expect(await pool.read.fee()).to.equal(fee); expect(await pool.read.sqrtPriceX96()).to.equal(sqrtPriceX96); }); });

我们部署了一个 Factory 和两个 TestToken 代币合约,然后创建了一个 Pool 合约,初始化了价格,然后测试了一下 Pool 合约的基本信息。另外我们需要引入 @uniswap/v3-sdk 用于 sqrtPriceX96 的计算,代码如下:

你还需要在项目中使用 npm install @uniswap/v3-sdk 安装需要的 @uniswap/v3-sdk 依赖。@uniswap/v3-sdk 是 Uniswap 的一个 Typescript 的 SDK,包含一些基础的计算逻辑,除了在单测中使用外,后续我们的 DApp 前端开发中也会用到。

接下来我们可以继续编写更多的测试样例,比如我们添加下面的样例测试 mint 流动性,然后检查代币的转移是否正确。

it("mint and burn and collect", async function () { const { pool, token0, token1, price } = await loadFixture(deployFixture); const testLP = await hre.viem.deployContract("TestLP"); const initBalanceValue = 1000n * 10n ** 18n; await token0.write.mint([testLP.address, initBalanceValue]); await token1.write.mint([testLP.address, initBalanceValue]); await testLP.write.mint([ testLP.address, 20000000n, pool.address, token0.address, token1.address, ]); expect(await token0.read.balanceOf([pool.address])).to.equal( initBalanceValue - (await token0.read.balanceOf([testLP.address])) ); expect(await token1.read.balanceOf([pool.address])).to.equal( initBalanceValue - (await token1.read.balanceOf([testLP.address])) ); const position = await pool.read.positions([testLP.address]); expect(position).to.deep.equal([20000000n, 0n, 0n]); expect(await pool.read.liquidity()).to.equal(20000000n); });

因为 Pool 的合约需要通过回调函数来处理代币的转移,所以我们需要新增一个测试 TestLP 合约,这个合约需要实现 IMintCallback 接口,具体代码如下:

// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "../interfaces/IPool.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; contract TestLP is IMintCallback { function sortToken( address tokenA, address tokenB ) private pure returns (address, address) { return tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA); } function mint( address recipient, uint128 amount, address pool, address tokenA, address tokenB ) external returns (uint256 amount0, uint256 amount1) { (address token0, address token1) = sortToken(tokenA, tokenB); (amount0, amount1) = IPool(pool).mint( recipient, amount, abi.encode(token0, token1) ); } function burn( uint128 amount, address pool ) external returns (uint256 amount0, uint256 amount1) { (amount0, amount1) = IPool(pool).burn(amount); } function collect( address recipient, address pool ) external returns (uint256 amount0, uint256 amount1) { (, , , uint128 tokensOwed0, uint128 tokensOwed1) = IPool(pool) .getPosition(address(this)); (amount0, amount1) = IPool(pool).collect( recipient, tokensOwed0, tokensOwed1 ); } function mintCallback( uint256 amount0Owed, uint256 amount1Owed, bytes calldata data ) external override { // transfer token (address token0, address token1) = abi.decode(data, (address, address)); if (amount0Owed > 0) { IERC20(token0).transfer(msg.sender, amount0Owed); } if (amount1Owed > 0) { IERC20(token1).transfer(msg.sender, amount1Owed); } } }

你还需要注意的是,因为流动性到代币的计算基于一个相对复杂的公式,中间还涉及到计算时取整的问题。在我们的单测中只是简单的测试了一些基础的逻辑,实际上你需要更多的测试用例来覆盖更多的情况,以及测试具体的数学运算的逻辑是否正确。

更多的测试代码你可以在 demo-contract/test/wtfswap/Pool.ts 找到。至此,我们就完成了 Pool 合约中的 LP 相关接口开发,在下一讲中我们将会补充 swap 接口,并添加手续费相关逻辑,完成整个 Pool 合约的开发。


发布者: 作者: 转发
评论区 (0)
U