import { BigNumberish, constants, Wallet } from 'ethers' import { waffle, ethers } from 'hardhat' import { Fixture } from 'ethereum-waffle' import { TestPositionNFTOwner, MockTimeNonfungiblePositionManager, TestERC20, IWETH9, IUniswapV3Factory, SwapRouter, } from '../typechain' import completeFixture from './shared/completeFixture' import { computePoolAddress } from './shared/computePoolAddress' import { FeeAmount, MaxUint128, TICK_SPACINGS } from './shared/constants' import { encodePriceSqrt } from './shared/encodePriceSqrt' import { expect } from './shared/expect' import getPermitNFTSignature from './shared/getPermitNFTSignature' import { encodePath } from './shared/path' import poolAtAddress from './shared/poolAtAddress' import snapshotGasCost from './shared/snapshotGasCost' import { getMaxTick, getMinTick } from './shared/ticks' import { expandTo18Decimals } from './shared/expandTo18Decimals' import { sortedTokens } from './shared/tokenSort' import { extractJSONFromURI } from './shared/extractJSONFromURI' import { abi as IUniswapV3PoolABI } from '@uniswap/v3-core/artifacts/contracts/interfaces/IUniswapV3Pool.sol/IUniswapV3Pool.json' describe('NonfungiblePositionManager', () => { let wallets: Wallet[] let wallet: Wallet, other: Wallet const nftFixture: Fixture<{ nft: MockTimeNonfungiblePositionManager factory: IUniswapV3Factory tokens: [TestERC20, TestERC20, TestERC20] weth9: IWETH9 router: SwapRouter }> = async (wallets, provider) => { const { weth9, factory, tokens, nft, router } = await completeFixture(wallets, provider) // approve & fund wallets for (const token of tokens) { await token.approve(nft.address, constants.MaxUint256) await token.connect(other).approve(nft.address, constants.MaxUint256) await token.transfer(other.address, expandTo18Decimals(1_000_000)) } return { nft, factory, tokens, weth9, router, } } let factory: IUniswapV3Factory let nft: MockTimeNonfungiblePositionManager let tokens: [TestERC20, TestERC20, TestERC20] let weth9: IWETH9 let router: SwapRouter let loadFixture: ReturnType before('create fixture loader', async () => { wallets = await (ethers as any).getSigners() ;[wallet, other] = wallets loadFixture = waffle.createFixtureLoader(wallets) }) beforeEach('load fixture', async () => { ;({ nft, factory, tokens, weth9, router } = await loadFixture(nftFixture)) }) it('bytecode size', async () => { expect(((await nft.provider.getCode(nft.address)).length - 2) / 2).to.matchSnapshot() }) describe('#createAndInitializePoolIfNecessary', () => { it('creates the pool at the expected address', async () => { const expectedAddress = computePoolAddress( factory.address, [tokens[0].address, tokens[1].address], FeeAmount.MEDIUM ) const code = await wallet.provider.getCode(expectedAddress) expect(code).to.eq('0x') await nft.createAndInitializePoolIfNecessary( tokens[0].address, tokens[1].address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1) ) const codeAfter = await wallet.provider.getCode(expectedAddress) expect(codeAfter).to.not.eq('0x') }) it('is payable', async () => { await nft.createAndInitializePoolIfNecessary( tokens[0].address, tokens[1].address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1), { value: 1 } ) }) it('works if pool is created but not initialized', async () => { const expectedAddress = computePoolAddress( factory.address, [tokens[0].address, tokens[1].address], FeeAmount.MEDIUM ) await factory.createPool(tokens[0].address, tokens[1].address, FeeAmount.MEDIUM) const code = await wallet.provider.getCode(expectedAddress) expect(code).to.not.eq('0x') await nft.createAndInitializePoolIfNecessary( tokens[0].address, tokens[1].address, FeeAmount.MEDIUM, encodePriceSqrt(2, 1) ) }) it('works if pool is created and initialized', async () => { const expectedAddress = computePoolAddress( factory.address, [tokens[0].address, tokens[1].address], FeeAmount.MEDIUM ) await factory.createPool(tokens[0].address, tokens[1].address, FeeAmount.MEDIUM) const pool = new ethers.Contract(expectedAddress, IUniswapV3PoolABI, wallet) await pool.initialize(encodePriceSqrt(3, 1)) const code = await wallet.provider.getCode(expectedAddress) expect(code).to.not.eq('0x') await nft.createAndInitializePoolIfNecessary( tokens[0].address, tokens[1].address, FeeAmount.MEDIUM, encodePriceSqrt(4, 1) ) }) it('could theoretically use eth via multicall', async () => { const [token0, token1] = sortedTokens(weth9, tokens[0]) const createAndInitializePoolIfNecessaryData = nft.interface.encodeFunctionData( 'createAndInitializePoolIfNecessary', [token0.address, token1.address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1)] ) await nft.multicall([createAndInitializePoolIfNecessaryData], { value: expandTo18Decimals(1) }) }) it('gas', async () => { await snapshotGasCost( nft.createAndInitializePoolIfNecessary( tokens[0].address, tokens[1].address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1) ) ) }) }) describe('#mint', () => { it('fails if pool does not exist', async () => { await expect( nft.mint({ token0: tokens[0].address, token1: tokens[1].address, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, recipient: wallet.address, deadline: 1, fee: FeeAmount.MEDIUM, }) ).to.be.reverted }) it('fails if cannot transfer', async () => { await nft.createAndInitializePoolIfNecessary( tokens[0].address, tokens[1].address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1) ) await tokens[0].approve(nft.address, 0) await expect( nft.mint({ token0: tokens[0].address, token1: tokens[1].address, fee: FeeAmount.MEDIUM, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, recipient: wallet.address, deadline: 1, }) ).to.be.revertedWith('STF') }) it('creates a token', async () => { await nft.createAndInitializePoolIfNecessary( tokens[0].address, tokens[1].address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1) ) await nft.mint({ token0: tokens[0].address, token1: tokens[1].address, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), fee: FeeAmount.MEDIUM, recipient: other.address, amount0Desired: 15, amount1Desired: 15, amount0Min: 0, amount1Min: 0, deadline: 10, }) expect(await nft.balanceOf(other.address)).to.eq(1) expect(await nft.tokenOfOwnerByIndex(other.address, 0)).to.eq(1) const { fee, token0, token1, tickLower, tickUpper, liquidity, tokensOwed0, tokensOwed1, feeGrowthInside0LastX128, feeGrowthInside1LastX128, } = await nft.positions(1) expect(token0).to.eq(tokens[0].address) expect(token1).to.eq(tokens[1].address) expect(fee).to.eq(FeeAmount.MEDIUM) expect(tickLower).to.eq(getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM])) expect(tickUpper).to.eq(getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM])) expect(liquidity).to.eq(15) expect(tokensOwed0).to.eq(0) expect(tokensOwed1).to.eq(0) expect(feeGrowthInside0LastX128).to.eq(0) expect(feeGrowthInside1LastX128).to.eq(0) }) it('can use eth via multicall', async () => { const [token0, token1] = sortedTokens(weth9, tokens[0]) // remove any approval await weth9.approve(nft.address, 0) const createAndInitializeData = nft.interface.encodeFunctionData('createAndInitializePoolIfNecessary', [ token0.address, token1.address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1), ]) const mintData = nft.interface.encodeFunctionData('mint', [ { token0: token0.address, token1: token1.address, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), fee: FeeAmount.MEDIUM, recipient: other.address, amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 1, }, ]) const refundETHData = nft.interface.encodeFunctionData('refundETH') const balanceBefore = await wallet.getBalance() await nft.multicall([createAndInitializeData, mintData, refundETHData], { value: expandTo18Decimals(1), gasPrice: 0, // necessary so the balance doesn't change by anything that's not spent }) const balanceAfter = await wallet.getBalance() expect(balanceBefore.sub(balanceAfter)).to.eq(100) }) it('emits an event') it('gas first mint for pool', async () => { await nft.createAndInitializePoolIfNecessary( tokens[0].address, tokens[1].address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1) ) await snapshotGasCost( nft.mint({ token0: tokens[0].address, token1: tokens[1].address, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), fee: FeeAmount.MEDIUM, recipient: wallet.address, amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 10, }) ) }) it('gas first mint for pool using eth with zero refund', async () => { const [token0, token1] = sortedTokens(weth9, tokens[0]) await nft.createAndInitializePoolIfNecessary( token0.address, token1.address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1) ) await snapshotGasCost( nft.multicall( [ nft.interface.encodeFunctionData('mint', [ { token0: token0.address, token1: token1.address, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), fee: FeeAmount.MEDIUM, recipient: wallet.address, amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 10, }, ]), nft.interface.encodeFunctionData('refundETH'), ], { value: 100 } ) ) }) it('gas first mint for pool using eth with non-zero refund', async () => { const [token0, token1] = sortedTokens(weth9, tokens[0]) await nft.createAndInitializePoolIfNecessary( token0.address, token1.address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1) ) await snapshotGasCost( nft.multicall( [ nft.interface.encodeFunctionData('mint', [ { token0: token0.address, token1: token1.address, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), fee: FeeAmount.MEDIUM, recipient: wallet.address, amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 10, }, ]), nft.interface.encodeFunctionData('refundETH'), ], { value: 1000 } ) ) }) it('gas mint on same ticks', async () => { await nft.createAndInitializePoolIfNecessary( tokens[0].address, tokens[1].address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1) ) await nft.mint({ token0: tokens[0].address, token1: tokens[1].address, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), fee: FeeAmount.MEDIUM, recipient: other.address, amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 10, }) await snapshotGasCost( nft.mint({ token0: tokens[0].address, token1: tokens[1].address, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), fee: FeeAmount.MEDIUM, recipient: wallet.address, amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 10, }) ) }) it('gas mint for same pool, different ticks', async () => { await nft.createAndInitializePoolIfNecessary( tokens[0].address, tokens[1].address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1) ) await nft.mint({ token0: tokens[0].address, token1: tokens[1].address, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), fee: FeeAmount.MEDIUM, recipient: other.address, amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 10, }) await snapshotGasCost( nft.mint({ token0: tokens[0].address, token1: tokens[1].address, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]) + TICK_SPACINGS[FeeAmount.MEDIUM], tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]) - TICK_SPACINGS[FeeAmount.MEDIUM], fee: FeeAmount.MEDIUM, recipient: wallet.address, amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 10, }) ) }) }) describe('#increaseLiquidity', () => { const tokenId = 1 beforeEach('create a position', async () => { await nft.createAndInitializePoolIfNecessary( tokens[0].address, tokens[1].address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1) ) await nft.mint({ token0: tokens[0].address, token1: tokens[1].address, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), fee: FeeAmount.MEDIUM, recipient: other.address, amount0Desired: 1000, amount1Desired: 1000, amount0Min: 0, amount1Min: 0, deadline: 1, }) }) it('increases position liquidity', async () => { await nft.increaseLiquidity({ tokenId: tokenId, amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 1, }) const { liquidity } = await nft.positions(tokenId) expect(liquidity).to.eq(1100) }) it('emits an event') it('can be paid with ETH', async () => { const [token0, token1] = sortedTokens(tokens[0], weth9) const tokenId = 1 await nft.createAndInitializePoolIfNecessary( token0.address, token1.address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1) ) const mintData = nft.interface.encodeFunctionData('mint', [ { token0: token0.address, token1: token1.address, fee: FeeAmount.MEDIUM, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), recipient: other.address, amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 1, }, ]) const refundETHData = nft.interface.encodeFunctionData('unwrapWETH9', [0, other.address]) await nft.multicall([mintData, refundETHData], { value: expandTo18Decimals(1) }) const increaseLiquidityData = nft.interface.encodeFunctionData('increaseLiquidity', [ { tokenId: tokenId, amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 1, }, ]) await nft.multicall([increaseLiquidityData, refundETHData], { value: expandTo18Decimals(1) }) }) it('gas', async () => { await snapshotGasCost( nft.increaseLiquidity({ tokenId: tokenId, amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 1, }) ) }) }) describe('#decreaseLiquidity', () => { const tokenId = 1 beforeEach('create a position', async () => { await nft.createAndInitializePoolIfNecessary( tokens[0].address, tokens[1].address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1) ) await nft.mint({ token0: tokens[0].address, token1: tokens[1].address, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), fee: FeeAmount.MEDIUM, recipient: other.address, amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 1, }) }) it('emits an event') it('fails if past deadline', async () => { await nft.setTime(2) await expect( nft.connect(other).decreaseLiquidity({ tokenId, liquidity: 50, amount0Min: 0, amount1Min: 0, deadline: 1 }) ).to.be.revertedWith('Transaction too old') }) it('cannot be called by other addresses', async () => { await expect( nft.decreaseLiquidity({ tokenId, liquidity: 50, amount0Min: 0, amount1Min: 0, deadline: 1 }) ).to.be.revertedWith('Not approved') }) it('decreases position liquidity', async () => { await nft.connect(other).decreaseLiquidity({ tokenId, liquidity: 25, amount0Min: 0, amount1Min: 0, deadline: 1 }) const { liquidity } = await nft.positions(tokenId) expect(liquidity).to.eq(75) }) it('is payable', async () => { await nft .connect(other) .decreaseLiquidity({ tokenId, liquidity: 25, amount0Min: 0, amount1Min: 0, deadline: 1 }, { value: 1 }) }) it('accounts for tokens owed', async () => { await nft.connect(other).decreaseLiquidity({ tokenId, liquidity: 25, amount0Min: 0, amount1Min: 0, deadline: 1 }) const { tokensOwed0, tokensOwed1 } = await nft.positions(tokenId) expect(tokensOwed0).to.eq(24) expect(tokensOwed1).to.eq(24) }) it('can decrease for all the liquidity', async () => { await nft.connect(other).decreaseLiquidity({ tokenId, liquidity: 100, amount0Min: 0, amount1Min: 0, deadline: 1 }) const { liquidity } = await nft.positions(tokenId) expect(liquidity).to.eq(0) }) it('cannot decrease for more than all the liquidity', async () => { await expect( nft.connect(other).decreaseLiquidity({ tokenId, liquidity: 101, amount0Min: 0, amount1Min: 0, deadline: 1 }) ).to.be.reverted }) it('cannot decrease for more than the liquidity of the nft position', async () => { await nft.mint({ token0: tokens[0].address, token1: tokens[1].address, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), fee: FeeAmount.MEDIUM, recipient: other.address, amount0Desired: 200, amount1Desired: 200, amount0Min: 0, amount1Min: 0, deadline: 1, }) await expect( nft.connect(other).decreaseLiquidity({ tokenId, liquidity: 101, amount0Min: 0, amount1Min: 0, deadline: 1 }) ).to.be.reverted }) it('gas partial decrease', async () => { await snapshotGasCost( nft.connect(other).decreaseLiquidity({ tokenId, liquidity: 50, amount0Min: 0, amount1Min: 0, deadline: 1 }) ) }) it('gas complete decrease', async () => { await snapshotGasCost( nft.connect(other).decreaseLiquidity({ tokenId, liquidity: 100, amount0Min: 0, amount1Min: 0, deadline: 1 }) ) }) }) describe('#collect', () => { const tokenId = 1 beforeEach('create a position', async () => { await nft.createAndInitializePoolIfNecessary( tokens[0].address, tokens[1].address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1) ) await nft.mint({ token0: tokens[0].address, token1: tokens[1].address, fee: FeeAmount.MEDIUM, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), recipient: other.address, amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 1, }) }) it('emits an event') it('cannot be called by other addresses', async () => { await expect( nft.collect({ tokenId, recipient: wallet.address, amount0Max: MaxUint128, amount1Max: MaxUint128, }) ).to.be.revertedWith('Not approved') }) it('cannot be called with 0 for both amounts', async () => { await expect( nft.connect(other).collect({ tokenId, recipient: wallet.address, amount0Max: 0, amount1Max: 0, }) ).to.be.reverted }) it('no op if no tokens are owed', async () => { await expect( nft.connect(other).collect({ tokenId, recipient: wallet.address, amount0Max: MaxUint128, amount1Max: MaxUint128, }) ) .to.not.emit(tokens[0], 'Transfer') .to.not.emit(tokens[1], 'Transfer') }) it('transfers tokens owed from burn', async () => { await nft.connect(other).decreaseLiquidity({ tokenId, liquidity: 50, amount0Min: 0, amount1Min: 0, deadline: 1 }) const poolAddress = computePoolAddress(factory.address, [tokens[0].address, tokens[1].address], FeeAmount.MEDIUM) await expect( nft.connect(other).collect({ tokenId, recipient: wallet.address, amount0Max: MaxUint128, amount1Max: MaxUint128, }) ) .to.emit(tokens[0], 'Transfer') .withArgs(poolAddress, wallet.address, 49) .to.emit(tokens[1], 'Transfer') .withArgs(poolAddress, wallet.address, 49) }) it('gas transfers both', async () => { await nft.connect(other).decreaseLiquidity({ tokenId, liquidity: 50, amount0Min: 0, amount1Min: 0, deadline: 1 }) await snapshotGasCost( nft.connect(other).collect({ tokenId, recipient: wallet.address, amount0Max: MaxUint128, amount1Max: MaxUint128, }) ) }) it('gas transfers token0 only', async () => { await nft.connect(other).decreaseLiquidity({ tokenId, liquidity: 50, amount0Min: 0, amount1Min: 0, deadline: 1 }) await snapshotGasCost( nft.connect(other).collect({ tokenId, recipient: wallet.address, amount0Max: MaxUint128, amount1Max: 0, }) ) }) it('gas transfers token1 only', async () => { await nft.connect(other).decreaseLiquidity({ tokenId, liquidity: 50, amount0Min: 0, amount1Min: 0, deadline: 1 }) await snapshotGasCost( nft.connect(other).collect({ tokenId, recipient: wallet.address, amount0Max: 0, amount1Max: MaxUint128, }) ) }) }) describe('#burn', () => { const tokenId = 1 beforeEach('create a position', async () => { await nft.createAndInitializePoolIfNecessary( tokens[0].address, tokens[1].address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1) ) await nft.mint({ token0: tokens[0].address, token1: tokens[1].address, fee: FeeAmount.MEDIUM, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), recipient: other.address, amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 1, }) }) it('emits an event') it('cannot be called by other addresses', async () => { await expect(nft.burn(tokenId)).to.be.revertedWith('Not approved') }) it('cannot be called while there is still liquidity', async () => { await expect(nft.connect(other).burn(tokenId)).to.be.revertedWith('Not cleared') }) it('cannot be called while there is still partial liquidity', async () => { await nft.connect(other).decreaseLiquidity({ tokenId, liquidity: 50, amount0Min: 0, amount1Min: 0, deadline: 1 }) await expect(nft.connect(other).burn(tokenId)).to.be.revertedWith('Not cleared') }) it('cannot be called while there is still tokens owed', async () => { await nft.connect(other).decreaseLiquidity({ tokenId, liquidity: 100, amount0Min: 0, amount1Min: 0, deadline: 1 }) await expect(nft.connect(other).burn(tokenId)).to.be.revertedWith('Not cleared') }) it('deletes the token', async () => { await nft.connect(other).decreaseLiquidity({ tokenId, liquidity: 100, amount0Min: 0, amount1Min: 0, deadline: 1 }) await nft.connect(other).collect({ tokenId, recipient: wallet.address, amount0Max: MaxUint128, amount1Max: MaxUint128, }) await nft.connect(other).burn(tokenId) await expect(nft.positions(tokenId)).to.be.revertedWith('Invalid token ID') }) it('gas', async () => { await nft.connect(other).decreaseLiquidity({ tokenId, liquidity: 100, amount0Min: 0, amount1Min: 0, deadline: 1 }) await nft.connect(other).collect({ tokenId, recipient: wallet.address, amount0Max: MaxUint128, amount1Max: MaxUint128, }) await snapshotGasCost(nft.connect(other).burn(tokenId)) }) }) describe('#transferFrom', () => { const tokenId = 1 beforeEach('create a position', async () => { await nft.createAndInitializePoolIfNecessary( tokens[0].address, tokens[1].address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1) ) await nft.mint({ token0: tokens[0].address, token1: tokens[1].address, fee: FeeAmount.MEDIUM, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), recipient: other.address, amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 1, }) }) it('can only be called by authorized or owner', async () => { await expect(nft.transferFrom(other.address, wallet.address, tokenId)).to.be.revertedWith( 'ERC721: transfer caller is not owner nor approved' ) }) it('changes the owner', async () => { await nft.connect(other).transferFrom(other.address, wallet.address, tokenId) expect(await nft.ownerOf(tokenId)).to.eq(wallet.address) }) it('removes existing approval', async () => { await nft.connect(other).approve(wallet.address, tokenId) expect(await nft.getApproved(tokenId)).to.eq(wallet.address) await nft.transferFrom(other.address, wallet.address, tokenId) expect(await nft.getApproved(tokenId)).to.eq(constants.AddressZero) }) it('gas', async () => { await snapshotGasCost(nft.connect(other).transferFrom(other.address, wallet.address, tokenId)) }) it('gas comes from approved', async () => { await nft.connect(other).approve(wallet.address, tokenId) await snapshotGasCost(nft.transferFrom(other.address, wallet.address, tokenId)) }) }) describe('#permit', () => { it('emits an event') describe('owned by eoa', () => { const tokenId = 1 beforeEach('create a position', async () => { await nft.createAndInitializePoolIfNecessary( tokens[0].address, tokens[1].address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1) ) await nft.mint({ token0: tokens[0].address, token1: tokens[1].address, fee: FeeAmount.MEDIUM, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), recipient: other.address, amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 1, }) }) it('changes the operator of the position and increments the nonce', async () => { const { v, r, s } = await getPermitNFTSignature(other, nft, wallet.address, tokenId, 1) await nft.permit(wallet.address, tokenId, 1, v, r, s) expect((await nft.positions(tokenId)).nonce).to.eq(1) expect((await nft.positions(tokenId)).operator).to.eq(wallet.address) }) it('cannot be called twice with the same signature', async () => { const { v, r, s } = await getPermitNFTSignature(other, nft, wallet.address, tokenId, 1) await nft.permit(wallet.address, tokenId, 1, v, r, s) await expect(nft.permit(wallet.address, tokenId, 1, v, r, s)).to.be.reverted }) it('fails with invalid signature', async () => { const { v, r, s } = await getPermitNFTSignature(wallet, nft, wallet.address, tokenId, 1) await expect(nft.permit(wallet.address, tokenId, 1, v + 3, r, s)).to.be.revertedWith('Invalid signature') }) it('fails with signature not from owner', async () => { const { v, r, s } = await getPermitNFTSignature(wallet, nft, wallet.address, tokenId, 1) await expect(nft.permit(wallet.address, tokenId, 1, v, r, s)).to.be.revertedWith('Unauthorized') }) it('fails with expired signature', async () => { await nft.setTime(2) const { v, r, s } = await getPermitNFTSignature(other, nft, wallet.address, tokenId, 1) await expect(nft.permit(wallet.address, tokenId, 1, v, r, s)).to.be.revertedWith('Permit expired') }) it('gas', async () => { const { v, r, s } = await getPermitNFTSignature(other, nft, wallet.address, tokenId, 1) await snapshotGasCost(nft.permit(wallet.address, tokenId, 1, v, r, s)) }) }) describe('owned by verifying contract', () => { const tokenId = 1 let testPositionNFTOwner: TestPositionNFTOwner beforeEach('deploy test owner and create a position', async () => { testPositionNFTOwner = (await ( await ethers.getContractFactory('TestPositionNFTOwner') ).deploy()) as TestPositionNFTOwner await nft.createAndInitializePoolIfNecessary( tokens[0].address, tokens[1].address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1) ) await nft.mint({ token0: tokens[0].address, token1: tokens[1].address, fee: FeeAmount.MEDIUM, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), recipient: testPositionNFTOwner.address, amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 1, }) }) it('changes the operator of the position and increments the nonce', async () => { const { v, r, s } = await getPermitNFTSignature(other, nft, wallet.address, tokenId, 1) await testPositionNFTOwner.setOwner(other.address) await nft.permit(wallet.address, tokenId, 1, v, r, s) expect((await nft.positions(tokenId)).nonce).to.eq(1) expect((await nft.positions(tokenId)).operator).to.eq(wallet.address) }) it('fails if owner contract is owned by different address', async () => { const { v, r, s } = await getPermitNFTSignature(other, nft, wallet.address, tokenId, 1) await testPositionNFTOwner.setOwner(wallet.address) await expect(nft.permit(wallet.address, tokenId, 1, v, r, s)).to.be.revertedWith('Unauthorized') }) it('fails with signature not from owner', async () => { const { v, r, s } = await getPermitNFTSignature(wallet, nft, wallet.address, tokenId, 1) await testPositionNFTOwner.setOwner(other.address) await expect(nft.permit(wallet.address, tokenId, 1, v, r, s)).to.be.revertedWith('Unauthorized') }) it('fails with expired signature', async () => { await nft.setTime(2) const { v, r, s } = await getPermitNFTSignature(other, nft, wallet.address, tokenId, 1) await testPositionNFTOwner.setOwner(other.address) await expect(nft.permit(wallet.address, tokenId, 1, v, r, s)).to.be.revertedWith('Permit expired') }) it('gas', async () => { const { v, r, s } = await getPermitNFTSignature(other, nft, wallet.address, tokenId, 1) await testPositionNFTOwner.setOwner(other.address) await snapshotGasCost(nft.permit(wallet.address, tokenId, 1, v, r, s)) }) }) }) describe('multicall exit', () => { const tokenId = 1 beforeEach('create a position', async () => { await nft.createAndInitializePoolIfNecessary( tokens[0].address, tokens[1].address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1) ) await nft.mint({ token0: tokens[0].address, token1: tokens[1].address, fee: FeeAmount.MEDIUM, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), recipient: other.address, amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 1, }) }) async function exit({ nft, liquidity, tokenId, amount0Min, amount1Min, recipient, }: { nft: MockTimeNonfungiblePositionManager tokenId: BigNumberish liquidity: BigNumberish amount0Min: BigNumberish amount1Min: BigNumberish recipient: string }) { const decreaseLiquidityData = nft.interface.encodeFunctionData('decreaseLiquidity', [ { tokenId, liquidity, amount0Min, amount1Min, deadline: 1 }, ]) const collectData = nft.interface.encodeFunctionData('collect', [ { tokenId, recipient, amount0Max: MaxUint128, amount1Max: MaxUint128, }, ]) const burnData = nft.interface.encodeFunctionData('burn', [tokenId]) return nft.multicall([decreaseLiquidityData, collectData, burnData]) } it('executes all the actions', async () => { const pool = poolAtAddress( computePoolAddress(factory.address, [tokens[0].address, tokens[1].address], FeeAmount.MEDIUM), wallet ) await expect( exit({ nft: nft.connect(other), tokenId, liquidity: 100, amount0Min: 0, amount1Min: 0, recipient: wallet.address, }) ) .to.emit(pool, 'Burn') .to.emit(pool, 'Collect') }) it('gas', async () => { await snapshotGasCost( exit({ nft: nft.connect(other), tokenId, liquidity: 100, amount0Min: 0, amount1Min: 0, recipient: wallet.address, }) ) }) }) describe('#tokenURI', async () => { const tokenId = 1 beforeEach('create a position', async () => { await nft.createAndInitializePoolIfNecessary( tokens[0].address, tokens[1].address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1) ) await nft.mint({ token0: tokens[0].address, token1: tokens[1].address, fee: FeeAmount.MEDIUM, tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), recipient: other.address, amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 1, }) }) it('reverts for invalid token id', async () => { await expect(nft.tokenURI(tokenId + 1)).to.be.reverted }) it('returns a data URI with correct mime type', async () => { expect(await nft.tokenURI(tokenId)).to.match(/data:application\/json;base64,.+/) }) it('content is valid JSON and structure', async () => { const content = extractJSONFromURI(await nft.tokenURI(tokenId)) expect(content).to.haveOwnProperty('name').is.a('string') expect(content).to.haveOwnProperty('description').is.a('string') expect(content).to.haveOwnProperty('image').is.a('string') }) }) describe('fees accounting', () => { beforeEach('create two positions', async () => { await nft.createAndInitializePoolIfNecessary( tokens[0].address, tokens[1].address, FeeAmount.MEDIUM, encodePriceSqrt(1, 1) ) // nft 1 earns 25% of fees await nft.mint({ token0: tokens[0].address, token1: tokens[1].address, fee: FeeAmount.MEDIUM, tickLower: getMinTick(FeeAmount.MEDIUM), tickUpper: getMaxTick(FeeAmount.MEDIUM), amount0Desired: 100, amount1Desired: 100, amount0Min: 0, amount1Min: 0, deadline: 1, recipient: wallet.address, }) // nft 2 earns 75% of fees await nft.mint({ token0: tokens[0].address, token1: tokens[1].address, fee: FeeAmount.MEDIUM, tickLower: getMinTick(FeeAmount.MEDIUM), tickUpper: getMaxTick(FeeAmount.MEDIUM), amount0Desired: 300, amount1Desired: 300, amount0Min: 0, amount1Min: 0, deadline: 1, recipient: wallet.address, }) }) describe('10k of token0 fees collect', () => { beforeEach('swap for ~10k of fees', async () => { const swapAmount = 3_333_333 await tokens[0].approve(router.address, swapAmount) await router.exactInput({ recipient: wallet.address, deadline: 1, path: encodePath([tokens[0].address, tokens[1].address], [FeeAmount.MEDIUM]), amountIn: swapAmount, amountOutMinimum: 0, }) }) it('expected amounts', async () => { const { amount0: nft1Amount0, amount1: nft1Amount1 } = await nft.callStatic.collect({ tokenId: 1, recipient: wallet.address, amount0Max: MaxUint128, amount1Max: MaxUint128, }) const { amount0: nft2Amount0, amount1: nft2Amount1 } = await nft.callStatic.collect({ tokenId: 2, recipient: wallet.address, amount0Max: MaxUint128, amount1Max: MaxUint128, }) expect(nft1Amount0).to.eq(2501) expect(nft1Amount1).to.eq(0) expect(nft2Amount0).to.eq(7503) expect(nft2Amount1).to.eq(0) }) it('actually collected', async () => { const poolAddress = computePoolAddress( factory.address, [tokens[0].address, tokens[1].address], FeeAmount.MEDIUM ) await expect( nft.collect({ tokenId: 1, recipient: wallet.address, amount0Max: MaxUint128, amount1Max: MaxUint128, }) ) .to.emit(tokens[0], 'Transfer') .withArgs(poolAddress, wallet.address, 2501) .to.not.emit(tokens[1], 'Transfer') await expect( nft.collect({ tokenId: 2, recipient: wallet.address, amount0Max: MaxUint128, amount1Max: MaxUint128, }) ) .to.emit(tokens[0], 'Transfer') .withArgs(poolAddress, wallet.address, 7503) .to.not.emit(tokens[1], 'Transfer') }) }) }) })