P104_PoolManager_合约简介


文档摘要

本节作者:@yeezo 这一讲我们将引导大家完成 合约的开发。 合约简介 合约的主要作用是控制池子,并且提供池子的信息来供前端展示。 合约继承了 合约,开发者在想要获取池子信息的时候,无需向更底层的 进行交互,只需要感知到 即可。 和 合约关系紧密,可以认为是同一个合约,但是为了合约功能的边界清晰我们进行了拆分。他们的关系可以如下图所示: PoolManager 合约主要需要的功能比较简单,但是我们可以在里面学到一些有趣的细节知识。 合约开发 完整的代码在 demo-contract/contracts/wtfswap/PoolManager.sol 中。 在开始写合约之前,我们可以先停下来想一下,我们合约的功能应该需要包含哪些方法。

本节作者:@yeezo

这一讲我们将引导大家完成 PoolManager.sol 合约的开发。

合约简介

PoolManager 合约的主要作用是控制池子,并且提供池子的信息来供前端展示。PoolManger 合约继承了 Factory 合约,开发者在想要获取池子信息的时候,无需向更底层的 Factory 进行交互,只需要感知到 PoolManager 即可。

PoolManagerFactory 合约关系紧密,可以认为是同一个合约,但是为了合约功能的边界清晰我们进行了拆分。他们的关系可以如下图所示:

PoolManager

PoolManager 合约主要需要的功能比较简单,但是我们可以在里面学到一些有趣的细节知识。

合约开发

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

在开始写合约之前,我们可以先停下来想一下,我们合约的功能应该需要包含哪些方法。在前端页面,我们有一个表格用于展示池子的一些信息,因此我们需要一个返回池子信息的方法。另外,在 position 前端页面有添加池子功能,因此我们还需要设计一个添加池子的方法。

当然,这个合约并不是必须的,它只是为了给前端提供数据,本教程将数据保存在服务端只是为了教学。实际开发中,更加推荐将这些数据存储在服务端(Uniswap 的做法),通过调用服务端的接口来保存、获取这些数据,这样的话既可以提高操作数据的响应速度,又可以减少合约存储数据的 gas 开销。

1. 合约继承

在之前的介绍中,我们设计了 PoolManager 合约需要继承 Factory 合约(可以参考上一节的内容),因此在写合约方法之前,我们需要继承一下。

import "./Factory.sol"; import "./interfaces/IPoolManager.sol"; contract PoolManager is Factory, IPoolManager { // ... }

在继承之后,我们就可以使用 Factory 合约里面设计的方法了。

2. 创建池子

首先我们考虑创建池子的功能。在之前的介绍中,我们知道了一个池子的唯一性是由代币对、价格区间以及费用确定的,因此该方法的输入参数应该包含这几项,另外该函数应该返回池子对应的合约地址。在合约的类型定义中,我们可以这么设计:

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 的入参 tokenAtokenB 没有顺序要求,但是在 createAndInitializePoolIfNecessary 中我们创建的时候要求 token0 < token1。因为在这个方法中需要传入初始化的价格,而在交易池中价格是按照 token0/token1 的方式计算的,做这个限制可以避免 LP 不小心初始化错误的价格。在后续的代码和测试中,我们也约定了 tokenAtokenB 是未排序的,而 token0token1 是排序的,这样也便于我们理解代码。

至此,创建池子的方法我们就算是实现完成了。

3. 返回全部池子信息

由于池子的信息是在 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); 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[i] = 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() }); } } return poolsInfo; } }

在这里需要注意的是,我们首先计算了返回池子的大小,然后再往里面添加数据。这么做看起来非常的不合理,但是在合约方法中,memory 数组是无法进行动态地添加数据的,这个是 Solidity 语法上的限制,为了性能上的优化,memory 定义的数组需要提前分配好内存。因此这是一个无奈之举。

4. 返回 pairs 数据

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 中,在实际项目中,你的单测应该覆盖到所有的逻辑分支,以保证合约的安全。


作者与出处
原作者: WTFAcademy
来源:WTFAcademy
许可证:MIT
整理: 灏天文库整理
由灏天文库结构化整理,提供目录导航、全文检索与在线阅读,便于系统化学习
发布者: 作者: WTFAcademy 转发
评论区 (0)
U