朋友们大家好!今天我们进入UniswapV3的periphery库,来读NonfungiblePositionManager.sol。
在读完pool之后,剩下的内容就都没有那么长、那么复杂了!建立了充分的信心!
这个文件的名字很长,分开来看Nonfungible是借用了Non-Fungible Token的意思,PositionManager是仓位管理,连起来就是将V3中的流动性仓位包装成符合ERC-721标准的NFT。
合约声明与继承

代码继承了一系列“基础组件”,用到的时候我们会逐一去看。
核心数据结构Position
这个结构体完整描述了一个流动性仓位的信息。

nonce:用于permits授权的计数器
operator:被授权者地址
poolId:池子pool的ID
tickLower和tickUpper:仓位价格的上下限;
liquidity:当前仓位的流动性数量;
feeGrowthInside0LastX128和feeGrowthInside1LastX128:两个token的手续费快照;
tokensOwed0和tokensOwed1:可供提取的token数量;
存储映射

定义了三个mapping关系,_poolIds是给每一个pool地址一个编号,可以根据地址找到对应的编号;_poolIdToPoolKey根据poolid可以找到pool的更多信息,包括池中的token和费率fee;_positions是每一个仓位都有一个编号,基于NFT的tokenId可以找到对应的仓位详细信息。
仓位计数器

_nextId是tokenId的计数,从1开始;_nextPoolId是poolId的计数,也是从1开始。注释里说跳过0,是为了避免和默认值0混淆。
链上元数据生成

_tokenDescriptor是一个immutable不可变的常量,用来存储生成NFT元数据的合约地址。UniswapV3的仓位NFT可以在钱包里显示为一张包含价格区间、流动性信息的图片,就是这个合约在发挥作用。设置为immutable,就可以在部署确定后硬编码入合约字节码,后续无法更改也省gas。
构造函数

在构造函数中分别初始化了两个父合约ERC721Permit和PeripheryImmutableState。调用ERC721Permit为NFT设定名称、代号和版本号,调用PeripheryImmutableState保存把传入的工厂地址和WETH代币地址。
最后初始化当前合约的变量,把_tokenDescriptor_传入当前合约中,供后续URI调用。
函数positions
这是是一个获取仓位信息的函数,入参是tokenId,出参返回nonce:permits授权的计数器;operator:被授权者地址;token0和token1:两种token;fee:手续费费率;tickLower和tickUpper:仓位价格的上下限;liquidity:当前仓位的流动性数量;feeGrowthInside0LastX128和feeGrowthInside1LastX128:两个token的手续费快照;tokensOwed0和tokensOwed1:可供提取的token数量;

99行进入函数体,逻辑并不复杂,基于tokenId找到对应的仓位信息,在仓位信息中获取poolId,再利用poolId去poolKey中查找池子的基础信息。最后把所有信息拼接在一起返回。

函数cachePoolKey
这是给新pool分配poolId,并写入poolIdToPoolKey的函数

入参传入pool地址和这个pool的核心信息poolKey,出参返回这个pool的poolId。
函数体也比较简单,首先用池子地址pool去类型为mapping的_poolIds中查询对应的poolId,如果是0证明还没有创建过poolId,把_nextPoolId+1之后赋值给poolId。由于是首次创建,因此同步把poolKey的信息写入对应的_poolIdToPoolKey中。
函数mint
mint是铸造函数,目的是创建新的仓位NFT

入参是类型为MintParams的params,这个结构体在接口文件contracts/interfaces/INonfungiblePositionManager.sol中有定义,字段内容包括代币、手续费率、仓位价格上下限、预期投入代币数量、最小投入代币数量、接收NFT凭证地址和到期时间。

回到manager文件中,通过MintParams结构体一次性将铸造所需的所有字段传入。出参四个字段分别是tokenId
流动性liquidity和两个token的数量。

139行进入函数体,调用了addLiquidity函数,函数来源于contracts/base/LiquidityManagement.sol。内容比较长我就不贴进来了,主要是为了计算给定两种代币数量,在当前价格和指定做市区间内,可以铸造多少流动性?主要流程是①从Slot0中获取当前价格②基于价格上下限tick计算出对应的√P③分别计算基于token0和token1的数量能够支持的最大流动性,两者取小值④基于计算出的最大流动性分别计算实际需要两种token各多少。计算完成之后调用mint函数完成铸造,从指定地址转出需要的代币数量。

调用_mint函数,创建tokenid,向接收地址铸造NFT。调用PositionKey.compute记录仓位的核心信息。
159行调用了pool的position函数,记录当前仓位手续费。

如果pool是新创建的,创建新的poolid,否则直接获取poolid。
168行基于新创建的tokenid记录仓位信息,调用的就是上边提到的Position函数。
181行,规定动作做完之后,对外广播事件IncreaseLiquidity,发布新仓位的信息。
修饰器isAuthorizedForToken

主要目的是检查调用者是否有权限操作该tokenID,_isApprovedOrOwner是ERC-721提供的标准函数,用于检查调某地址是否被授权或者是NFT的所有者,如果没有权限则返回'Not approved'。
函数tokenURI

这是一个基于tokenId,返回对应url的函数。
首先判断tokenId是否存在,确保没有被销毁,否则直接回滚。如果tokenId存在,_tokenDescriptor是构造函数中保存的描述器合约地址,将tokenid和manager地址传给描述器合约,将会在脸上生成一个包含仓位信息的图片,并将其编码为URL回传。
函数baseURI
这是一个空白函数,函数体里什么也没有实现。在注释里写,这样做为了节约字节码。父合约强制要求必须实现,但实际这个函数用不到,所以就空实现了这个函数,修饰符用了pure,编译器就就不会读写状态,空的pure函数编译之后几乎不占用字节码。

函数increaseLiquidity
这是一个名叫“增加流动性”的函数,用于在当前价格区间内,处理新的流动性注入、手续费结算和仓位更新。过程是先和Pool交互,然后更新NFT的记录。

函数入参是一个IncreaseLiquidityParams类型的数组,把需要调整流动性用到的相关参数都传入。
出参是流动性变量和两个代币数量的变化。

209行定义了一个Position类型的变量,基于传入一系列参数中的tokenId获取对应的仓位信息。
211行的poolKey,是基于tokenId获取的仓位信息中的poolId,获取的池子的核心信息。
214行调用addLiquidity函数,计算出在当前价格区间内,基于投入的代币数量可以增加的流动性。

229行,基于合约地址和提供流动性的价格区间计算出poolKey,再通过这个poolKey调用Position函数,可以获取该仓位的手续费快照feeGrowthInside0LastX128。
pool.positions(positionKey)中获取的feeGrowthInside1LastX128是当前最新的手续费快照,这是一个跨合约调用,直接去链上读取的Pool合约中存储的仓位数据。在Pool合约内部,feeGrowthInside是实时维护的,每一次swap产生手续费都会积累。
而position.feeGrowthInside1LastX128是manager这个合约自己存储的仓位记录,存储在_positions[tokenId],只有主动调用合约执行手续费结算时,才会被更新。
因此236行两个手续费相减就是这段时间内单位流动性对应的手续费增量,乘上之前的流动性,获得的就是对应仓位之前应得的手续费。把手续费累加到tokensOwed上,得到可供提取的代币数量。
241行同理对token1做一样的处理。
249行,在处理完成后把最新的手续费快照数据更新到本地合约中,并且把新增加的流动性加入仓位记录。
最后253行,完成事件广播。
函数decreaseLiquidity
减少流动性的函数原理和增加流动性逻辑类似,就不再重复。
函数collect
这是提取手续费的函数,主要路径是调用pool.collect函数,将代币从pool合约转到指定的接收地址,并减少对应的可提取金额。

函数入参是一个结构体,包含了提取手续费的一系列参数。
出参是提取的两种代币的数量。

316行,首先校验传入参数中希望提取的最大数量最少要有一个大于0,否则就没有必要提取了。
318行,如果用户传入的提取地址recipient是0地址,就以当前合约地址作为接收地址。这是一种常见的健壮性设计,避免用户输入错误,也便于后续逻辑处理。
320至322行,获取当前tokenId对应的仓位信息,以及池子的核心信息。
324行通过工厂地址factory和池子的核心信息poolKey计算得出池子地址。这是V3外围合约确定Pool地址的通用做法。
326行,获取当前仓位信息,得到两种代币可供提取余额。

329至351行,是在结算手续费,并将这部分手续费加到可提取的代币数量上。结算手续费的内容在increaseLiquidity函数中有详细解释,这里就不再重复。更新手续费费的动作也将合约的快照更新为最新值,确保下次结算的起点正确。

amount0Collect是用户希望提取amount0Max和实际可提取tokensOwed0中较小的值。把这两个数作为提取金额传入pool中的collect函数,获得实际提取金额,并完成转账。

371行,扣减本次提取金额后,更新本地合约中存储的可提取金额。
最后373行对外广播本次提取代币的时间。
函数burn
这是一个销毁函数。

当没有流动性、可提取的手续费都清空之后,会删除_positions中记录的仓位信息,最后调用_burn函数销毁tokenId。
函数_getAndIncrementNonce
这是一个获取并递增许可计数器的函数

函数体里非常简单,是一个nonce的递增运算,在执行permit时需要消耗一个nonce来防止重放攻击。当调用时先返回当前的nonce值用于签名,然后再+1,这样每次签名都消耗一个唯一的nonce,旧的签名随即失效。
函数getApproved
这是一个查询授权地址的函数。

这是一个ERC-721定义的查询函数,返回被授权操作指定的NFT地址。正常情况下会有一个独立的mapping关系来存储授权地址。但是position结构体中有operator字段,就没必要再额外存一份了,直接返回就可以。
函数_approve
这是一个设置授权地址的函数。

函数体就两行,397行把入参传入的地址写入position结构体中的operator字段,然后398行对外发布事件。
总结
NonfungiblePositionManager.sol文件就读完了,这是V3外部协议中用户可以直接交互的合约,把core合约里的复杂逻辑,包装成标准、可管理、可转移的ERC-721NFT。让每一个流动性仓位都变成了独一无二的“艺术品”凭证。
朋友们,下次再见啦!
夜雨聆风