LPing on Uniswap V3
Introduction
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`
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.0;
3
4contract Factory {
5 function deploy() external returns (address client) {
6 bytes20 targetBytes = bytes20(implementation);
7 uint256 nonce = nonces[msg.sender];
8 bytes32 salt = keccak256(abi.encodePacked(nonce, msg.sender));
9
10 assembly {
11 let ptr := mload(0x40)
12
13 mstore(
14 ptr,
15 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000
16 )
17 mstore(add(ptr, 0x14), targetBytes)
18 mstore(
19 add(ptr, 0x28),
20 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000
21 )
22
23 client := create2(0, ptr, 0x37, salt)
24 }
25
26 if (client == address(0)) revert DeploymentFailed();
27
28 if (!IClient(client).initialize(msg.sender))
29 revert InitializationFailed();
30
31 clients[msg.sender][nonce] = client;
32
33 unchecked {
34 nonces[msg.sender] = nonce + 1;
35 }
36
37 emit ClientDeployed(msg.sender, client);
38 }
39}
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.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.0;
3
4contract Client {
5 function initialize(
6 address owner
7 ) external initializer returns (bool success) {
8 Slot storage s = slot();
9 s.owner = owner;
10
11 s.modules[IModuleManager.update.selector] = moduleManager;
12 s.signatures[moduleManager].push(IModuleManager.update.selector);
13
14 s.modules[IModuleManager.clear.selector] = moduleManager;
15 s.signatures[moduleManager].push(IModuleManager.clear.selector);
16
17 return slot().owner == owner;
18 }
19}
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.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.0;
3
4struct Slot {
5 mapping(bytes4 => address) modules;
6 mapping(address => bytes4[]) signatures;
7 mapping(uint256 => Position) positions;
8 address owner;
9}
10
11contract Client {
12
13 function _fallback(address module) private {
14 if (module == address(0)) revert InvalidModule();
15
16 assembly {
17 calldatacopy(0, 0, calldatasize())
18 let result := delegatecall(gas(), module, 0, calldatasize(), 0, 0)
19 returndatacopy(0, 0, returndatasize())
20 switch result
21 case 0 {
22 revert(0, returndatasize())
23 }
24 default {
25 return(0, returndatasize())
26 }
27 }
28 }
29
30 fallback() external payable {
31 _fallback(slot().modules[msg.sig]);
32 }
33}
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.
1
2export const getCollateralAndBorrowAmounts = async (
3 assumption: Assumption,
4 collateralAsset: TokenModel,
5 borrowAsset: TokenModel,
6 tokenAmount: BigNumber,
7 debtRatio: number
8) => {
9 if (
10 !isWrappedNative(collateralAsset.address) &&
11 !isWrappedNative(borrowAsset.address)
12 ) {
13 throw new Error("!WETH")
14 }
15
16 const token = !!isWrappedNative(collateralAsset.address)
17 ? borrowAsset
18 : collateralAsset
19
20 const [
21 { collateralFactor },
22 { borrowFactor },
23 { price: tokenPrice }
24 ] = await Promise.all([
25 getAssetConfig(collateralAsset.address),
26 getAssetConfig(borrowAsset.address),
27 getUnderlyingPrice(token.address)
28 ])
29
30 if (collateralFactor / EULER_FACTOR_SCALE === 0) {
31 throw new Error("Invalid collateral asset")
32 }
33
34 const scale = 18 - Math.abs(collateralAsset.decimals - borrowAsset.decimals)
35 const maxRatio = collateralFactor * borrowFactor / COLLATERAL_FACTOR_SCALE
36 const borrowRatio = Math.round(maxRatio * debtRatio / COLLATERAL_FACTOR_SCALE)
37 const collateralRatio = borrowRatio + COLLATERAL_FACTOR_SCALE
38
39 let collateralAmount: BigNumber
40 let borrowAmount: BigNumber
41
42 switch (assumption) {
43 case Assumption.BEARISH:
44 collateralAmount = tokenAmount
45 break
46
47 case Assumption.NEUTRAL:
48 collateralAmount = percentDiv(tokenAmount, collateralRatio)
49 break
50
51 default:
52 throw new Error("Invalid assumption")
53 }
54
55 if (!!isWrappedNative(collateralAsset.address)) {
56 borrowAmount = percentMul(collateralAmount, borrowRatio).mul(parseUnits(1, scale)).div(tokenPrice)
57 } else {
58 borrowAmount = percentMul(collateralAmount, borrowRatio).mul(tokenPrice).div(parseUnits(1, scale))
59 }
60
61 return { collateralAmount, borrowAmount }
62}
63
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.
1
2const payloads: string[] = [
3 encodePullTokens(collateralToken.address, ethAmount),
4 encodeSupplyAndBorrow(
5 await eulerAdapter.id(),
6 collateralToken.address,
7 borrowToken.address,
8 collateralAmount,
9 borrowAmount
10 ),
11 encodeSwap(
12 [
13 {
14 adapterId: await v3Swap.id(),
15 pool: pool.address,
16 tokenIn: tokenIn.address,
17 tokenOut: tokenOut.address,
18 },
19 ],
20 amountIn,
21 amountOut,
22 deadline
23 ),
24 encodeMint(
25 assumption,
26 duration,
27 token0.address,
28 token1.address,
29 pool.fee,
30 tickLower,
31 tickUpper,
32 amount0Desired,
33 amount1Desired,
34 0,
35 0,
36 deadline,
37 true
38 ),
39 encodeSweepTokens(
40 weth.address,
41 constants.MaxUint256,
42 trader.address
43 ),
44];
45
46const tx = await client.connect(trader).multicall(payloads);
47await tx.wait();
48
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 ] }.