这一讲我们将引导大家完成 PoolManager.sol
合约的开发。
PoolManager
合约的主要作用是控制池子,并且提供池子的信息来供前端展示。PoolManger
合约继承了 Factory
合约,开发者在想要获取池子信息的时候,无需向更底层的 Factory
进行交互,只需要感知到 PoolManager
即可。
PoolManager
和 Factory
合约关系紧密,可以认为是同一个合约,但是为了合约功能的边界清晰我们进行了拆分。他们的关系可以如下图所示:
PoolManager
合约主要需要的功能比较简单,但是我们可以在里面学到一些有趣的细节知识。
在开始写合约之前,我们可以先停下来想一下,我们合约的功能应该需要包含哪些方法。在前端页面,我们有一个表格用于展示池子的一些信息,因此我们需要一个返回池子信息的方法。另外,在 position 前端页面有添加池子功能,因此我们还需要设计一个添加池子的方法。
当然,这个合约并不是必须的,它只是为了给前端提供数据,本教程将数据保存在服务端只是为了教学。实际开发中,更加推荐将这些数据存储在服务端(Uniswap 的做法),通过调用服务端的接口来保存、获取这些数据,这样的话既可以提高操作数据的响应速度,又可以减少合约存储数据的 gas 开销。
在之前的介绍中,我们设计了 PoolManager
合约需要继承 Factory
合约(可以参考上一节的内容),因此在写合约方法之前,我们需要继承一下。
import "./Factory.sol";
import "./interfaces/IPoolManager.sol";
contract PoolManager is Factory, IPoolManager {
// ...
}
在继承之后,我们就可以使用 Factory
合约里面设计的方法了。
首先我们考虑创建池子的功能。在之前的介绍中,我们知道了一个池子的唯一性是由代币对、价格区间以及费用确定的,因此该方法的输入参数应该包含这几项,另外该函数应该返回池子对应的合约地址。在合约的类型定义中,我们可以这么设计:
import "./IFactory.sol";
interface IPoolManager is IFactory {
// ...
struct CreateAndInitializeParams {
address token0;
address token1;
uint24 fee;
int24 tickLower;
int24 tickUpper;
uint160 sqrtPriceX96;
}
function createAndInitializePoolIfNecessary(
CreateAndInitializeParams calldata params
) external payable returns (address pool);
}
现在我们考虑具体的功能实现,在 Factory
合约里面对外暴露了一个 createPool
的方法,我们可以调用该方法去创建池子。需要注意的是,对于重复创建的情况,并不在 PoolManager
中处理,而是在 Factory
处理。
import "./Factory.sol";
import "./interfaces/IPoolManager.sol";
contract PoolManager is Factory, IPoolManager {
// ...
function createAndInitializePoolIfNecessary(
CreateAndInitializeParams calldata params
) external payable override returns (address poolAddress) {
require(params.token0 < params.token1);
poolAddress = this.createPool(
params.token0,
params.token1,
params.tickLower,
params.tickUpper,
params.fee
);
//...
}
}
在创建完成池子之后,我们需要维护 DEX 整体池子的信息,该信息包含两部份,DEX 支持的交易对种类以及交易对的具体信息。前者主要是为了提供我们的 DEX 支持哪些 Token 间进行交易,后者主要是提供完整的池子信息。
在 Factory
合约中,每次创建完成一个池子,都会记录下它的信息,因此这个信息我们不需要再记录,我们需要记录的是交易对的种类,即在获得一个新的交易对时,动态地维护一个 pairs
数组。
由于我们继承了 Factory
,因此我们可以轻松拿到 Factory
中的 pools
,因此记录信息的逻辑可以如下实现:
import "./Factory.sol";
import "./interfaces/IPool.sol";
import "./interfaces/IPoolManager.sol";
contract PoolManager is Factory, IPoolManager {
Pair[] public pairs;
// ...
function createAndInitializePoolIfNecessary(
CreateAndInitializeParams calldata params
) external payable override returns (address poolAddress) {
require(params.token0 < params.token1);
poolAddress = this.createPool(
params.token0,
params.token1,
params.tickLower,
params.tickUpper,
params.fee
);
// 获取池子合约
IPool pool = IPool(poolAddress);
// 获取同一交易对的数量
uint256 index = pools[pool.token0()][pool.token1()].length;
// 新创建的池子,没有初始化价格,需要初始化价格
if (pool.sqrtPriceX96() == 0) {
pool.initialize(params.sqrtPriceX96);
if (index == 1) {
// 如果是第一次添加该交易对,需要记录下来
pairs.push(
Pair({token0: pool.token0(), token1: pool.token1()})
);
}
}
}
}
需要注意的是,虽然 createPool
的入参 tokenA
和 tokenB
没有顺序要求,但是在 createAndInitializePoolIfNecessary
中我们创建的时候要求 token0 < token1
。因为在这个方法中需要传入初始化的价格,而在交易池中价格是按照 token0/token1
的方式计算的,做这个限制可以避免 LP 不小心初始化错误的价格。在后续的代码和测试中,我们也约定了 tokenA
和 tokenB
是未排序的,而 token0
和 token1
是排序的,这样也便于我们理解代码。
至此,创建池子的方法我们就算是实现完成了。
由于池子的信息是在 Factory
合约中保存的,因此我们在返回全部池子信息的时候,还需要对 Factory
保存的信息进行处理,处理成我们想要的数据格式。
这部分的逻辑比较清晰,通过遍历全部的池子信息,做一些数据转换就行。
import "./Factory.sol";
import "./interfaces/IPool.sol";
import "./interfaces/IPoolManager.sol";
contract PoolManager is Factory, IPoolManager {
function getAllPools()
external
view
override
returns (PoolInfo[] memory poolsInfo)
{
uint32 length = 0;
// 先算一下大小,从 pools 获取
for (uint32 i = 0; i < pairs.length; i++) {
length += uint32(pools[pairs[i].token0][pairs[i].token1].length);
}
// 再填充数据
poolsInfo = new PoolInfo[](length);
uint256 index
for (uint32 i = 0; i < pairs.length; i++) {
address[] memory addresses = pools[pairs[i].token0][
pairs[i].token1
];
for (uint32 j = 0; j < addresses.length; j++) {
IPool pool = IPool(addresses[j]);
poolsInfo[index] = PoolInfo({
token0: pool.token0(),
token1: pool.token1(),
index: j,
fee: pool.fee(),
feeProtocol: 0,
tickLower: pool.tickLower(),
tickUpper: pool.tickUpper(),
tick: pool.tick(),
sqrtPriceX96: pool.sqrtPriceX96()
});
index++;
}
}
return poolsInfo;
}
}
在这里需要注意的是,我们首先计算了返回池子的大小,然后再往里面添加数据。这么做看起来非常的不合理,但是在合约方法中,memory 数组是无法进行动态地添加数据的,这个是 Solidity 语法上的限制,为了性能上的优化,memory 定义的数组需要提前分配好内存。因此这是一个无奈之举。
pairs
数据主要是用于查询我们 DEX 是否支持某一交易对的交易,我们在介绍 2.创建池子 章节的时候就已经维护了 pairs
,因此我们只需要将其返回出去就好。
import "./Factory.sol";
import "./interfaces/IPoolManager.sol";
contract PoolManager is Factory, IPoolManager {
Pair[] public pairs;
function getPairs() external view override returns (Pair[] memory) {
return pairs;
}
//...
}
可以看到我们设计了一个 getPairs
方法用于返回数据。可能会有一些同学有这样的疑问,为什么要设计一个函数方法用于返回,合约里面的变量不是会自动生成对应的 getter 方法用于获取其值的吗?
是的没有错,solidity 是会自动给合约里面定义的 public 变量生成对应的 getter 方法,开发者可以不用再额外设计获取值的方法,但是对于变量是数组这个特殊情况,solidity 生成的 getter 方法并不会返回整个数据,而是需要调用者指定索引,只返回索引对应的值。这么设计的原因是避免一次返回过多的数据,在别的合约使用这份数据时产生不可控的 gas 费。
因此,对于数组这个特殊情况,如果我们需要它返回全部的内容,就需要自己写一个合约方法将其返回。
我们需要测试是否成功创建以及返回的池子信息是否正确,代码如下:
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("PoolManager", function () {
async function deployFixture() {
const manager = await hre.viem.deployContract("PoolManager");
const publicClient = await hre.viem.getPublicClient();
return {
manager,
publicClient,
};
}
it("getPairs & getAllPools", async function () {
const { manager } = await loadFixture(deployFixture);
const tokenA: `0x${string}` = "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984";
const tokenB: `0x${string}` = "0xEcd0D12E21805803f70de03B72B1C162dB0898d9";
const tokenC: `0x${string}` = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
const tokenD: `0x${string}` = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
// 创建 tokenA-tokenB
await manager.write.createAndInitializePoolIfNecessary([
{
token0: tokenA,
token1: tokenB,
fee: 3000,
tickLower: TickMath.getTickAtSqrtRatio(encodeSqrtRatioX96(1, 1)),
tickUpper: TickMath.getTickAtSqrtRatio(encodeSqrtRatioX96(10000, 1)),
sqrtPriceX96: BigInt(encodeSqrtRatioX96(100, 1).toString()),
},
]);
// 由于和前一个参数一样,会被合并
await manager.write.createAndInitializePoolIfNecessary([
{
token0: tokenA,
token1: tokenB,
fee: 3000,
tickLower: TickMath.getTickAtSqrtRatio(encodeSqrtRatioX96(1, 1)),
tickUpper: TickMath.getTickAtSqrtRatio(encodeSqrtRatioX96(10000, 1)),
sqrtPriceX96: BigInt(encodeSqrtRatioX96(100, 1).toString()),
},
]);
await manager.write.createAndInitializePoolIfNecessary([
{
token0: tokenC,
token1: tokenD,
fee: 2000,
tickLower: TickMath.getTickAtSqrtRatio(encodeSqrtRatioX96(100, 1)),
tickUpper: TickMath.getTickAtSqrtRatio(encodeSqrtRatioX96(5000, 1)),
sqrtPriceX96: BigInt(encodeSqrtRatioX96(200, 1).toString()),
},
]);
// 判断返回的 pairs 的数量是否正确
const pairs = await manager.read.getPairs();
expect(pairs.length).to.equal(2);
// 判断返回的 pools 的数量、参数是否正确
const pools = await manager.read.getAllPools();
expect(pools.length).to.equal(2);
expect(pools[0].token0).to.equal(tokenA);
expect(pools[0].token1).to.equal(tokenB);
expect(pools[0].sqrtPriceX96).to.equal(
BigInt(encodeSqrtRatioX96(100, 1).toString())
);
expect(pools[1].token0).to.equal(tokenC);
expect(pools[1].token1).to.equal(tokenD);
expect(pools[1].sqrtPriceX96).to.equal(
BigInt(encodeSqrtRatioX96(200, 1).toString())
);
});
});
你可以再补充一个异常情况的测试样例:
it("require token0 < token1", async function () {
const { manager } = await loadFixture(deployFixture);
const tokenA: `0x${string}` = "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984";
const tokenB: `0x${string}` = "0xEcd0D12E21805803f70de03B72B1C162dB0898d9";
await expect(
manager.write.createAndInitializePoolIfNecessary([
{
token0: tokenB,
token1: tokenA,
fee: 3000,
tickLower: TickMath.getTickAtSqrtRatio(encodeSqrtRatioX96(1, 1)),
tickUpper: TickMath.getTickAtSqrtRatio(encodeSqrtRatioX96(10000, 1)),
sqrtPriceX96: BigInt(encodeSqrtRatioX96(100, 1).toString()),
},
])
).to.be.rejected;
});
完整的单测代码在 demo-contract/test/wtfswap/PoolManager.ts 中,在实际项目中,你的单测应该覆盖到所有的逻辑分支,以保证合约的安全。