[WOOF!] Uniswap LP as collateral in Compound V3

This forum post contains a full scope of work that must be performed in order to integrate Uniswap V3 LP positions as collateral for Compound V3 protocol.

Solution

Whitelist

The asset address that will be whitelisted by the governance is represented by the pool address. Each pair in the pool can have up to 4 tiers but usually there are no more than 1 tier per pair depending on its volatility.

Example:

  • USDC/USDT has only 0.01% fee tier
  • ETH/LINK has only 0.3% fee tier

So usually we’ll maintain only 1(maximum 2) pools per pair.

Supply

We can change the supply method to use ERC721 token ID instead of ERC20 value and asset address.

function supplyCollateral(
    	address from,
    	address dst,
    	uint256 tokenId
	) internal {

In order to get Uniswap V3 pool Address we can use a method from the Uniswap V3 libraries.

function computeAddress(
       address token0,
       address token1,
       uint24 fee
   ) public view returns (address pool) {
       pool = address(
           uint160(
               uint256(
                   keccak256(
                       abi.encodePacked(
                           hex"ff",
                           factory,
                           keccak256(
                               abi.encode(token0, token1, fee)
                           ),
                           POOL_INIT_CODE_HASH
                       )
                   )
               )
           )
       );
   }

Then we may use the Uniswap V3 position manager to retrieve the arguments that will help us to identify pool addresses. Next step we may store token id and for pool address both in user positions array and global positions array.

INonfungiblePositionManager.Position
           memory _position = INonfungiblePositionManager(positionManager)
               .positions(tokenId);

address poolAddress = computeAddress(_position.token0, _position.token1, 
          _position.fee);

userCollateral[dst][poolAddress].push(tokenId);
tokenIds[poolAddress].push(tokenId);

We will need the second array to easily iterate all the positions by exact pool in order to calculate total supply. So here we’ll have several approaches to calculate the total supply. The main issue here is that we can not summarize ERC721 positions of users without conversion of it to the single ERC20 address but while price of ERC721 position constantly changes we must recalculate all the pool liquidity on each supply. The more users NFTs are there in the pool - the more iterations we have to do, the more expensive gas price of each supply or absorption method becomes.

for (uint256 i = 0; i < tokenIds[assetInfo.asset].length; i++) {
               INonfungiblePositionManager.Position
                   memory _position = INonfungiblePositionManager(
                       positionManager
                   ).positions(tokenIds[assetInfo.asset][i]);
               totalLiquidity += getLiquidity(_position, assetInfo);
           }

Total supply recalculation

So the basic approach here is to loop the tokenIds mapping by pool address, extract underlyings, convert them to the single asset using two chainlink endpoints.

(uint256 amount0, uint256 amount1) = getAmountsForLiquidity(_position);

uint256 priceAssetA = getPrice(assetInfo.priceFeed);
uint256 priceAssetB = getPrice(assetInfo.priceFeed);

uint256 amountA = mulPrice(amount0, priceAssetA, assetInfo.scaleA);
uint256 amountB = mulPrice(amount1, priceAssetB, assetInfo.scaleB);
return amountA + amountB;

But it leads to high gas usage as described above because in order to split a token on underlying Uniswap requires square root operations that are quite expensive. Here is the chart that represents how gas costs increase for supply function from 0 to 50 NFTs in the pool.

So in order to prevent constant recalculation of the total supply using supply or absorb method we have an alternative idea.

Offchain service

The external offchain is the most straight-forward solution here if we could use some kind of workers that could execute a recalculation function using time based operations. In order to have the most decentralized solution for such a kind of service we could rely on Chainlink Automation that uses its own network to set up workers. It uses LINK as a gas token and supports lots of networks. The only issue here is that those operations could influence the profit of the protocol while we’ll still have to perform all the calculations on smart contracts. So Chainlink Automation provides a good decentralized solution for the automation workers but using their own offchain service is the most cost-efficient solution with the lack of decentralization. However we could implement this service by ourselves while maintaining the same approach.

So how does it work and why is this approach more preferable than utilizing a common cron/event subscription to maintain workers?

The basic structure of any Chainlink Automation contract must include such functions.

function checkUpkeep(Log calldata log, bytes memory) external view returns (bool upkeepNeeded, bytes memory performData)
	{
	  // checkUpkeep is simulated off chain by a Chainlink DON using OCR3
	  // Do all of your off computation here for free
	  // Pass the result of the computation to perfomUpkeep through performData
	  return (true, performData)
	}
	
function performUpkeep(bytes calldata performData) external 
	{
	  //On chain transaction logic
	}

Where checkUnkeep function will include the total supply recalculation logic and performUnkeep will be used to set the received value. How does it work? checkUnkeep could be triggered by cron/event subscription using Chainlink DON. It is a fully validatable network of Chainlink Nodes that will call checkUnkeep according to the trigger rules and the received data could be passed to performUnkeep that will call the Comet in order to set the new value. That means that recalculation function will become totally FREE while the protocol only has to pay for setter functions that is much cheaper than maintaining recalculation logic in write methods of smart contracts while preserving the decentralization. The recalculation logic still remains on-chain while the worker’s job is handled by the network of nodes that is much more reliable and stable than building own backend service.

Note: Chainlink keeper contract has to be whitelisted by Compound protocol to be able to perform the update operation.

So that’s why the implementation of Chainlink Automation resolves the problem with gas cost of total supply evaluation while preserving decentralization.

We had a great call with Chainlink Automation architect that answered all our questions and make us comfortable with the solution.

Rewards

One of the remaining questions is - how to deal with the rewards? The rewards in Uniswap V3 position must boost its price but the rewards in Uniswap V3 are not auto recompounded. Moreover there is no view method that allows checking current rewards. Uniswap V3 uses the collect method…

struct CollectParams {
       uint256 tokenId;
       address recipient;
       uint128 amount0Max;
       uint128 amount1Max;
   }

function collect( CollectParams calldata params ) external returns (uint256 amount0, uint256 amount1);

…that returns current rewards after actually collecting them. So it creates several boundaries. Here are the two scenarios on how to resolve this bottleneck.

Put it on absorber - Option 1

We can make an absorber to collect the rewards and recompound them into the same position during liquidation. We can provide additional liquidity into the position using increaseLiquidity function on Uniswap V3 position manager which interface is specified below.

struct IncreaseLiquidityParams {
       uint256 tokenId;
       uint256 amount0Desired;
       uint256 amount1Desired;
       uint256 amount0Min;
       uint256 amount1Min;
       uint256 deadline;
   }


function increaseLiquidity(
       IncreaseLiquidityParams calldata params
   )
       external
       payable
       returns (uint128 liquidity, uint256 amount0, uint256 amount1)

This could easily resolve the problem but will lead to higher transaction costs for liquidators.

Rebalance control - Option 2

We can allow users to interact with collateralized LP positions by rebalancing rewards. But if there are any rewards on position during absorption they will not be counted. Rebalancing in terms of Uniswap V3 is not performed in the same position. Instead we have to drain liquidity from the previous position and create a new one with an updated tick range. The main pitfall here is that different tick ranges will require different proportions of underlyings that must be deposited. So in this case rebalance could lead to underlyings remains that will not be redeposited after rebalance. So here we either have to swap the received underlyings in correct proportions or deal with the remains by returning them to the user that will lead to the collateral price decreace.

Absorb and Buy Collateral

In this case we have to also loop the positions but only user’s ones to check if total users pool liquidity is enough to not be liquidated. So limiting maximum users positions will be a good solution in this case to prevent gas overflows. This approach could be used both in isLiquidatable and isBorrowCollateralized.

for ( uint256 i = 0; i < userCollateral[dst][assetInfo.asset].length; i++ )   {
   INonfungiblePositionManager.Position memory _position =    
     INonfungiblePositionManager(positionManager     
        ).positions(userCollateral[dst][assetInfo.asset][i]);
    totalLiquidity += getLiquidity(_position, assetInfo);
 }

In common Comet we used to provide some amount of base assets to buy the liquidated users position. But now while all the liquidated LP positions are stored as ERC721 assets we could just recalculate the price of the position and discount it, accepting base asset equivalent.
So the buyCollateral interface could be simplified to…

 function buyCollateral(
    	uint256 tokenId,
    	address recipient
	) external nonReentrant;

…as we only need a token ID. So the exact amount accepted by this function and the pool address will be revealed in the function body.

Bulker

We must also have a separate bulker with the interface of LP Comet for supplying ERC721.

function supplyTo(address comet, address to, uint256 tokenId) internal {
       CometInterface(comet).supplyFrom(msg.sender, to, tokenId);
   }

Moreover it could be used to provide another supply option. Instead of creating a position on Uniswap V3 and supplying in 2 distinct transactions we could provide users a possibility to transfer underlying proportions to create the position on Comet side and supply it immediately.

function supplyTo(address comet, address to, address poolAddress, uint256 amount0, uint256 amount1) internal {
       uint256 tokenId = // position creation logic here ;
       CometInterface(comet).supplyFrom(msg.sender, to, tokenId);
   }

Estimate

Comet Uniswap V3 Integration

Modify the Compound V3 (Comet) protocol to support Uniswap V3 LP tokens as collateral. Handle ERC721 token management, pool address computation, and liquidity tracking.

Subtasks:

  • Implement support for ERC721 tokens in the supply logic.
  • Develop rewards collection, liquidation, and absorption scenarios for Uniswap V3 LP tokens.
  • Integrate Chainlink Automation into Comet infrastructure.
  • Update Bulker contract to support Comet LP interface.

Integration of the Chainlink Automation for Supply Recalculation

Develop and integrate an aggregator smart contract and connect it with Chainlink Automation DON to handle total supply recalculations periodically and by event subscriptions, reducing on-chain computation costs. Use or a custom off-chain service for decentralized periodic recalculations.

Subtasks

  • Develop a keeper smart contract that computes total supply and allows to set new values on Comet.
  • Set up Chainlink Automation and integrate with the keeper smart contract to periodically update the supply.
  • Whitelist the keeper contract on LP Comet.

Unit Tests

Write comprehensive unit tests for all new features and modifications. Focus on gas efficiency, edge cases, and protocol-level correctness.

Subtasks:

  • Test ERC721 supply and withdrawal.
  • Test total supply recalculation logic (on-chain and off-chain).
  • Test rewards collection, liquidation, and absorption scenarios.
  • Gas cost benchmarking and optimization tests.

Migrations

Perform migrations to deploy updated smart contracts and configure integrations on the protocol.

Subtasks:

  • Write scripts to deploy and configure updated contracts.
  • Handle migrations for user collateral and positions to the new ERC721-compatible structure.
  • Test migrations on a forked mainnet to ensure reliability.

Liquidator

As Comet’s new implementation will be different from the liquidation perspective, we should create a SC that liquidates the position, as well as Typescript logic, that triggers the liquidation.

Subtasks:

  • Develop OnChainLPLiquidator Smart contract
  • Develop script, that collects positions and trigger the liquidation

Frontend Integration

As WOOF! Handles the development of new Compound Frontend, we need to add the functionality to supply LPs.

Subtasks:

  • Update supply collateral flow
  • Update other frontend parts to work with LP

Migrations

The development of the whole functionality, including smart contract, liquidator and frontend integration is 1.5 to 2 months.

Next steps

  • Community feedback
  • Choose the option for rewards
2 Likes

Great proposal, @dmitriywoofsoftware for keeping technical innovation going in Compound.

Here are a few initial comments:

Business related

  1. Right now, both Compound and AAVE (+ Spark), and now Morpho are considered to be the safest places to earn yield on their assets. One of the core reasons for this is the simple, verifiable on-chain code with minimal external integrations. How will these new integrations be perceived by the market?
  2. Do we have any high-level idea of the demand this will drive?

Technical

  1. Uniswap V3 has concentrated liquidity, which means the position can be concentrated in just one asset. Thus, two LPs in the ETH/USDC pool can have completely different risk profiles and should have different TVLs.
  2. The liquidation logic becomes too complex, and we saw in August last year that there were not many liquidators running. Some of the positions that were supposed to be liquidated were not.

That being said, I feel that this is a great initiative and should be pursued. Here are some recommendations:

  • Keep the Comet code separate and develop it as a separate module.
  • These new lending/borrowing modules can be branded as “Compound X (experimental).”
  • As we see adoption and maturity of the code, they can be moved to one of the main lending/borrowing modules. This will maintain Compound’s reputation as the safest lending and borrowing protocol.
  • I guess there are a lot of deep pools still on Uniswap V2, and which will never migrate to V3 or V4, and that is the reason Uniswap has a Universal router.
  • So may be the initial version can target v2 pools which have minimal technical complexity as all the LPs will have the same risk profile.
  • Similar to Uniswap’s universal router design, we can later develop Compound’s Lending router where users can mention their risk profile and, accordingly, the router can choose the right market, with the current Compound 3 markets with minimal changes and with blue-chip assets at the least risk side.
1 Like

Thank you for detailing this out, Dmitriy.

Thank you for you comments, @robinnagpal. Super valuable.

How will these new integrations be perceived by the market?

I agree that mentioned protocols are verifiable on-chain code, while current solution is leveraging Chainlink Automation that adds a bit of centralization. It is also speculative in my opinion: using third-party(e.g. Chainlink) oracles for critical infrastructure as liquidations could also expose safety of the protocol to some extend.

As of Chainlink Automation: all calculations are recorded on-chain, and for performing the counting operations, we use Chainlink’s DON, which is a verifiable network.

Do we have any high-level idea of the demand this will drive?

We bet on Uniswap rise related to Uniswap v4 release and Unichain, where Compound will be available on day one.

Uniswap V3 has concentrated liquidity, which means the position can be concentrated in just one asset. Thus, two LPs in the ETH/USDC pool can have completely different risk profiles and should have different TVLs.

We don’t lump all positions into one pool. Each position is managed individually. However, using the pool address as an identifier for all positions within that pool and calculating the total TVL helps avoid market fragmentation or, on the other hand, combining all positions into a single pool, which would shift the responsibility for risk management onto the protocol. This approach doesn’t create a critical burden on governance and remains an optimal solution overall.

The liquidation logic becomes too complex.

I don’t think the liquidation logic is too complex in this situation, especially if we manage positions individually. If one of the user’s LP positions drops in value, it doesn’t mean we need to liquidate all their positions. Liquidating just the affected one should be sufficient.

Keep the Comet code separate and develop it as a separate module.

Agreed with thanks.

These new lending/borrowing modules can be branded as “Compound X (experimental).”

Agree

Initial version can target v2 pools which have minimal technical complexity as all the LPs will have the same risk profile.

Great point I would address to the Growth Team.

We can later develop Compound’s Lending router.

Love the idea. Happy to dive deeper.

Thanks for the comments @robinnagpal. Looking forward to further feedback!

1 Like

Can someone outline how this makes sense explicitly?

In my mind (im just a regular DeFi user), adding LP as collateral should be capped. In the case that too much LP gets added, it would be hardd to liquidate positions no?

Might have to be capped as a percentage of the total liquidity pool, which, Im not sure makes sense here considering governance proposals take so long to go from discussion to vote to implement.

How would you micromanage the LP as collateral on Compound compared to the rest of the LP pool onchain effectively?