Uniswap v3 NFT Management

Overview Each Uniswap v3 farming setup is linked to a single Uniswap v3 NFT that represents a single price curve for a liquidity pool. The curve's parameters are established by the host during the creation of the setup (see here) within its parent contract.

This means that the farming contract itself is the holder of each of its setups' NFT, and that all users who create a farming position by adding or removing liquidity to a given setup interact with the same NFT. Advantages

Having a single NFT per setup—rather than one per farmer—has several advantages. For example:

  • farming contracts & their setups can be tailored with the right curves for projects & tokens

  • it enables a variety of ways to incentivize farmers, such as giving higher rewards to wider curves that better secure liquidity, or giving lower rewards to smaller curves that are riskier for liquidity but which earn farmers more trading fees

  • farming is much cheaper, as farmers don't have to mint a new NFT every time they open a position; instead, they all just add liquidity to or remove liquidity from the same NFT

  • by eschewing excessively granular liquidity on Uniswap v3—i.e. liquidity with many different curves, and thus many NFTs—trading is cheaper too, as each swap in a v3 pool activates all NFTs at a cost

NFT Minting & Burning

The following NFT parameters are established when a setup is created:

  • LP address -> from which the two token addresses and the fee of the pool are retrieved

  • tickLower ->parameter representing the value of tickLower used to create the Uniswap v3 NFT for the setup.

  • tickUpper ->parameter representing the value of tickUpper used to create the Uniswap v3 NFT for the setup.

These are saved within the FarmingSetupInfo. Once the setup is active—i.e when the first user creates a new position within that setup—the NFT is minted by taking them as the mintParams to create the NFT position via the Uniswap v3 INonfungiblePositionManager:

 if(_setupPositionsCount[setupIndex] == 0 && _setups[setupIndex].objectId == 0) {
     data[0] = abi.encodeWithSelector(nonfungiblePositionManager.mint.selector, abi.encode(INonfungiblePositionManager.MintParams({
         token0: token0, 
         token1: IUniswapV3Pool(setupInfo.liquidityPoolTokenAddress).token1(),
         fee: IUniswapV3Pool(setupInfo.liquidityPoolTokenAddress).fee(),
         tickLower: setupInfo.tickLower, 
         tickUpper: setupInfo.tickUpper,
         amount0Desired: request.amount0, 
         amount1Desired: request.amount1,
         amount0Min: 1,
         amount1Min: 1,
         recipient: address(this),
         deadline: block.timestamp + 10000
    })));
    (_setups[setupIndex].objectId, liquidityAmount,,) = abi.decode(IMulticall(address(nonfungiblePositionManager)).multicall{ value: ethValue }(data)[0], (uint256, uint128, uint256, uint256));

When the setup comes to an end (e.g. at the end block) and is deactivated (e.g. when the last position is withdrawn, leaving the setup with zero liquidity), it is deleted from the contract's memory (just as with Generation 1 contracts) and the NFT is burned via the INonfungiblePositionManager burn method.

function _tryClearSetup(uint256 setupIndex) private {
    if (_setupPositionsCount[setupIndex] == 0 && !_setups[setupIndex].active && _setups[setupIndex].objectId != 0) {
        uint256 objectId = _setups[setupIndex].objectId;
        address(nonfungiblePositionManager).call(abi.encodeWithSelector(nonfungiblePositionManager.collect.selector, INonfungiblePositionManager.CollectParams({
            tokenId: objectId,
            recipient: address(this),
            amount0Max: 0xffffffffffffffffffffffffffffffff,
            amount1Max: 0xffffffffffffffffffffffffffffffff
        })));
        nonfungiblePositionManager.burn(objectId);
        delete _setups[setupIndex];
      }
}

If a setup has been customized to renew after it ends (through the _toggleSetup function), a new NFT will be minted when it is (i.e when the first position is opened in it) with the same parameters as before. This ensures that a setup always corresponds to the same equivalent NFT.

Trading Fee Management

Trading fees in Uniswap v3 are managed differently than in other AMMs supported by the Covenant AMM Aggregator (such as Uniswap v2) as they can be withdrawn separately from liquidity. Accordingly, Covenant Farming contracts for Uniswap v3 are designed with an intra-NFT fee-splitting logic, so that Farmers of the same setup (and thus the same NFT) can withdraw the fees they have earned individually.

The calculation of the reward for a single farmer's position contains information about the time (blocks) the farmer has spent in the farming setup and the amount of liquidity he has pooled. This calculation is used to determine how much he will receive in trading fees at the exact moment he claims his reward in accordance with the formula of the fee-splitting logic, which is:

The amount of fees for an individual farmer is therefore given by:

FM -> Fee Multiplier

FP -> Fee per Position i -> Individual farmer

positionreward = amount of reward tokens being withdrawn by the user

totalrewardtokens = reward per Block * numbers of blocks from the start block until the current block

totalrewardredeemed = total reward tokens withdrawn by all users of the setup up to the current block

So, when a user withdraws his or her reward, his or her share of the trading fees is also withdrawn. Trading fees cannot be withdrawn separately from the reward.

Linking the fee-splitting logic to the reward instead of directly to the liquidity prevents a user who adds a large amount of liquidity to the setup for a few blocks from withdrawing more fees than they've earned.

N.B. Using the sharing NFT architecture users can experience impermanent losses in the fees they have earned if the trading volume in a pool drops too much.

Liquidity

The architecture of Covenant Farming v1.0 contracts was relatively easy to adapt to Uniswap v3. Although Uniswap v3 doesn't use fungible LP tokens, the v3 NFTs do use the "liquidity" parameter which represents the liquidity within an NFT's curve. Thisliquidity parameter is used in v1.5 contracts (instead of LP tokens, like in v1.0 contracts) for farming features such as position reward calculation.

So, for example, in OpenPosition operations, the liquidity parameter is used as follows:

uint256 liquidityAmount = _addLiquidity(request.setupIndex, request);
_positions[positionId] = FarmingPosition({
    uniqueOwner: uniqueOwner,
    setupIndex : request.setupIndex,
    liquidityPoolTokenAmount: liquidityAmount,
    reward: 0,
    creationBlock: block.number
});

The liquidityAmount parameter represents the liquidity amount in a Uniswap v3 farming setup.

The same thing applies to the addLiquidity operation:

uint256 liquidityAmount = _addLiquidity(farmingPosition.setupIndex, request)
farmingPosition.liquidityPoolTokenAmount += liquidityAmount;
chosenSetup.totalSupply += liquidityAmount;

For WithdrawLiquidity operations, the liquidity parameter is used as follows:

_setups[farmingPosition.setupIndex].totalSupply -= removedLiquidity; 
farmingPosition.liquidityPoolTokenAmount -= removedLiquidity;

What was managed through the LP token amount in v1.0 contracts is now managed through the liquidity parameter of the NFT in v1.5 contracts.

Last updated