本节作者:@愚指导
这一讲将会实现 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 中。
添加流动性是调用 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
计算出 amount0
和 amount1
,并返回这两个值。amount0
和 amount1
分别是两个代币的数量,另外还需要在 mint
方法中调用我们定义的回调函数 mintCallback
,以及修改 Pool
合约中的一些状态。
首先,我们参考 Uniswap V3 的代码来写一个 _modifyPosition
的方法,这是一个 priviate
的函数,只有合约内部可以调用,在该方法中修改交易池整体的流动性 liquidity
并计算返回 amount0
和 amount1
。
首先我们需要定义 Position
结构体,用来记录 LP 的流动性信息:
struct Position {
// 该 Position 拥有的流动性
uint128 liquidity;
// 可提取的 token0 数量
uint128 tokensOwed0;
// 可提取的 token1 数量
uint128 tokensOwed1;
}
// 用一个 mapping 来存放所有 Position 的信息
mapping(address => Position) public positions;
然后实现 _modifyPosition
方法用来在 mint
、burn
时修改 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.tickLower
和 params.tickUpper
,而我们的代码中是固定的 tickLower
和 tickUpper
。
另外计算过程中会用到 SqrtPriceMath 库,这个库是 Uniswap V3 中的一个工具库,也需要你在我们的合约代码中引入,改库还依赖了其它几个库,也需要一并引入,其中 FullMath.sol
和 TickMath.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
的关键词语法,你可以查看《库合约》 这篇文章了解更多。
amount0
和 amount1
计算完成后需要调用 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
事件。
这里需要注意的是,我们还需要添加 balance0
和 balance1
两个方法,它们也是参考了 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
方法就完成了。
接下来,让我们继续实现我们之前定义好的 burn
方法:
function burn(
uint128 amount
) external returns (uint256 amount0, uint256 amount1);
和 mint
类似,它也需要传入一个 amount
,只是它不需要有回调,另外提取代币是放到 collect
中操作的。在 burn
方法中,我们只是把流动性移除,并计算出要退回给 LP 的 amount0
和 amount1
,记录在合约状态中。
完整的代码如下,我们还是会继续用到上面的 _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
中定义了 tokensOwed0
和 tokensOwed1
,用来记录 LP 可以提取的代币数量,这个代币数量是在 collect
中提取的,接下来就让我们继续实现 collect
方法。
提取代币是调用 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
合约的开发。