Create an invest/divest contract

Learn how to create an invest/divest contract for an investment opportunity.

Create an invest/divest contract

Invest/divest smart contracts are used to programmatically borrow from Cozy protection markets and/or Cozy unprotected money markets and invest into DeFi products for investors in on-chain protocols, such as Yearn Finance.

At a high-level, the contract needs the following external functions to be implemented:

  1. invest: Automates borrowing from Cozy markets and investing the borrowed funds in a DeFi product.

  2. divest: Automates closing positions in a DeFi product that were opened with the contract's invest function and repays debt in the Cozy market.

  3. claimRewards: If the investment opportunity accrues reward tokens, this function is required to claim them and transfer them to the investor's wallet.

Additionally, invest/divest contracts are intended to be used by delegatecalling to them from a proxy wallet to borrow, invest, and divest on behalf of the user. This means these contracts cannot be used directly with EOAs, as EOAs cannot execute code and therefore the investments will occur on behalf of the invest contract, and not on behalf of the user.

At Cozy, we use DSProxy wallets alongside a DSProxyMulticall helper contract which allows batched execution of a sequence of contract calls, without the need for deploying a new "script" contract for each sequence (additional context can be found in DSProxyMulticall.sol in the Cozy Developer Guides). The address of the DSProxyMultiCall contract can be found in the Cozy Subgraph.

The contract examples shown throughout this document can be referenced from the CozyInvestCurve3CryptoEth.sol and CozyInvestConvex.sol contracts in the Cozy Developer Guides repository. CozyInvestCurve3CryptoEth.sol is an example of investing with ETH, and CozyInvestConvex.sol is an example of investing with ERC20 tokens.

Everything in this guide assumes that you have experience with JavaScript, ethers.js, and Solidity.

Contracts used by an invest/divest contract

An invest/divest contract needs to know the addresses of a few contracts found on the Cozy Subgraph:

  • The address of the protection market contract to borrow from.

  • If the underlying asset of the protection market is Ether (ETH), the address of the Maxmillion contract is required. The Maximillion contract is used to repay ETH debt to the protection market.

    • Note: Cozy uses 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE as the address the represent ETH.

  • Optional: If you'd like to support unprotected investing, the address of the unprotected money market to borrow from.

In addition to the above, any contracts specific to investing and divesting from the target DeFi product are required (ie: a Curve liquidity pool, a Yearn Vault, etc.).

Interfaces required for Cozy contracts

The following snippet includes interfaces for interacting with Cozy contracts required for borrowing.

ICozy.sol
pragma solidity ^0.8.9;

interface ICozyShared {
  function underlying() external view returns (address);

  function borrow(uint256 borrowAmount) external returns (uint256);

  function borrowBalanceCurrent(address account) external returns (uint256);
}

// @dev Interface for a Cozy market with an ERC20 token underlying
interface ICozyToken is ICozyShared {
  function repayBorrowBehalf(address borrower, uint256 repayAmount) external returns (uint256);

  function repayBorrow(uint256 repayAmount) external returns (uint256);
}

// @dev Interface for a Cozy market with ETH underlying
interface ICozyEther is ICozyShared {
  function repayBorrowBehalf(address borrower) external payable;
}

// @dev Interface for the Maximillion contract used to repay ETH borrows
interface IMaximillion {
  function repayBehalfExplicit(address borrower, ICozyEther market) external payable;
}

Develop the invest/divest contract constructor

The constructor for the invest/divest contract should be used to initialize any declared state variables required by the contract logic that were not initialized at the time they were declared. The state variables required may vary between different investment opportunities, but there are a few that should always be initialized:

  • The protection market contract address to borrow from.

  • If the underlying asset is ETH, the Maximillion contract address to repay borrows with.

The following snippet is an example of a constructor for an invest/divest contract for borrowing ETH from a market and investing into the Curve tricrypto pool.

CozyInvestCurve3CryptoEth.sol
/**
 * @notice On-chain scripts for borrowing from a Cozy Curve 3Crypto Trigger protection market or 
 * ETH money market and using that ETH to add liquidity to the Curve tricrypto USDT/WBTC/WETH pool
 * @dev This contract is intended to be used by delegatecalling to it from a DSProxy
 */
contract CozyInvestCurve3CryptoEth {
  /// @dev OpenZeppelin's Address and SafeERC20 implementations are used for addresses and ERC20 tokens
  using Address for address payable;
  using SafeERC20 for IERC20;

  /// @notice Cozy protection market with ETH underlying to borrow from: Curve 3Crypto Trigger
  address public immutable protectionMarket;

  /// @notice Cozy unprotected money market with ETH underlying
  address public immutable moneyMarket;

  /// @notice Curve 3Crypto Deposit Zap -- helper contract for wrapping ETH before depositing
  ICrvDepositZap public immutable depositZap;

  /// @notice Curve 3Crypto Liquidity Gauge -- contract for measuring liquidity provided over time
  /// and distributing reward tokens
  ICrvGauge public immutable gauge;

  /// @notice Curve 3Crypto receipt token
  IERC20 public immutable curveLpToken;

  /// @notice Maximillion contract for repaying ETH debt
  IMaximillion public immutable maximillion;

  /// @dev Index of WETH in the curve `coins` mapping
  uint256 internal constant ethIndex = 2;

  constructor(
    address _moneyMarket,
    address _protectionMarket,
    address _maximillion,
    address _depositZap,
    address _gauge
  ) {
    moneyMarket = _moneyMarket;
    protectionMarket = _protectionMarket;
    maximillion = IMaximillion(_maximillion);
    gauge = ICrvGauge(_gauge);

    depositZap = ICrvDepositZap(_depositZap);
    curveLpToken = IERC20(depositZap.token());
  }

  ...

Note: The contract above uses the constructor to initalize state variables for required contract addresses to allow re-use among different chains; this is not required.

Develop the invest logic

The invest function logic should automate borrowing from a valid protection market and investing the borrowed funds into a DeFi product.

The following snippet is an example of an invest function on an invest/divest contract for borrowing ETH and investing the borrowed ETH into the Curve tricrypto pool.

CozyInvestCurve3CryptoEth.sol
/**
 * @notice Protected invest method for borrowing from given cozy ETH market,
 * and using that ETH to add liquidity to the Curve 3Crypto pool
 * @param _ethMarket Address of the market to borrow ETH from
 * @param _borrowAmount Amount of ETH to borrow and deposit into Curve
 * @param _curveMinAmountOut The minAmountOut we expect to receive when adding liquidity to Curve
 */
function invest(
  address _ethMarket,
  uint256 _borrowAmount,
  uint256 _curveMinAmountOut
) external payable {
  require(_ethMarket == moneyMarket || _ethMarket == protectionMarket, "Invalid borrow market");
  ICozyEther _market = ICozyEther(_ethMarket);

  // Borrow ETH from Cozy market. The return value from this method is an error code,
  // where a value of zero indicates no error (i.e. the borrow was successful)
  require(_market.borrow(_borrowAmount) == 0, "Borrow failed");

  // Add liquidity to Curve, which gives the caller a receipt token and returns the amount of receipt tokens received
  uint256 _balance = depositZap.add_liquidity{value: _borrowAmount}(
    [0, 0, _borrowAmount],
    _curveMinAmountOut,
    address(this)
  );

  // Approve the Curve tricrypto liquidity gauge to spend our receipt tokens using the safeApprove method. 
  // As mentioned in ERC-20, allowance is set to 0 first to prevent attack vectors 
  // on the approve method (https://eips.ethereum.org/EIPS/eip-20#approve). This is explicitly required by
  // some ERC-20 tokens, such as USDT.
  curveLpToken.safeApprove(address(gauge), 0);
  curveLpToken.safeApprove(address(gauge), type(uint256).max);

  // Deposit lp tokens in to liquidity gauge to earn reward tokens
  gauge.deposit(_balance);
}

Let's decompose the CozyInvestCurve3CryptoEth.sol snippet above:

  1. An error check to ensure that a valid market is requested to be borrowed from. In this case, unprotected borrowing is also supported (optional).

  2. Borrow from the market, wrapped within an error check to ensure the borrow was successful. Note: The borrow will fail if there is insufficient collateral supplied to the market by the sender for the desired borrow amount.

  3. Logic specific to Curve for adding ETH liquidity to a pool and staking Curve LP tokens.

Develop the divest logic

The divest function logic should automate closing positions in a DeFi product that were entered with the contract's invest function, repaying borrowed funds to Cozy markets, and transfering any earnings to the investor's wallet.

Logic for divesting ETH

The following snippet is an example of a divest function on an invest/divest contract for borrowing ETH and investing the borrowed ETH into the Curve tricrypto pool.

CozyInvestCurve3CryptoEth.sol
/**
 * @notice Protected divest method for closing a position opened using this contract's `invest` method
 * @param _ethMarket Address of the market to repay ETH to
 * @param _recipient Address where any leftover ETH should be transferred
 * @param _redeemAmount Amount of Curve receipt tokens to redeem
 * @param _curveMinAmountOut The minAmountOut we expect to receive when removing liquidity from Curve
 */
function divest(
  address _ethMarket,
  address _recipient,
  uint256 _redeemAmount,
  uint256 _curveMinAmountOut
) external payable {
  require(_ethMarket == moneyMarket || _ethMarket == protectionMarket, "Invalid borrow market");

  ICozyEther _market = ICozyEther(_ethMarket);

  // Withdraw lp tokens from liquidity gauge
  gauge.withdraw(_redeemAmount);

  // Approve Curve's depositZap to spend our receipt tokens using the safeApprove method.
  // As mentioned in ERC-20, allowance is set to 0 first to prevent attack vectors 
  // on the approve method (https://eips.ethereum.org/EIPS/eip-20#approve). This is explicitly required by
  // some ERC-20 tokens, such as USDT.
  curveLpToken.safeApprove(address(depositZap), 0);
  curveLpToken.safeApprove(address(depositZap), type(uint256).max);

  // Withdraw from Curve
  depositZap.remove_liquidity_one_coin(_redeemAmount, ethIndex, _curveMinAmountOut, address(this));

  // Pay back as much of the borrow as possible, excess ETH is refunded to `recipient`. Maximillion handles
  // error codes when repayment is unsuccessful.
  maximillion.repayBehalfExplicit{value: address(this).balance}(address(this), _market);

  // Transfer any remaining funds to the user
  payable(_recipient).sendValue(address(this).balance);

  // Claim reward tokens from liquidity gauge and transfer to the user
  claimRewards(_recipient);
}

Let's decompose the CozyInvestCurve3CryptoEth.sol snippet above:

  1. An error check to ensure that a valid market is requested to repay debt to. In this case, unprotected borrowing is also supported (optional).

  2. Logic specific to Curve for closing the investment position; unstaking Curve LP tokens and withdrawing liquidity from Curve (in this case, ETH).

  3. Cozy market debt repayment. Any remaining funds after repayment are transferred to the user. In this case, the underlying asset is ETH, so the Maximillion contract is used which includes error code handling.

  4. Curve reward tokens are claimed and transferred to the user. This is only required for investment opportunities that accrue rewards.

Logic for divesting ERC20 tokens

There are a few notable differences for divesting ERC20 tokens, as shown in the following example of a divest function on an invest/divest contract for borrowing WBTC and depositing the borrowed WBTC into the Curve TBTC pool for receipt tokens, then staking the receipt tokens in the Convex TBTC pool.

CozyInvestConvex.sol
/**
  * @notice Protected divest method for exiting a position entered using this contract's `invest` method
  * @param _market Address of the market to repay debt to
  * @param _recipient Address where any leftover funds should be transferred
  * @param _withdrawAmount Amount of Curve receipt tokens to redeem
  * @param _excessTokens Quantity to transfer from the caller into this address to ensure
  * the borrow can be repaid in full.
  * @param _curveMinAmountOut The minAmountOut we expect to receive when removing liquidity from Curve
  */
function divest(
  address _market,
  address _recipient,
  uint256 _withdrawAmount,
  uint256 _excessTokens,
  uint256 _curveMinAmountOut
) external {
  require(_market == moneyMarket || _market == protectionMarket, "Invalid borrow market");

  // 1. Withdraw Curve receipt tokens from from Convex
  IConvexRewardManager _convexRewardManager = IConvexRewardManager(convexRewardManager);
  _convexRewardManager.withdrawAndUnwrap(_withdrawAmount, false);

  // 2. Withdraw from Curve
  // There are two kinds of curve zaps -- one requires curve pool to be specified in first argument.
  // Approve Curve's depositZap to spend our receipt tokens.
  // As mentioned in ERC-20, allowance is set to 0 first to prevent attack vectors on the approve 
  // method (https://eips.ethereum.org/EIPS/eip-20#approve). This is explicitly required by some 
  // ERC-20 tokens, such as USDT.
  IERC20(curveLpToken).safeApprove(curveDepositZap, 0);
  IERC20(curveLpToken).safeApprove(curveDepositZap, type(uint256).max);
  if (longSigFormat) {
    ICrvDepositZap(curveDepositZap).remove_liquidity_one_coin(
      curveLpToken,
      _withdrawAmount,
      curveIndex,
      _curveMinAmountOut
    );
  } else {
    ICrvDepositZap(curveDepositZap).remove_liquidity_one_coin(_withdrawAmount, curveIndex, _curveMinAmountOut);
  }

  // Pay back as much of the borrow as possible, excess is refunded to `recipient`
  executeMaxRepay(_market, address(underlying), _excessTokens);

  // Transfer any remaining tokens to the user after paying back borrow
  IERC20(underlying).transfer(_recipient, IERC20(underlying).balanceOf(address(this)));
  claimRewards(_recipient);
}

Let's decompose the CozyInvestConvex.sol snippet above:

  1. An _excessTokens function parameter is supported, which allows the caller to transfer additional quantities of the underlying asset to repay borrows if the investment withdrawal does not fully cover the debt owed to the Cozy market.

  2. An error check to ensure that a valid market is requested to repay debt to. In this case, unprotected borrowing is also supported (optional).

  3. Logic specific to Convex and Curve for closing the investment position; unstaking Curve LP tokens in Convex and withdrawing liquidity from Curve (in this case, WBTC).

  4. Cozy market debt repayment. Any remaining funds after repayment are transferred to the user. For ERC20 tokens, make sure to include error code checks for repaying borrows. In this case, we use a custom defined executeMaxRepay function which includes the error check and supports debt repayment with _excessTokens.

     /**
     * @notice Repays as much token debt as possible
     * @param _market Market to repay
     * @param _underlying That market's underlying token (can be obtained by a call, but passing it in saves gas)
     * @param _excessTokens Quantity to transfer from the caller into this address to ensure
     * the borrow can be repaid in full. Only required if you want to repay the full borrow amount and the
     * amount obtained from withdrawing from the invest opportunity will not cover the full debt. A value of zero
     * will not attempt to transfer tokens from the caller, and the transfer will not be attempted if it's not required
     */
     function executeMaxRepay(
       address _market,
       address _underlying,
       uint256 _excessTokens
     ) internal {
       // Pay back as much of the borrow as possible, excess is refunded to `recipient`
       uint256 _borrowBalance = ICozyToken(_market).borrowBalanceCurrent(address(this));
       uint256 _initialBalance = IERC20(_underlying).balanceOf(address(this));
       if (_initialBalance < _borrowBalance && _excessTokens > 0) {
         TransferHelper.safeTransferFrom(_underlying, msg.sender, address(this), _excessTokens);
       }
       uint256 _balance = _initialBalance + _excessTokens; // this contract's current balance
       uint256 _repayAmount = _balance >= _borrowBalance ? type(uint256).max : _balance;
    
       // Approve the market to spend the repay amount in the given underlying token
       TransferHelper.safeApprove(_underlying, address(_market), _repayAmount);
    
       // Repay borrow to the Cozy market. The return value from this method is an error code,
       // where a value of zero indicates no error (i.e. the repay was successful)
       require(ICozyToken(_market).repayBorrow(_repayAmount) == 0, "Repay failed");
     }
  5. Any remaining underlying tokens (in this case, WBTC) are transferred to the user after debt repayment.

  6. Convex reward tokens are claimed and transferred to the user. This is only required for investment opportunities that accrue rewards.

Develop the reward claim logic

If the investment opportunity accrues rewards, this function is required to claim them and transfer them to the investor's wallet. This function should also be used in the divest function.

The following snippet is an example of a claimRewards function on an invest/divest contract for claiming staking rewards from Curve.

CozyInvestCurve3CryptoEth.sol
/**
 * @notice Method to claim reward tokens from Curve and transfer to recipient
 * @param _recipient Address of the owner's wallet
 */
function claimRewards(address _recipient) public {
  gauge.claim_rewards(address(this), _recipient);
}

Testing

When writing tests for invest/divest contracts, some setup is required in your test suite to replicate production behaviour. The following steps should be implemented in your test suite setup fixture:

  1. Deploy the new invest/divest contract.

  2. Deploy a proxy wallet. Cozy uses a proxy wallet for all borrows and investment opportunities.

  3. Supply funds to the protection market used in the test suite.

  4. Give the user's proxy wallet collateral so they can successfully borrow from Cozy markets.

Examples on how to implement the above in a development environment using JavaScript and Hardhat can be found in the CozyInvestCurve3CryptoEth.test.ts and CozyInvestConvex.test.ts files in the Cozy Developer Guides repository. The test suites in these examples also include examples of test cases.

Once there is sufficient test coverage, you are ready to deploy your new invest/divest contract.

Last updated