Uniswap V3 - Liquidity Management

Uniswap V3 - Liquidity Management

·

5 min read

We have seen on how to make swaps using V3 and now let us look into how to mint a new position, add/remove liquidity and collect LP on the protocol in our smart contracts.

Full code can be found here: https://github.com/jveer634/uniswap-v3-demo/blob/master/contracts/Liquidity.sol

Minting a new position

We all know that V3 works using tick math for providing concentrated liquidity feature. Which means, even the pool is same, there are multiple positions where a user can add their liquidity. And each of the position that the user placed their liquidity is identified by using a Non-fungible token provided by the Non-Fungible Position Manager.

The NFP manager is responsible for generating positions, mapping tokenIds to the user and also redirecting the stake to the given Pool.

    function mint(
        uint amount0,
        uint amount1
    )
        external
        returns (
            uint256 tokenId,
            uint128 liquidity,
            uint256 amount0Deposited,
            uint256 amount1Deposited
        )
    {
        DAI.safeTransferFrom(msg.sender, address(this), amount0);
        USDC.safeTransferFrom(msg.sender, address(this), amount1);

        DAI.approve(address(positionManager), amount0);
        USDC.approve(address(positionManager), amount1);

        INonfungiblePositionManager.MintParams
            memory params = INonfungiblePositionManager.MintParams({
                token0: address(DAI),
                token1: address(USDC),
                fee: poolFee,
                tickLower: TickMath.MIN_TICK,
                tickUpper: TickMath.MAX_TICK,
                amount0Desired: amount0,
                amount1Desired: amount1,
                amount0Min: 0,
                amount1Min: 0,
                recipient: msg.sender,
                deadline: block.timestamp
            });

        (
            tokenId,
            liquidity,
            amount0Deposited,
            amount1Deposited
        ) = positionManager.mint(params);

        if (amount0Deposited < amount0) {
            uint256 refund0 = amount0 - amount0Deposited;
            DAI.safeTransfer(msg.sender, refund0);
        }

        if (amount1Deposited < amount1) {
            uint256 refund1 = amount1 - amount1Deposited;
            USDC.safeTransfer(msg.sender, refund1);
        }
    }

Here we are using DAI/USDC 0.01% pool for our contract. The mint first transfers token to the contract and then approves the tokens to the NonFungiblePositionManager (positionManager) and then calls the mint function on the position manager passing MintParams struct as arguments. To make things easy, we are staking on the full range and passing min values of the tokens as 0. Change them according to the requirements in the production.

The mint call returns tokenId, amount of Liquidity tokens generated and amounts of tokens added. After that we are refunding the extra tokens remained unstaked.

Increasing Liquidity

Once the position is minted, user can also increase and decrease the liquidity on that position as they wish. In order to increase the tokens, we must call increaseLiquidity on the position manager as shown below.

 function increaseLiquidity(
        uint256 tokenId,
        uint256 amountAdd0,
        uint256 amountAdd1
    ) external returns (uint128 liquidity, uint256 amount0, uint256 amount1) {
        DAI.safeTransferFrom(msg.sender, address(this), amountAdd0);
        USDC.safeTransferFrom(msg.sender, address(this), amountAdd1);
        DAI.approve(address(positionManager), amountAdd0);
        USDC.approve(address(positionManager), amountAdd1);

        INonfungiblePositionManager.IncreaseLiquidityParams
            memory params = INonfungiblePositionManager
                .IncreaseLiquidityParams({
                    tokenId: tokenId,
                    amount0Desired: amountAdd0,
                    amount1Desired: amountAdd1,
                    amount0Min: 0,
                    amount1Min: 0,
                    deadline: block.timestamp
                });

        (liquidity, amount0, amount1) = positionManager.increaseLiquidity(
            params
        );
    }

The increase liquidity first transfer the tokens to the contract and then calls the increaseLiquidity on the position manager with IncreaseLiquidityParams struct as arguments. The function call returns the liquidity generated and amount of each tokens added to the pool.

Decreasing Liquidity

Similar to increasing liquidity on a position, we can also decrease the liquidity. Only the position NFT owner can decrease liquidity. The code is as follows.

function decreaseLiquidity(
        uint256 tokenId,
        uint128 amount
    ) external returns (uint256 amount0, uint256 amount1) {
        positionManager.safeTransferFrom(msg.sender, address(this), tokenId);

        INonfungiblePositionManager.DecreaseLiquidityParams
            memory params = INonfungiblePositionManager
                .DecreaseLiquidityParams({
                    tokenId: tokenId,
                    liquidity: amount,
                    amount0Min: 0,
                    amount1Min: 0,
                    deadline: block.timestamp
                });

        (amount0, amount1) = positionManager.decreaseLiquidity(params);

        positionManager.safeTransferFrom(address(this), msg.sender, tokenId);
    }

Here, we are transferring the NFT to the contract and then decreasing the liquidity and lastly returning the NFT to the user. Note that, the decreaseLiqudity function call will not return the tokens pooled rather just removes the liquidity from the position.

Collect Tokens

In order to collect the LP fees or the unstaked liquidity, we must call the collect function which will return the tokens based on the position NFT.

function collect(
        uint256 tokenId
    ) external returns (uint256 amount0, uint256 amount1) {
        positionManager.safeTransferFrom(msg.sender, address(this), tokenId);

        INonfungiblePositionManager.CollectParams
            memory params = INonfungiblePositionManager.CollectParams({
                tokenId: tokenId,
                recipient: msg.sender,
                amount0Max: type(uint128).max,
                amount1Max: type(uint128).max
            });

        (amount0, amount1) = positionManager.collect(params);
    }

Here we have specified the amount0Max and amount1Max to the maximum values. So, it will return all the unstaked and staked liquidity including the LP fee generated as well.

Here we have a test script to test these functions.

import { ethers } from "hardhat";
import {
    LiquidityContract,
    IERC20,
    INonfungiblePositionManager,
} from "../typechain-types";
import { expect } from "chai";

describe("LiquidityContract", () => {
    let liquidity: LiquidityContract,
        dai: IERC20,
        usdc: IERC20,
        user: string,
        positManager: INonfungiblePositionManager;

    let obj: [bigint, bigint, bigint, bigint] & {
        tokenId: bigint;
        liquidity: bigint;
        amount0Deposited: bigint;
        amount1Deposited: bigint;
    };

    const DAI_WHALE = "0xe5F8086DAc91E039b1400febF0aB33ba3487F29A";
    const DAI_TOKEN = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
    const USDC_TOKEN = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
    const USDC_WHALE = "0xD6153F5af5679a75cC85D8974463545181f48772";
    const POSITION_MANAGER = "0xC36442b4a4522E871399CD717aBDD847Ab11FE88";

    const USDC_AMOUNT = ethers.parseUnits("10", 6);
    const DAI_AMOUNT = ethers.parseUnits("10", 18);

    beforeEach(async () => {
        const daiSigner = await ethers.getImpersonatedSigner(DAI_WHALE);
        const usdcSigner = await ethers.getImpersonatedSigner(USDC_WHALE);
        const signers = await ethers.getSigners();

        await signers[0].sendTransaction({
            value: ethers.parseEther("5"),
            to: DAI_WHALE,
        });
        await signers[0].sendTransaction({
            value: ethers.parseEther("5"),
            to: USDC_WHALE,
        });

        dai = await ethers.getContractAt("IERC20", DAI_TOKEN);
        usdc = await ethers.getContractAt("IERC20", USDC_TOKEN);
        positManager = await ethers.getContractAt(
            "INonfungiblePositionManager",
            POSITION_MANAGER
        );

        // transfer tokens to our signer to make transactions
        user = signers[0].address;
        await dai.connect(daiSigner).transfer(user, DAI_AMOUNT * 5n);
        await usdc.connect(usdcSigner).transfer(user, USDC_AMOUNT * 5n);

        const s = await ethers.deployContract("LiquidityContract", [
            POSITION_MANAGER,
        ]);

        liquidity = await s.waitForDeployment();
    });

    it(".. test minting new position", async () => {
        await dai.approve(liquidity.target, DAI_AMOUNT);
        await usdc.approve(liquidity.target, USDC_AMOUNT);

        obj = await liquidity.mint.staticCall(DAI_AMOUNT, USDC_AMOUNT);
        await liquidity.mint(DAI_AMOUNT, USDC_AMOUNT);

        expect(await positManager.ownerOf(obj.tokenId)).to.equal(user);
    });

    it(".. test increasing liquidity", async () => {
        await dai.approve(liquidity.target, DAI_AMOUNT);
        await usdc.approve(liquidity.target, USDC_AMOUNT);

        const increased = await liquidity.increaseLiquidity.staticCall(
            obj.tokenId,
            DAI_AMOUNT,
            USDC_AMOUNT
        );
        await liquidity.increaseLiquidity(obj.tokenId, DAI_AMOUNT, USDC_AMOUNT);

        const res = await positManager.positions(obj.tokenId);
        expect(res.liquidity).to.equal(obj.liquidity + increased.liquidity);
    });

    it(".. test decreasing liquidity", async () => {
        await positManager.approve(liquidity, obj.tokenId);

        const beforePos = await positManager.positions(obj.tokenId);
        const res = await liquidity.decreaseLiquidity.staticCall(
            obj.tokenId,
            obj.liquidity
        );
        await liquidity.decreaseLiquidity(obj.tokenId, obj.liquidity);
        const afterPos = await positManager.positions(obj.tokenId);

        expect(afterPos.tokensOwed0 - beforePos.tokensOwed0).to.equal(
            res.amount0
        );
    });

    it(".. test collect fees", async () => {
        await positManager.approve(liquidity, obj.tokenId);
        const beforeDAI = await dai.balanceOf(user);

        const res = await liquidity.collect.staticCall(obj.tokenId);
        await liquidity.collect(obj.tokenId);

        const afterDAI = await dai.balanceOf(user);

        expect(ethers.formatEther(afterDAI - beforeDAI)).to.be.greaterThan(9);
    });
});

In the upcoming articles, let us write code on how to implement flash swaps using the Uniswap V3.