LPing V3

LPing in Uniswap V3 Part 1

LPing in Uniswap V3 Part 1

This project is built to automate the process of lping on Uniswap V3 pools based on the flowchart above, created by @guil_lambert. You can find his original tweet from here.

Contracts

Core

  • Client - Contract that can be deployed by anyone permissionlessly and is used as the interface of Module contracts to manage positions
  • Factory - Contract that is used for deploying the `Client` contracts and registering the Module contracts by its owner

Modules

  • ModuleManager - Contract that is used for updating the states of Module contracts for the `Client`
  • NFTForwarder - Contract that is used for forwarding the transactions to `NonfungiblePositionManager` for the `Client`
  • DexAggregator - Contract that is used for aggregating between the pools of different DEXes
  • LendingDispatcher - Contract that is used for aggregating between the lending protocols

Adapters

  • V3Swap - Contract that is used for forwarding the transactions to the `Uniswap V3 Pools` via `DexAggregator`
  • V2Swap - Contract that is used for forwarding the transactions to the `Uniswap V2 Pairs` via `DexAggregator`
  • EulerAdapter - Contract that is used for forwarding the transactions to the contracts of `Euler Finance` via `LendingDispatcher`

contract Factory {
    function deploy() external returns (address client) {
        bytes20 targetBytes = bytes20(implementation);
        uint256 nonce = nonces[msg.sender];
        bytes32 salt = keccak256(abi.encodePacked(nonce, msg.sender));
    
        assembly {
            let ptr := mload(0x40)
    
            mstore(
                ptr,
                0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000
            )
            mstore(add(ptr, 0x14), targetBytes)
            mstore(
                add(ptr, 0x28),
                0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000
            )
    
            client := create2(0, ptr, 0x37, salt)
        }
    
        if (client == address(0)) revert DeploymentFailed();
    
        if (!IClient(client).initialize(msg.sender))
            revert InitializationFailed();
    
        clients[msg.sender][nonce] = client;
    
        unchecked {
            nonces[msg.sender] = nonce + 1;
        }
    
        emit ClientDeployed(msg.sender, client);
    }
}

Factory contract deploys the Client contracts via Create2 to minimize the cost of the deployment. And its address is generated with the address and the nonce of the deployer; the owner of the Client. Also, the ModuleManager contract which allows to configure the Module contracts for the Client, is being added on the initialization of the deployment.

contract Client {
    function initialize(
        address owner
    ) external initializer returns (bool success) {
        Slot storage s = slot();
        s.owner = owner;

        s.modules[IModuleManager.update.selector] = moduleManager;
        s.signatures[moduleManager].push(IModuleManager.update.selector);

        s.modules[IModuleManager.clear.selector] = moduleManager;
        s.signatures[moduleManager].push(IModuleManager.clear.selector);

        return slot().owner == owner;
    }
}

Module contracts enable the Client to interact with other DeFi protocols via delegatecall. The owner of Factory could register a new Module contract to the Factory then it can be added to the Client by its owner via ModuleManager.

struct Slot {
    mapping(bytes4 => address) modules;
    mapping(address => bytes4[]) signatures;
    mapping(uint256 => Position) positions;
    address owner;
}

function _fallback(address module) private {
    if (module == address(0)) revert InvalidModule();

    assembly {
        calldatacopy(0, 0, calldatasize())
        let result := delegatecall(gas(), module, 0, calldatasize(), 0, 0)
        returndatacopy(0, 0, returndatasize())
        switch result
        case 0 {
            revert(0, returndatasize())
        }
        default {
            return(0, returndatasize())
        }
    }
}

fallback() external payable {
    _fallback(slot().modules[msg.sig]);
}

Client contract stores the function signatures of Module contract along with the address. This ensures the Client to point to the valid Module contract on fallback execution to forward the transaction.

DexAggregator and LendingDispatcher can be extended by connecting more Adapter contracts similar to the Client. LendingDispatcher can be integrated with other lending protocols like Compound and Aave. DexAggregator also can be connected with DEX protocols other than the Uniswap as well.

The integration test will assure that the Client contract can create every type of positions from the picture above with different kinds of tokens. It will be tested to swap and borrow tokens then create position.

Define the assumption, duration, and scaling factor for the position and compute the min and max prices based on it. Then deploy the position with the defined price range.

Bullish

  • Deploy the full capital if own tokens already
  • Swap ETH for tokens then deploy the capital with swapped tokens

Bearish

  • Swap tokens for ETH then deploy the capital with swapped ETH
  • Supply ETH and Borrow tokens on Euler then deploy the capital with borrowed tokens

Neutral

  • Swap half of token holdings for ETH then deploy the capital
  • Supply ETH and Borrow tokens worth half of the position on Euler then deploy the capital

Define the Collateral and Debt token amounts

Here's the util function that's being used from the unit tests for computing the amounts for collateral and debt tokens.


export const getCollateralAndBorrowAmounts = async (
	assumption: Assumption,
	collateralAsset: TokenModel,
	borrowAsset: TokenModel,
	tokenAmount: BigNumber,
	debtRatio: number
) => {
	if (
      !isWrappedNative(collateralAsset.address) &&
      !isWrappedNative(borrowAsset.address)
    ) {
		throw new Error("!WETH")
	}

	const token = !!isWrappedNative(collateralAsset.address) 
      ? borrowAsset 
      : collateralAsset

	const [
		{ collateralFactor },
		{ borrowFactor },
		{ price: tokenPrice }
	] = await Promise.all([
		getAssetConfig(collateralAsset.address),
		getAssetConfig(borrowAsset.address),
		getUnderlyingPrice(token.address)
	])

	if (collateralFactor / EULER_FACTOR_SCALE === 0) {
		throw new Error("Invalid collateral asset")
	}

	const scale = 18 - Math.abs(collateralAsset.decimals - borrowAsset.decimals)
	const maxRatio = collateralFactor * borrowFactor / COLLATERAL_FACTOR_SCALE
	const borrowRatio = Math.round(maxRatio * debtRatio / COLLATERAL_FACTOR_SCALE)
	const collateralRatio = borrowRatio + COLLATERAL_FACTOR_SCALE

	let collateralAmount: BigNumber
	let borrowAmount: BigNumber

	switch (assumption) {
		case Assumption.BEARISH:
			collateralAmount = tokenAmount
			break

		case Assumption.NEUTRAL:
			collateralAmount = percentDiv(tokenAmount, collateralRatio)
			break

		default:
			throw new Error("Invalid assumption")
	}

	if (!!isWrappedNative(collateralAsset.address)) {
		borrowAmount = percentMul(collateralAmount, borrowRatio).mul(parseUnits(1, scale)).div(tokenPrice)
	} else {
		borrowAmount = percentMul(collateralAmount, borrowRatio).mul(tokenPrice).div(parseUnits(1, scale))
	}

	return { collateralAmount, borrowAmount }
}

First I defined the debt ratio with the collateral factor and the borrow factor of the assets then compute the borrow amount based on the amount of collateral tokens that I'm willing to supply. The collateral factor of the asset supplied as collateral is multiplied by the borrow factor of the borrowed asset to arrive at the final factor. For example, if you supplied 1,000 USD worth of USDC (0.90 collateral factor), you can borrow UNI (0.76 borrow factor) in line with a final factor of 0.648 (0.90 * 0.72). Then I can compute the price range of the position with the duration and the scaling factor after defining the amounts of collateral and debt tokens.

The Client contract inherits the Multicall base contract similar to the SwapRouter contract of Uniswap V3. You can encode the transaction payloads to execute multiple transactions at once like below on hardhat test environment. The code below is going to execute the transactions in the context of Client contract to 1) pull tokens from the sender, 2) supply the collateral tokens and borrow the debt tokens on Euler Finance, 3) perform swap on Uniswap V3 pool, 4) mint a new position, 5) and finally sweep left over tokens back to the sender if there is any.


const payloads: string[] = [
    encodePullTokens(collateralToken.address, ethAmount),
    encodeSupplyAndBorrow(
        await eulerAdapter.id(),
        collateralToken.address,
        borrowToken.address,
        collateralAmount,
        borrowAmount
    ),
    encodeSwap(
        [
            {
                adapterId: await v3Swap.id(),
                pool: pool.address,
                tokenIn: tokenIn.address,
                tokenOut: tokenOut.address,
            },
        ],
        amountIn,
        amountOut,
        deadline
    ),
    encodeMint(
        assumption,
        duration,
        token0.address,
        token1.address,
        pool.fee,
        tickLower,
        tickUpper,
        amount0Desired,
        amount1Desired,
        0,
        0,
        deadline,
        true
    ),
    encodeSweepTokens(
        weth.address,
        constants.MaxUint256,
        trader.address
    ),
];


const tx = await client.connect(trader).multicall(payloads);
await tx.wait();

The Client contract was tested to ensure that it can create all 6 types of positions shown in the flowchart from the unit tests with the following tokens and pools: [ USDC-WETH/3000, WETH-USDT/3000, WBTC-WETH/3000, UNI-WETH/3000, LINK-WETH/3000, WETH-ENS/3000, WETH-GRT/3000 ]. Notice that the unit tests include tokens of every asset tier from Euler Finance: { Collateral Tier: [WETH, WBTC, USDC, USDT, LINK, UNI], Cross Tier: [ ENS ], Isolated Tier: [ GRT ] }.

Go to Github Repo