diff --git a/contracts/mainnet/connectors/liquity/events.sol b/contracts/mainnet/connectors/liquity/events.sol new file mode 100644 index 00000000..c7d1dcc8 --- /dev/null +++ b/contracts/mainnet/connectors/liquity/events.sol @@ -0,0 +1,42 @@ +pragma solidity ^0.7.0; + +contract Events { + + /* Trove */ + event LogOpen( + address indexed borrower, + uint maxFeePercentage, + uint depositAmount, + uint borrowAmount, + uint getId, + uint setId + ); + event LogClose(address indexed borrower, uint setId); + event LogDeposit(address indexed borrower, uint amount, uint getId); + event LogWithdraw(address indexed borrower, uint amount, uint setId); + event LogBorrow(address indexed borrower, uint amount, uint setId); + event LogRepay(address indexed borrower, uint amount, uint getId); + event LogAdjust( + address indexed borrower, + uint maxFeePercentage, + uint depositAmount, + uint withdrawAmount, + uint borrowAmount, + uint repayAmount, + uint getDepositId, + uint setWithdrawId, + uint getRepayId, + uint setBorrowId + ); + event LogClaimCollateralFromRedemption(address indexed borrower); + + /* Stability Pool */ + event LogStabilityDeposit(address indexed borrower, uint amount, address frontendTag, uint getId); + event LogStabilityWithdraw(address indexed borrower, uint amount, uint setId); + event LogStabilityMoveEthGainToTrove(address indexed borrower); + + /* Staking */ + event LogStake(address indexed borrower, uint amount, uint getId); + event LogUnstake(address indexed borrower, uint amount, uint setId); + event LogClaimGains(address indexed borrower); +} \ No newline at end of file diff --git a/contracts/mainnet/connectors/liquity/helpers.sol b/contracts/mainnet/connectors/liquity/helpers.sol new file mode 100644 index 00000000..1fb15204 --- /dev/null +++ b/contracts/mainnet/connectors/liquity/helpers.sol @@ -0,0 +1,6 @@ +pragma solidity ^0.7.0; + +import { DSMath } from "../../common/math.sol"; +import { Basic } from "../../common/basic.sol"; + +abstract contract Helpers is DSMath, Basic {} \ No newline at end of file diff --git a/contracts/mainnet/connectors/liquity/interface.sol b/contracts/mainnet/connectors/liquity/interface.sol new file mode 100644 index 00000000..80782912 --- /dev/null +++ b/contracts/mainnet/connectors/liquity/interface.sol @@ -0,0 +1,60 @@ +pragma solidity ^0.7.0; + +interface BorrowerOperationsLike { + function openTrove( + uint256 _maxFee, + uint256 _LUSDAmount, + address _upperHint, + address _lowerHint + ) external payable; + + function addColl(address _upperHint, address _lowerHint) external payable; + + function withdrawColl( + uint256 _amount, + address _upperHint, + address _lowerHint + ) external; + + function withdrawLUSD( + uint256 _maxFee, + uint256 _amount, + address _upperHint, + address _lowerHint + ) external; + + function repayLUSD( + uint256 _amount, + address _upperHint, + address _lowerHint + ) external; + + function closeTrove() external; + + function adjustTrove( + uint256 _maxFee, + uint256 _collWithdrawal, + uint256 _debtChange, + bool isDebtIncrease, + address _upperHint, + address _lowerHint + ) external payable; + + function claimCollateral() external; +} + +interface TroveManagerLike { + function getTroveColl(address _borrower) external view returns (uint); + function getTroveDebt(address _borrower) external view returns (uint); +} + +interface StabilityPoolLike { + function provideToSP(uint _amount, address _frontEndTag) external; + function withdrawFromSP(uint _amount) external; + function withdrawETHGainToTrove(address _upperHint, address _lowerHint) external; +} + +interface StakingLike { + function stake(uint _LQTYamount) external; + function unstake(uint _LQTYamount) external; +} diff --git a/contracts/mainnet/connectors/liquity/main.sol b/contracts/mainnet/connectors/liquity/main.sol new file mode 100644 index 00000000..948b6450 --- /dev/null +++ b/contracts/mainnet/connectors/liquity/main.sol @@ -0,0 +1,347 @@ +pragma solidity ^0.7.0; + +/** + * @title Liquity. + * @dev Lending & Borrowing. + */ +import "hardhat/console.sol"; + +import { BorrowerOperationsLike, TroveManagerLike, StabilityPoolLike, StakingLike } from "./interface.sol"; +import { Stores } from "../../common/stores.sol"; +import { Helpers } from "./helpers.sol"; +import { Events } from "./events.sol"; + +abstract contract LiquityResolver is Events, Helpers { + BorrowerOperationsLike internal constant borrowerOperations = + BorrowerOperationsLike(0x24179CD81c9e782A4096035f7eC97fB8B783e007); + TroveManagerLike internal constant troveManager = + TroveManagerLike(0xA39739EF8b0231DbFA0DcdA07d7e29faAbCf4bb2); + StabilityPoolLike internal constant stabilityPool = + StabilityPoolLike(0x66017D22b0f8556afDd19FC67041899Eb65a21bb); + StakingLike internal constant staking = + StakingLike(0x4f9Fbb3f1E99B56e0Fe2892e623Ed36A76Fc605d); + + struct AdjustTrove { + uint maxFeePercentage; + uint withdrawAmount; + uint depositAmount; + uint borrowAmount; + uint repayAmount; + bool isBorrow; + } + + constructor() { + console.log("Liquity Connector contract deployed at", address(this)); + } + + /* Begin: Trove */ + + /** + * @dev Deposit native ETH and borrow LUSD + * @notice Opens a Trove by depositing ETH and borrowing LUSD + * @param depositAmount The amount of ETH to deposit + * @param maxFeePercentage The maximum borrow fee that this transaction should permit + * @param borrowAmount The amount of LUSD to borrow + * @param upperHint Address of the Trove near the upper bound of where the user's Trove will now sit in the ordered Trove list + * @param lowerHint Address of the Trove near the lower bound of where the user's Trove will now sit in the ordered Trove list + * @param getId Optional storage slot to retrieve ETH instead of receiving it from msg.value + * @param setId Optional storage slot to store the LUSD borrowed against + */ + function open( + uint depositAmount, + uint maxFeePercentage, + uint borrowAmount, + address upperHint, + address lowerHint, + uint getId, + uint setId + ) external payable returns (string memory _eventName, bytes memory _eventParam) { + // User can either send ETH directly or have it collected from a previous spell + depositAmount = getUint(getId, depositAmount); + + borrowerOperations.openTrove{value: depositAmount}( + maxFeePercentage, + borrowAmount, + upperHint, + lowerHint + ); + + // Allow other spells to use the borrowed amount + setUint(setId, borrowAmount); + _eventName = "LogOpen(address,uint,uint,uint,uint,uint)"; + _eventParam = abi.encode(msg.sender, maxFeePercentage, depositAmount, borrowAmount, getId, setId); + } + + /** + * @dev Repay LUSD debt from the DSA account's LUSD balance, and withdraw ETH to DSA + * @notice Closes a Trove by repaying LUSD debt + * @param setId Optional storage slot to store the ETH withdrawn from the Trove + */ + function close(uint setId) external returns (string memory _eventName, bytes memory _eventParam) { + uint collateral = troveManager.getTroveColl(address(this)); + borrowerOperations.closeTrove(); + + // Allow other spells to use the collateral released from the Trove + setUint(setId, collateral); + _eventName = "LogClose(address,uint)"; + _eventParam = abi.encode(msg.sender, setId); + } + + /** + * @dev Deposit ETH to Trove + * @notice Increase Trove collateral (collateral Top up) + * @param amount Amount of ETH to deposit into Trove + * @param upperHint Address of the Trove near the upper bound of where the user's Trove will now sit in the ordered Trove list + * @param lowerHint Address of the Trove near the lower bound of where the user's Trove will now sit in the ordered Trove list + * @param getId Optional storage slot to retrieve the ETH from + */ + function deposit( + uint amount, + address upperHint, + address lowerHint, + uint getId + ) external payable returns (string memory _eventName, bytes memory _eventParam) { + amount = getUint(getId, amount); + borrowerOperations.addColl{value: amount}(upperHint, lowerHint); + _eventName = "LogDeposit(address,uint,uint)"; + _eventParam = abi.encode(msg.sender, amount, getId); + } + + /** + * @dev Withdraw ETH from Trove + * @notice Move Trove collateral from Trove to DSA + * @param amount Amount of ETH to move from Trove to DSA + * @param upperHint Address of the Trove near the upper bound of where the user's Trove will now sit in the ordered Trove list + * @param lowerHint Address of the Trove near the lower bound of where the user's Trove will now sit in the ordered Trove list + * @param setId Optional storage slot to store the withdrawn ETH in + */ + function withdraw( + uint amount, + address upperHint, + address lowerHint, + uint setId + ) external payable returns (string memory _eventName, bytes memory _eventParam) { + borrowerOperations.withdrawColl(amount, upperHint, lowerHint); + + setUint(setId, amount); + _eventName = "LogWithdraw(address,uint,uint)"; + _eventParam = abi.encode(msg.sender, amount, setId); + } + + /** + * @dev Mints LUSD tokens + * @notice Borrow LUSD via an existing Trove + * @param maxFeePercentage The maximum borrow fee that this transaction should permit + * @param amount Amount of LUSD to borrow + * @param upperHint Address of the Trove near the upper bound of where the user's Trove will now sit in the ordered Trove list + * @param lowerHint Address of the Trove near the lower bound of where the user's Trove will now sit in the ordered Trove list + * @param setId Optional storage slot to store the borrowed LUSD in + */ + function borrow( + uint maxFeePercentage, + uint amount, + address upperHint, + address lowerHint, + uint setId + ) external payable returns (string memory _eventName, bytes memory _eventParam) { + borrowerOperations.withdrawLUSD(maxFeePercentage, amount, upperHint, lowerHint); + + setUint(setId, amount); // TODO: apply fee / get exact amount borrowed (with the fee applied) + _eventName = "LogBorrow(address,uint,uint)"; + _eventParam = abi.encode(msg.sender, amount, setId); + } + + /** + * @dev Send LUSD to repay debt + * @notice Repay LUSD Trove debt + * @param amount Amount of LUSD to repay + * @param upperHint Address of the Trove near the upper bound of where the user's Trove will now sit in the ordered Trove list + * @param lowerHint Address of the Trove near the lower bound of where the user's Trove will now sit in the ordered Trove list + * @param getId Optional storage slot to retrieve the LUSD from + */ + function repay( + uint amount, + address upperHint, + address lowerHint, + uint getId + ) external payable returns (string memory _eventName, bytes memory _eventParam) { + amount = getUint(getId, amount); + borrowerOperations.repayLUSD(amount, upperHint, lowerHint); + _eventName = "LogRepay(address,uint,uint)"; + _eventParam = abi.encode(msg.sender, amount, getId); + } + + /** + * @dev Increase or decrease Trove ETH collateral and LUSD debt in one transaction + * @notice Adjust Trove debt and/or collateral + * @param maxFeePercentage The maximum borrow fee that this transaction should permit + * @param withdrawAmount Amount of ETH to withdraw + * @param depositAmount Amount of ETH to deposit + * @param borrowAmount Amount of LUSD to borrow + * @param repayAmount Amount of LUSD to repay + * @param upperHint Address of the Trove near the upper bound of where the user's Trove will now sit in the ordered Trove list + * @param lowerHint Address of the Trove near the lower bound of where the user's Trove will now sit in the ordered Trove list + * @param getDepositId Optional storage slot to retrieve the ETH to deposit + * @param setWithdrawId Optional storage slot to store the withdrawn ETH to + * @param getRepayId Optional storage slot to retrieve the LUSD to repay + * @param setBorrowId Optional storage slot to store the LUSD borrowed + */ + function adjust( + uint maxFeePercentage, + uint withdrawAmount, + uint depositAmount, + uint borrowAmount, + uint repayAmount, + address upperHint, + address lowerHint, + uint getDepositId, + uint setWithdrawId, + uint getRepayId, + uint setBorrowId + ) external payable returns (string memory _eventName, bytes memory _eventParam) { + AdjustTrove memory adjustTrove; + + adjustTrove.maxFeePercentage = maxFeePercentage; + adjustTrove.withdrawAmount = withdrawAmount; + adjustTrove.depositAmount = getUint(getDepositId, depositAmount); + adjustTrove.borrowAmount = borrowAmount; + adjustTrove.repayAmount = getUint(getRepayId, repayAmount); + adjustTrove.isBorrow = borrowAmount > 0; + + borrowerOperations.adjustTrove{value: depositAmount}( + adjustTrove.maxFeePercentage, + adjustTrove.withdrawAmount, + adjustTrove.borrowAmount, + adjustTrove.isBorrow, + upperHint, + lowerHint + ); + + // Allow other spells to use the withdrawn collateral + setUint(setWithdrawId, withdrawAmount); + + // Allow other spells to use the borrowed amount + setUint(setBorrowId, borrowAmount); + + _eventName = "LogAdjust(address,uint,uint,uint,uint,uint,uint,uint,uint,uint)"; + _eventParam = abi.encode(msg.sender, maxFeePercentage, depositAmount, borrowAmount, getDepositId, setWithdrawId, getRepayId, setBorrowId); + } + + /** + * @dev Withdraw remaining ETH balance from user's redeemed Trove to their DSA + * @notice Claim remaining collateral from Trove + */ + function claimCollateralFromRedemption() external returns(string memory _eventName, bytes memory _eventParam) { + borrowerOperations.claimCollateral(); + _eventName = "LogClaimCollateralFromRedemption(address)"; + _eventParam = abi.encode(msg.sender); + } + /* End: Trove */ + + /* Begin: Stability Pool */ + + /** + * @dev Deposit LUSD into Stability Pool + * @notice Deposit LUSD into Stability Pool + * @param amount Amount of LUSD to deposit into Stability Pool + * @param frontendTag Address of the frontend to make this deposit against (determines the kickback rate of rewards) + * @param getId Optional storage slot to retrieve the LUSD from + */ + function stabilityDeposit( + uint amount, + address frontendTag, + uint getId + ) external returns (string memory _eventName, bytes memory _eventParam) { + amount = getUint(getId, amount); + + stabilityPool.provideToSP(amount, frontendTag); + + _eventName = "LogStabilityDeposit(address,uint,address,uint)"; + _eventParam = abi.encode(msg.sender, amount, frontendTag, getId); + } + + /** + * @dev Withdraw user deposited LUSD from Stability Pool + * @notice Withdraw LUSD from Stability Pool + * @param amount Amount of LUSD to withdraw from Stability Pool + * @param setId Optional storage slot to store the withdrawn LUSD + */ + function stabilityWithdraw( + uint amount, + uint setId + ) external returns (string memory _eventName, bytes memory _eventParam) { + stabilityPool.withdrawFromSP(amount); + setUint(setId, amount); + + _eventName = "LogStabilityWithdraw(address,uint,uint)"; + _eventParam = abi.encode(msg.sender, amount, setId); + } + + /** + * @dev Increase Trove collateral by sending Stability Pool ETH gain to user's Trove + * @notice Moves user's ETH gain from the Stability Pool into their Trove + * @param upperHint Address of the Trove near the upper bound of where the user's Trove will now sit in the ordered Trove list + * @param lowerHint Address of the Trove near the lower bound of where the user's Trove will now sit in the ordered Trove list + */ + function stabilityMoveEthGainToTrove( + address upperHint, + address lowerHint + ) external returns (string memory _eventName, bytes memory _eventParam) { + stabilityPool.withdrawETHGainToTrove(upperHint, lowerHint); + + _eventName = "LogStabilityMoveEthGainToTrove(address)"; + _eventParam = abi.encode(msg.sender); + } + /* End: Stability Pool */ + + /* Begin: Staking */ + + /** + * @dev Sends LQTY tokens from user to Staking Pool + * @notice Stake LQTY in Staking Pool + * @param amount Amount of LQTY to stake + * @param getId Optional storage slot to retrieve the LQTY from + */ + function stake( + uint amount, + uint getId + ) external returns (string memory _eventName, bytes memory _eventParam) { + amount = getUint(getId, amount); + staking.stake(amount); + _eventName = "LogStake(address,uint,uint)"; + _eventParam = abi.encode(msg.sender, amount, getId); + } + + /** + * @dev Sends LQTY tokens from Staking Pool to user + * @notice Unstake LQTY in Staking Pool + * @param amount Amount of LQTY to unstake + * @param setId Optional storage slot to store the unstaked LQTY + */ + function unstake( + uint amount, + uint setId + ) external returns (string memory _eventName, bytes memory _eventParam) { + staking.unstake(amount); + setUint(setId, amount); + _eventName = "LogUnstake(address,uint,uint)"; + _eventParam = abi.encode(msg.sender, amount, setId); + } + + /** + * @dev Sends ETH and LUSD gains from Staking to user + * @notice Claim ETH and LUSD gains from Staking + */ + function claimGains() external returns (string memory _eventName, bytes memory _eventParam) { + // claims are gained when a user's stake is adjusted, so we unstake 0 to trigger the claim + staking.unstake(0); + _eventName = "LogClaimGains(address)"; + _eventParam = abi.encode(msg.sender); + } + /* End: Staking */ + +} + +contract ConnectV2Liquity is LiquityResolver { + string public name = "Liquity-v1"; +} diff --git a/hardhat.config.js b/hardhat.config.js index b48aa2f3..6c1f8bb2 100644 --- a/hardhat.config.js +++ b/hardhat.config.js @@ -1,12 +1,11 @@ - require("@nomiclabs/hardhat-waffle"); require("@nomiclabs/hardhat-ethers"); require("@tenderly/hardhat-tenderly"); require("@nomiclabs/hardhat-etherscan"); -require("@nomiclabs/hardhat-web3") +require("@nomiclabs/hardhat-web3"); require("hardhat-deploy"); require("hardhat-deploy-ethers"); -require('dotenv').config(); +require("dotenv").config(); const { utils } = require("ethers"); @@ -20,30 +19,30 @@ module.exports = { solidity: { compilers: [ { - version: "0.7.6" + version: "0.7.6", }, { - version: "0.6.0" + version: "0.6.0", }, { - version: "0.6.2" + version: "0.6.2", }, { - version: "0.6.5" - } - ] + version: "0.6.5", + }, + ], }, networks: { // defaultNetwork: "hardhat", kovan: { url: `https://eth-kovan.alchemyapi.io/v2/${ALCHEMY_ID}`, - accounts: [`0x${PRIVATE_KEY}`] + accounts: [`0x${PRIVATE_KEY}`], }, mainnet: { url: `https://eth-mainnet.alchemyapi.io/v2/${ALCHEMY_ID}`, accounts: [`0x${PRIVATE_KEY}`], timeout: 150000, - gasPrice: parseInt(utils.parseUnits("132", "gwei")) + gasPrice: parseInt(utils.parseUnits("132", "gwei")), }, hardhat: { forking: { @@ -56,14 +55,14 @@ module.exports = { url: "https://rpc-mainnet.maticvigil.com/", accounts: [`0x${PRIVATE_KEY}`], timeout: 150000, - gasPrice: parseInt(utils.parseUnits("1", "gwei")) - } + gasPrice: parseInt(utils.parseUnits("1", "gwei")), + }, }, etherscan: { - apiKey: process.env.ETHERSCAN_API_KEY + apiKey: process.env.ETHERSCAN_API_KEY, }, tenderly: { project: process.env.TENDERLY_PROJECT, username: process.env.TENDERLY_USERNAME, - } + }, }; diff --git a/scripts/constant/abis.js b/scripts/constant/abis.js index c25bd350..5f0c4d65 100644 --- a/scripts/constant/abis.js +++ b/scripts/constant/abis.js @@ -1,14 +1,14 @@ module.exports = { - core: { - connectorsV2: require("./abi/core/connectorsV2.json"), - instaIndex: require("./abi/core/instaIndex.json"), - }, - connectors: { - basic: require("./abi/connectors/basic.json"), - auth: require("./abi/connectors/auth.json"), - }, - basic: { - erc20: require("./abi/basics/erc20.json"), - }, - }; - \ No newline at end of file + core: { + connectorsV2: require("./abi/core/connectorsV2.json"), + instaIndex: require("./abi/core/instaIndex.json"), + }, + connectors: { + "Basic-v1": require("./abi/connectors/basic.json"), + basic: require("./abi/connectors/basic.json"), + auth: require("./abi/connectors/auth.json"), + }, + basic: { + erc20: require("./abi/basics/erc20.json"), + }, +}; diff --git a/scripts/constant/addresses.js b/scripts/constant/addresses.js index ead09c40..cea51b29 100644 --- a/scripts/constant/addresses.js +++ b/scripts/constant/addresses.js @@ -1,11 +1,10 @@ module.exports = { - connectors: { - basic: "0xe5398f279175962E56fE4c5E0b62dc7208EF36c6", - auth: "0xd1aff9f2acf800c876c409100d6f39aea93fc3d9", - }, - core: { - connectorsV2: "0xFE2390DAD597594439f218190fC2De40f9Cf1179", - instaIndex: "0x2971AdFa57b20E5a416aE5a708A8655A9c74f723" - } - }; - \ No newline at end of file + connectors: { + basic: "0xe5398f279175962E56fE4c5E0b62dc7208EF36c6", + auth: "0xd1aff9f2acf800c876c409100d6f39aea93fc3d9", + }, + core: { + connectorsV2: "0x97b0B3A8bDeFE8cB9563a3c610019Ad10DB8aD11", + instaIndex: "0x2971AdFa57b20E5a416aE5a708A8655A9c74f723", + }, +}; diff --git a/test/liquity/liquity.abi.js b/test/liquity/liquity.abi.js new file mode 100644 index 00000000..d1171d1d --- /dev/null +++ b/test/liquity/liquity.abi.js @@ -0,0 +1,84 @@ +const TROVE_MANAGER_ADDRESS = "0xA39739EF8b0231DbFA0DcdA07d7e29faAbCf4bb2"; +const TROVE_MANAGER_ABI = [ + "function getTroveColl(address _borrower) external view returns (uint)", + "function getTroveDebt(address _borrower) external view returns (uint)", + "function getTroveStatus(address _borrower) external view returns (uint)", + "function redeemCollateral(uint _LUSDAmount, address _firstRedemptionHint, address _upperPartialRedemptionHint, address _lowerPartialRedemptionHint, uint _partialRedemptionHintNICR, uint _maxIterations, uint _maxFee) external returns (uint)", + "function getNominalICR(address _borrower) external view returns (uint)", + "function liquidate(address _borrower) external", + "function liquidateTroves(uint _n) external", +]; + +const BORROWER_OPERATIONS_ADDRESS = + "0x24179CD81c9e782A4096035f7eC97fB8B783e007"; +const BORROWER_OPERATIONS_ABI = [ + "function openTrove(uint256 _maxFee, uint256 _LUSDAmount, address _upperHint, address _lowerHint) external payable", + "function closeTrove() external", +]; + +const LUSD_TOKEN_ADDRESS = "0x5f98805A4E8be255a32880FDeC7F6728C6568bA0"; +const LUSD_TOKEN_ABI = [ + "function transfer(address _to, uint256 _value) public returns (bool success)", + "function balanceOf(address account) external view returns (uint256)", + "function approve(address spender, uint256 amount) external returns (bool)", +]; + +const ACTIVE_POOL_ADDRESS = "0xDf9Eb223bAFBE5c5271415C75aeCD68C21fE3D7F"; +const ACTIVE_POOL_ABI = ["function getLUSDDebt() external view returns (uint)"]; + +const PRICE_FEED_ADDRESS = "0x4c517D4e2C851CA76d7eC94B805269Df0f2201De"; +const PRICE_FEED_ABI = ["function fetchPrice() external returns (uint)"]; + +const HINT_HELPERS_ADDRESS = "0xE84251b93D9524E0d2e621Ba7dc7cb3579F997C0"; +const HINT_HELPERS_ABI = [ + "function getRedemptionHints(uint _LUSDamount, uint _price, uint _maxIterations) external view returns (address firstRedemptionHint, uint partialRedemptionHintNICR, uint truncatedLUSDamount)", + "function getApproxHint(uint _CR, uint _numTrials, uint _inputRandomSeed) view returns (address hintAddress, uint diff, uint latestRandomSeed)", + "function computeNominalCR(uint _coll, uint _debt) external pure returns (uint)", +]; + +const SORTED_TROVES_ADDRESS = "0x8FdD3fbFEb32b28fb73555518f8b361bCeA741A6"; +const SORTED_TROVES_ABI = [ + "function findInsertPosition(uint256 _ICR, address _prevId, address _nextId) external view returns (address, address)", + "function getLast() external view returns (address)", +]; + +const STABILITY_POOL_ADDRESS = "0x66017D22b0f8556afDd19FC67041899Eb65a21bb"; +const STABILITY_POOL_ABI = [ + "function getCompoundedLUSDDeposit(address _depositor) external view returns (uint)", + "function getDepositorETHGain(address _depositor) external view returns (uint)", +]; + +const STAKING_ADDRESS = "0x4f9Fbb3f1E99B56e0Fe2892e623Ed36A76Fc605d"; +const STAKING_ABI = [ + "function stake(uint _LQTYamount) external", + "function unstake(uint _LQTYamount) external", +]; + +const LQTY_TOKEN_ADDRESS = "0x6DEA81C8171D0bA574754EF6F8b412F2Ed88c54D"; +const LQTY_TOKEN_ABI = [ + "function balanceOf(address account) external view returns (uint256)", + "function transfer(address _to, uint256 _value) public returns (bool success)", +]; + +module.exports = { + TROVE_MANAGER_ADDRESS, + TROVE_MANAGER_ABI, + BORROWER_OPERATIONS_ADDRESS, + BORROWER_OPERATIONS_ABI, + LUSD_TOKEN_ADDRESS, + LUSD_TOKEN_ABI, + STABILITY_POOL_ADDRESS, + STABILITY_POOL_ABI, + ACTIVE_POOL_ADDRESS, + ACTIVE_POOL_ABI, + PRICE_FEED_ADDRESS, + PRICE_FEED_ABI, + HINT_HELPERS_ADDRESS, + HINT_HELPERS_ABI, + SORTED_TROVES_ADDRESS, + SORTED_TROVES_ABI, + STAKING_ADDRESS, + STAKING_ABI, + LQTY_TOKEN_ADDRESS, + LQTY_TOKEN_ABI, +}; diff --git a/test/liquity/liquity.helpers.js b/test/liquity/liquity.helpers.js new file mode 100644 index 00000000..c400ded5 --- /dev/null +++ b/test/liquity/liquity.helpers.js @@ -0,0 +1,327 @@ +const hre = require("hardhat"); +const hardhatConfig = require("../../hardhat.config"); + +// Instadapp deployment and testing helpers +const deployAndEnableConnector = require("../../scripts/deployAndEnableConnector.js"); +const encodeSpells = require("../../scripts/encodeSpells.js"); +const getMasterSigner = require("../../scripts/getMasterSigner"); + +// Instadapp instadappAddresses/ABIs +const instadappAddresses = require("../../scripts/constant/addresses"); +const instadappAbi = require("../../scripts/constant/abis"); + +// Instadapp Liquity Connector artifacts +const connectV2LiquityArtifacts = require("../../artifacts/contracts/mainnet/connectors/liquity/main.sol/ConnectV2Liquity.json"); +const connectV2BasicV1Artifacts = require("../../artifacts/contracts/mainnet/connectors/basic/main.sol/ConnectV2Basic.json"); + +const CONNECTOR_NAME = "LIQUITY-v1-TEST"; +const LUSD_GAS_COMPENSATION = hre.ethers.utils.parseUnits("200", 18); // 200 LUSD gas compensation repaid after loan repayment +const BLOCK_NUMBER = 12478159; // Deterministic block number for tests to run against, if you change this, tests will break. +const JUSTIN_SUN_ADDRESS = "0x903d12bf2c57a29f32365917c706ce0e1a84cce3"; // LQTY whale address +const LIQUIDATABLE_TROVE_ADDRESS = "0xafbeb4cb97f3b08ec2fe07ef0dac15d37013a347"; // Trove which is liquidatable at blockNumber: BLOCK_NUMBER +const MAX_GAS = hardhatConfig.networks.hardhat.blockGasLimit; // Maximum gas limit (12000000) + +const openTroveSpell = async ( + dsa, + signer, + depositAmount, + borrowAmount, + upperHint, + lowerHint, + maxFeePercentage +) => { + let address = signer.address; + if (signer.address === undefined) { + address = await signer.getAddress(); + } + + const openTroveSpell = { + connector: CONNECTOR_NAME, + method: "open", + args: [ + depositAmount, + maxFeePercentage, + borrowAmount, + upperHint, + lowerHint, + 0, + 0, + ], + }; + const openTx = await dsa + .connect(signer) + .cast(...encodeSpells([openTroveSpell]), address, { + value: depositAmount, + }); + return await openTx.wait(); +}; + +const createDsaTrove = async ( + dsa, + signer, + hintHelpers, + sortedTroves, + depositAmount = hre.ethers.utils.parseEther("5"), + borrowAmount = hre.ethers.utils.parseUnits("2000", 18) +) => { + const maxFeePercentage = hre.ethers.utils.parseUnits("0.5", 18); // 0.5% max fee + const { upperHint, lowerHint } = await getTroveInsertionHints( + depositAmount, + borrowAmount, + hintHelpers, + sortedTroves + ); + return await openTroveSpell( + dsa, + signer, + depositAmount, + borrowAmount, + upperHint, + lowerHint, + maxFeePercentage + ); +}; + +const sendToken = async (token, amount, from, to) => { + await hre.network.provider.request({ + method: "hardhat_impersonateAccount", + params: [from], + }); + const signer = await hre.ethers.provider.getSigner(from); + + return await token.connect(signer).transfer(to, amount); +}; + +const resetHardhatBlockNumber = async (blockNumber) => { + return await hre.network.provider.request({ + method: "hardhat_reset", + params: [ + { + forking: { + jsonRpcUrl: hardhatConfig.networks.hardhat.forking.url, + blockNumber, + }, + }, + ], + }); +}; + +const deployAndConnect = async (contracts, isDebug = false) => { + // Pin Liquity tests to a particular block number to create deterministic state (Ether price etc.) + await resetHardhatBlockNumber(BLOCK_NUMBER); + + const liquity = { + troveManager: null, + borrowerOperations: null, + stabilityPool: null, + lusdToken: null, + lqtyToken: null, + activePool: null, + priceFeed: null, + hintHelpers: null, + sortedTroves: null, + staking: null, + }; + + const masterSigner = await getMasterSigner(); + const instaConnectorsV2 = await ethers.getContractAt( + instadappAbi.core.connectorsV2, + instadappAddresses.core.connectorsV2 + ); + const connector = await deployAndEnableConnector({ + connectorName: CONNECTOR_NAME, + contractArtifact: connectV2LiquityArtifacts, + signer: masterSigner, + connectors: instaConnectorsV2, + }); + isDebug && + console.log(`${CONNECTOR_NAME} Connector address`, connector.address); + + const basicConnector = await deployAndEnableConnector({ + connectorName: "Basic-v1", + contractArtifact: connectV2BasicV1Artifacts, + signer: masterSigner, + connectors: instaConnectorsV2, + }); + isDebug && console.log("Basic-v1 Connector address", basicConnector.address); + + liquity.troveManager = new ethers.Contract( + contracts.TROVE_MANAGER_ADDRESS, + contracts.TROVE_MANAGER_ABI, + ethers.provider + ); + isDebug && + console.log("TroveManager contract address", liquity.troveManager.address); + + liquity.borrowerOperations = new ethers.Contract( + contracts.BORROWER_OPERATIONS_ADDRESS, + contracts.BORROWER_OPERATIONS_ABI, + ethers.provider + ); + isDebug && + console.log( + "BorrowerOperations contract address", + liquity.borrowerOperations.address + ); + + liquity.stabilityPool = new ethers.Contract( + contracts.STABILITY_POOL_ADDRESS, + contracts.STABILITY_POOL_ABI, + ethers.provider + ); + isDebug && + console.log( + "StabilityPool contract address", + liquity.stabilityPool.address + ); + + liquity.lusdToken = new ethers.Contract( + contracts.LUSD_TOKEN_ADDRESS, + contracts.LUSD_TOKEN_ABI, + ethers.provider + ); + isDebug && + console.log("LusdToken contract address", liquity.lusdToken.address); + + liquity.lqtyToken = new ethers.Contract( + contracts.LQTY_TOKEN_ADDRESS, + contracts.LQTY_TOKEN_ABI, + ethers.provider + ); + isDebug && + console.log("LqtyToken contract address", liquity.lqtyToken.address); + + liquity.activePool = new ethers.Contract( + contracts.ACTIVE_POOL_ADDRESS, + contracts.ACTIVE_POOL_ABI, + ethers.provider + ); + isDebug && + console.log("ActivePool contract address", liquity.activePool.address); + + liquity.priceFeed = new ethers.Contract( + contracts.PRICE_FEED_ADDRESS, + contracts.PRICE_FEED_ABI, + ethers.provider + ); + isDebug && + console.log("PriceFeed contract address", liquity.priceFeed.address); + + liquity.hintHelpers = new ethers.Contract( + contracts.HINT_HELPERS_ADDRESS, + contracts.HINT_HELPERS_ABI, + ethers.provider + ); + isDebug && + console.log("HintHelpers contract address", liquity.hintHelpers.address); + + liquity.sortedTroves = new ethers.Contract( + contracts.SORTED_TROVES_ADDRESS, + contracts.SORTED_TROVES_ABI, + ethers.provider + ); + isDebug && + console.log("SortedTroves contract address", liquity.sortedTroves.address); + + liquity.staking = new ethers.Contract( + contracts.STAKING_ADDRESS, + contracts.STAKING_ABI, + ethers.provider + ); + isDebug && console.log("Staking contract address", liquity.staking.address); + + return liquity; +}; + +const getTroveInsertionHints = async ( + depositAmount, + borrowAmount, + hintHelpers, + sortedTroves +) => { + const nominalCR = await hintHelpers.computeNominalCR( + depositAmount, + borrowAmount + ); + + const { hintAddress, latestRandomSeed } = await hintHelpers.getApproxHint( + nominalCR, + 50, + 1298379, + { + gasLimit: MAX_GAS, + } + ); + randomSeed = latestRandomSeed; + + const { 0: upperHint, 1: lowerHint } = await sortedTroves.findInsertPosition( + nominalCR, + hintAddress, + hintAddress, + { + gasLimit: MAX_GAS, + } + ); + + return { + upperHint, + lowerHint, + }; +}; + +let randomSeed = 4223; + +const getRedemptionHints = async ( + amount, + hintHelpers, + sortedTroves, + priceFeed +) => { + const ethPrice = await priceFeed.callStatic.fetchPrice(); + const [ + firstRedemptionHint, + partialRedemptionHintNicr, + ] = await hintHelpers.getRedemptionHints(amount, ethPrice, 0); + + const { hintAddress, latestRandomSeed } = await hintHelpers.getApproxHint( + partialRedemptionHintNicr, + 50, + randomSeed, + { + gasLimit: MAX_GAS, + } + ); + randomSeed = latestRandomSeed; + + const { 0: upperHint, 1: lowerHint } = await sortedTroves.findInsertPosition( + partialRedemptionHintNicr, + hintAddress, + hintAddress, + { + gasLimit: MAX_GAS, + } + ); + + return { + partialRedemptionHintNicr, + firstRedemptionHint, + upperHint, + lowerHint, + }; +}; + +module.exports = { + deployAndConnect, + createDsaTrove, + openTroveSpell, + sendToken, + CONNECTOR_NAME, + LUSD_GAS_COMPENSATION, + BLOCK_NUMBER, + JUSTIN_SUN_ADDRESS, + LIQUIDATABLE_TROVE_ADDRESS, + MAX_GAS, + resetHardhatBlockNumber, + getTroveInsertionHints, + getRedemptionHints, +}; diff --git a/test/liquity/liquity.test.js b/test/liquity/liquity.test.js new file mode 100644 index 00000000..a7993876 --- /dev/null +++ b/test/liquity/liquity.test.js @@ -0,0 +1,1133 @@ +const hre = require("hardhat"); +const { expect } = require("chai"); + +// Instadapp deployment and testing helpers +const buildDSAv2 = require("../../scripts/buildDSAv2"); +const encodeSpells = require("../../scripts/encodeSpells.js"); + +// Liquity smart contracts +const contracts = require("./liquity.abi"); + +// Liquity helpers +const helpers = require("./liquity.helpers"); + +// Instadapp uses a fake address to represent native ETH +const { eth_addr: ETH_ADDRESS } = require("../../scripts/constant/constant"); + +describe.only("Liquity", () => { + const { waffle, ethers } = hre; + const { provider } = waffle; + + // Waffle test account 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 (holds 1000 ETH) + const wallet = provider.getWallets()[0]; + let dsa = null; + let liquity = null; + + before(async () => { + liquity = await helpers.deployAndConnect(contracts, true); + expect(liquity.troveManager.address).to.exist; + expect(liquity.borrowerOperations.address).to.exist; + expect(liquity.stabilityPool.address).to.exist; + expect(liquity.lusdToken.address).to.exist; + expect(liquity.lqtyToken.address).to.exist; + expect(liquity.activePool.address).to.exist; + expect(liquity.priceFeed.address).to.exist; + expect(liquity.hintHelpers.address).to.exist; + expect(liquity.sortedTroves.address).to.exist; + expect(liquity.staking.address).to.exist; + }); + + beforeEach(async () => { + // Build a new DSA before each test so we start each test from the same default state + dsa = await buildDSAv2(wallet.address); + expect(dsa.address).to.exist; + }); + + describe("Main (Connector)", () => { + describe("Trove", () => { + it("opens a Trove", async () => { + const depositAmount = ethers.utils.parseEther("5"); // 5 ETH + const borrowAmount = ethers.utils.parseUnits("2000", 18); // 2000 LUSD + const maxFeePercentage = ethers.utils.parseUnits("0.5", 18); // 0.5% max fee + const upperHint = ethers.constants.AddressZero; + const lowerHint = ethers.constants.AddressZero; + const originalUserBalance = await ethers.provider.getBalance( + wallet.address + ); + const originalDsaBalance = await ethers.provider.getBalance( + dsa.address + ); + + const openTroveSpell = { + connector: helpers.CONNECTOR_NAME, + method: "open", + args: [ + depositAmount, + maxFeePercentage, + borrowAmount, + upperHint, + lowerHint, + 0, + 0, + ], + }; + + const spells = [openTroveSpell]; + const tx = await dsa + .connect(wallet) + .cast(...encodeSpells(spells), wallet.address, { + value: depositAmount, + }); + + await tx.wait(); + + const userBalance = await ethers.provider.getBalance(wallet.address); + const dsaEthBalance = await ethers.provider.getBalance(dsa.address); + const dsaLusdBalance = await liquity.lusdToken.balanceOf(dsa.address); + const troveDebt = await liquity.troveManager.getTroveDebt(dsa.address); + const troveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + + expect(userBalance).lt( + originalUserBalance, + "User should have less Ether after opening Trove" + ); + + expect(dsaEthBalance).to.eq( + originalDsaBalance, + "User's DSA account Ether should not change after borrowing" + ); + + expect( + dsaLusdBalance, + "DSA account should now hold the amount the user tried to borrow" + ).to.eq(borrowAmount); + + expect(troveDebt).to.gt( + borrowAmount, + "Trove debt should equal the borrowed amount plus fee" + ); + + expect(troveCollateral).to.eq( + depositAmount, + "Trove collateral should equal the deposited amount" + ); + }); + + it("opens a Trove using ETH collected from a previous spell", async () => { + const depositAmount = ethers.utils.parseEther("5"); // 5 ETH + const borrowAmount = ethers.utils.parseUnits("2000", 18); // 2000 LUSD + const maxFeePercentage = ethers.utils.parseUnits("0.5", 18); // 0.5% max fee + const upperHint = ethers.constants.AddressZero; + const lowerHint = ethers.constants.AddressZero; + const originalUserBalance = await ethers.provider.getBalance( + wallet.address + ); + const originalDsaBalance = await ethers.provider.getBalance( + dsa.address + ); + const depositId = 1; // Choose an ID to store and retrieve the deopsited ETH + + const depositEthSpell = { + connector: "Basic-v1", + method: "deposit", + args: [ETH_ADDRESS, depositAmount, 0, depositId], + }; + + const openTroveSpell = { + connector: helpers.CONNECTOR_NAME, + method: "open", + args: [ + 0, // When pulling ETH from a previous spell it doesn't matter what deposit value we put in this param + maxFeePercentage, + borrowAmount, + upperHint, + lowerHint, + depositId, + 0, + ], + }; + + const spells = [depositEthSpell, openTroveSpell]; + const tx = await dsa + .connect(wallet) + .cast(...encodeSpells(spells), wallet.address, { + value: depositAmount, + }); + + await tx.wait(); + const userBalance = await ethers.provider.getBalance(wallet.address); + const dsaEthBalance = await ethers.provider.getBalance(dsa.address); + const dsaLusdBalance = await liquity.lusdToken.balanceOf(dsa.address); + const troveDebt = await liquity.troveManager.getTroveDebt(dsa.address); + const troveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + + expect(userBalance).lt( + originalUserBalance, + "User should have less Ether" + ); + + expect(dsaEthBalance).to.eq( + originalDsaBalance, + "DSA balance should not change" + ); + + expect( + dsaLusdBalance, + "DSA account should now hold the amount the user tried to borrow" + ).to.eq(borrowAmount); + + expect(troveDebt).to.gt( + borrowAmount, + "Trove debt should equal the borrowed amount plus fee" + ); + + expect(troveCollateral).to.eq( + depositAmount, + "Trove collateral should equal the deposited amount" + ); + }); + + it("opens a Trove and stores the debt for other spells to use", async () => { + const depositAmount = ethers.utils.parseEther("5"); // 5 ETH + const borrowAmount = ethers.utils.parseUnits("2000", 18); // 2000 LUSD + const maxFeePercentage = ethers.utils.parseUnits("0.5", 18); // 0.5% max fee + const upperHint = ethers.constants.AddressZero; + const lowerHint = ethers.constants.AddressZero; + const originalUserBalance = await ethers.provider.getBalance( + wallet.address + ); + const originalDsaBalance = await ethers.provider.getBalance( + dsa.address + ); + const borrowId = 1; + + const openTroveSpell = { + connector: helpers.CONNECTOR_NAME, + method: "open", + args: [ + depositAmount, + maxFeePercentage, + borrowAmount, + upperHint, + lowerHint, + 0, + borrowId, + ], + }; + + const withdrawLusdSpell = { + connector: "Basic-v1", + method: "withdraw", + args: [ + contracts.LUSD_TOKEN_ADDRESS, + 0, // amount comes from the previous spell's setId + dsa.address, + borrowId, + 0, + ], + }; + + const spells = [openTroveSpell, withdrawLusdSpell]; + const tx = await dsa + .connect(wallet) + .cast(...encodeSpells(spells), wallet.address, { + value: depositAmount, + }); + + await tx.wait(); + + const userBalance = await ethers.provider.getBalance(wallet.address); + + expect(userBalance).lt( + originalUserBalance, + "User should have less Ether after opening Trove" + ); + + const dsaEthBalance = await ethers.provider.getBalance(dsa.address); + const dsaLusdBalance = await liquity.lusdToken.balanceOf(dsa.address); + const troveDebt = await liquity.troveManager.getTroveDebt(dsa.address); + const troveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + + expect(dsaEthBalance).to.eq( + originalDsaBalance, + "User's DSA account Ether should not change after borrowing" + ); + + expect( + dsaLusdBalance, + "DSA account should now hold the amount the user tried to borrow" + ).to.eq(borrowAmount); + + expect(troveDebt).to.gt( + borrowAmount, + "Trove debt should equal the borrowed amount plus fee" + ); + + expect(troveCollateral).to.eq( + depositAmount, + "Trove collateral should equal the deposited amount" + ); + }); + + it("closes a Trove", async () => { + const depositAmount = ethers.utils.parseEther("5"); + const borrowAmount = ethers.utils.parseUnits("2000", 18); + await helpers.createDsaTrove( + dsa, + wallet, + liquity.hintHelpers, + liquity.sortedTroves, + depositAmount, + borrowAmount + ); + + const originalTroveDebt = await liquity.troveManager.getTroveDebt( + dsa.address + ); + + const originalTroveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + + // Send DSA account enough LUSD (from Stability Pool) to close their Trove + const extraLusdRequiredToCloseTrove = originalTroveDebt.sub( + borrowAmount + ); + + await helpers.sendToken( + liquity.lusdToken, + extraLusdRequiredToCloseTrove, + contracts.STABILITY_POOL_ADDRESS, + dsa.address + ); + + const originalDsaLusdBalance = await liquity.lusdToken.balanceOf( + dsa.address + ); + + expect( + originalDsaLusdBalance, + "DSA account should now hold the LUSD amount required to pay off the Trove debt" + ).to.eq(originalTroveDebt); + + const closeTroveSpell = { + connector: helpers.CONNECTOR_NAME, + method: "close", + args: [0], + }; + + const closeTx = await dsa + .connect(wallet) + .cast(...encodeSpells([closeTroveSpell]), wallet.address); + + await closeTx.wait(); + const dsaEthBalance = await ethers.provider.getBalance(dsa.address); + const dsaLusdBalance = await liquity.lusdToken.balanceOf(dsa.address); + const troveDebt = await liquity.troveManager.getTroveDebt(dsa.address); + const troveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + + expect(troveDebt, "Trove debt should equal 0 after close").to.eq(0); + + expect( + troveCollateral, + "Trove collateral should equal 0 after close" + ).to.eq(0); + + expect( + dsaEthBalance, + "DSA account should now hold the Trove's ETH collateral" + ).to.eq(originalTroveCollateral); + + expect( + dsaLusdBalance, + "DSA account should now hold the gas compensation amount of LUSD as it paid off the Trove debt" + ).to.eq(helpers.LUSD_GAS_COMPENSATION); + }); + + it("closes a Trove using LUSD obtained from a previous spell", async () => { + await helpers.createDsaTrove( + dsa, + wallet, + liquity.hintHelpers, + liquity.sortedTroves + ); + + const originalTroveDebt = await liquity.troveManager.getTroveDebt( + dsa.address + ); + const originalTroveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + + // Send user enough LUSD to repay the loan, we'll use a deposit and withdraw spell to obtain it + await helpers.sendToken( + liquity.lusdToken, + originalTroveDebt, + contracts.STABILITY_POOL_ADDRESS, + wallet.address + ); + + // Allow DSA to spend user's LUSD + await liquity.lusdToken + .connect(wallet) + .approve(dsa.address, originalTroveDebt); + + const lusdDepositId = 1; + + // Simulate a spell which would have pulled LUSD from somewhere (e.g. AAVE) into InstaMemory + // In this case we're simply running a deposit spell from the user's EOA + const depositLusdSpell = { + connector: "Basic-v1", + method: "deposit", + args: [ + contracts.LUSD_TOKEN_ADDRESS, + originalTroveDebt, + 0, + lusdDepositId, + ], + }; + // Withdraw the obtained LUSD into DSA account + const withdrawLusdSpell = { + connector: "Basic-v1", + method: "withdraw", + args: [ + contracts.LUSD_TOKEN_ADDRESS, + 0, // amount comes from the previous spell's setId + dsa.address, + lusdDepositId, + 0, + ], + }; + + const closeTroveSpell = { + connector: helpers.CONNECTOR_NAME, + method: "close", + args: [0], + }; + + const closeTx = await dsa + .connect(wallet) + .cast( + ...encodeSpells([ + depositLusdSpell, + withdrawLusdSpell, + closeTroveSpell, + ]), + wallet.address + ); + + await closeTx.wait(); + const dsaEthBalance = await ethers.provider.getBalance(dsa.address); + const troveDebt = await liquity.troveManager.getTroveDebt(dsa.address); + const troveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + + expect(troveDebt, "Trove debt should equal 0 after close").to.eq(0); + + expect( + troveCollateral, + "Trove collateral should equal 0 after close" + ).to.eq(0); + + expect( + dsaEthBalance, + "DSA account should now hold the Trove's ETH collateral" + ).to.eq(originalTroveCollateral); + }); + + it("closes a Trove and stores the released collateral for other spells to use", async () => { + const depositAmount = ethers.utils.parseEther("5"); + const borrowAmount = ethers.utils.parseUnits("2000", 18); + await helpers.createDsaTrove( + dsa, + wallet, + liquity.hintHelpers, + liquity.sortedTroves, + depositAmount, + borrowAmount + ); + + const originalTroveDebt = await liquity.troveManager.getTroveDebt( + dsa.address + ); + const originalTroveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + + // Send DSA account enough LUSD (from Stability Pool) to close their Trove + const extraLusdRequiredToCloseTrove = originalTroveDebt.sub( + borrowAmount + ); + await helpers.sendToken( + liquity.lusdToken, + extraLusdRequiredToCloseTrove, + contracts.STABILITY_POOL_ADDRESS, + dsa.address + ); + const originalDsaLusdBalance = await liquity.lusdToken.balanceOf( + dsa.address + ); + + expect( + originalDsaLusdBalance, + "DSA account should now hold the LUSD amount required to pay off the Trove debt" + ).to.eq(originalTroveDebt); + + const collateralWithdrawId = 1; + + const closeTroveSpell = { + connector: helpers.CONNECTOR_NAME, + method: "close", + args: [collateralWithdrawId], + }; + + const withdrawEthSpell = { + connector: "Basic-v1", + method: "withdraw", + args: [ + ETH_ADDRESS, + 0, // amount comes from the previous spell's setId + dsa.address, + collateralWithdrawId, + 0, + ], + }; + + const closeTx = await dsa + .connect(wallet) + .cast( + ...encodeSpells([closeTroveSpell, withdrawEthSpell]), + wallet.address + ); + + await closeTx.wait(); + const dsaEthBalance = await ethers.provider.getBalance(dsa.address); + const dsaLusdBalance = await liquity.lusdToken.balanceOf(dsa.address); + const troveDebt = await liquity.troveManager.getTroveDebt(dsa.address); + const troveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + + expect(troveDebt, "Trove debt should equal 0 after close").to.eq(0); + + expect( + troveCollateral, + "Trove collateral should equal 0 after close" + ).to.eq(0); + + expect( + dsaEthBalance, + "DSA account should now hold the Trove's ETH collateral" + ).to.eq(originalTroveCollateral); + + expect( + dsaLusdBalance, + "DSA account should now hold the gas compensation amount of LUSD as it paid off the Trove debt" + ).to.eq(helpers.LUSD_GAS_COMPENSATION); + }); + + it("deposits ETH into a Trove", async () => { + await helpers.createDsaTrove( + dsa, + wallet, + liquity.hintHelpers, + liquity.sortedTroves + ); + + const originalTroveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + + const topupAmount = ethers.utils.parseEther("1"); + const upperHint = ethers.constants.AddressZero; + const lowerHint = ethers.constants.AddressZero; + const depositEthSpell = { + connector: helpers.CONNECTOR_NAME, + method: "deposit", + args: [topupAmount, upperHint, lowerHint, 0], + }; + + const depositTx = await dsa + .connect(wallet) + .cast(...encodeSpells([depositEthSpell]), wallet.address, { + value: topupAmount, + }); + + await depositTx.wait(); + + const troveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + + const expectedTroveCollateral = originalTroveCollateral.add( + topupAmount + ); + + expect( + troveCollateral, + `Trove collateral should have increased by ${topupAmount} ETH` + ).to.eq(expectedTroveCollateral); + }); + + it("withdraws ETH from a Trove", async () => { + await helpers.createDsaTrove( + dsa, + wallet, + liquity.hintHelpers, + liquity.sortedTroves + ); + + const originalTroveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + const withdrawAmount = ethers.utils.parseEther("1"); + const upperHint = ethers.constants.AddressZero; + const lowerHint = ethers.constants.AddressZero; + const withdrawEthSpell = { + connector: helpers.CONNECTOR_NAME, + method: "withdraw", + args: [withdrawAmount, upperHint, lowerHint, 0], + }; + + const withdrawTx = await dsa + .connect(wallet) + .cast(...encodeSpells([withdrawEthSpell]), wallet.address); + + await withdrawTx.wait(); + const troveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + const expectedTroveCollateral = originalTroveCollateral.sub( + withdrawAmount + ); + + expect( + troveCollateral, + `Trove collateral should have decreased by ${withdrawAmount} ETH` + ).to.eq(expectedTroveCollateral); + }); + + it("borrows LUSD from a Trove", async () => { + await helpers.createDsaTrove( + dsa, + wallet, + liquity.hintHelpers, + liquity.sortedTroves + ); + + const originalTroveDebt = await liquity.troveManager.getTroveDebt( + dsa.address + ); + const borrowAmount = ethers.utils.parseUnits("1000"); // 1000 LUSD + const upperHint = ethers.constants.AddressZero; + const lowerHint = ethers.constants.AddressZero; + const maxFeePercentage = ethers.utils.parseUnits("0.5", 18); // 0.5% max fee + const borrowSpell = { + connector: helpers.CONNECTOR_NAME, + method: "borrow", + args: [maxFeePercentage, borrowAmount, upperHint, lowerHint, 0], + }; + + const borrowTx = await dsa + .connect(wallet) + .cast(...encodeSpells([borrowSpell]), wallet.address); + + await borrowTx.wait(); + const troveDebt = await liquity.troveManager.getTroveDebt(dsa.address); + const expectedTroveDebt = originalTroveDebt.add(borrowAmount); + + expect( + troveDebt, + `Trove debt should have increased by at least ${borrowAmount} ETH` + ).to.gte(expectedTroveDebt); + }); + + it("repays LUSD to a Trove", async () => { + await helpers.createDsaTrove( + dsa, + wallet, + liquity.hintHelpers, + liquity.sortedTroves + ); + + const originalTroveDebt = await liquity.troveManager.getTroveDebt( + dsa.address + ); + const repayAmount = ethers.utils.parseUnits("100"); // 100 LUSD + const upperHint = ethers.constants.AddressZero; + const lowerHint = ethers.constants.AddressZero; + const borrowSpell = { + connector: helpers.CONNECTOR_NAME, + method: "repay", + args: [repayAmount, upperHint, lowerHint, 0], + }; + + const repayTx = await dsa + .connect(wallet) + .cast(...encodeSpells([borrowSpell]), wallet.address, { + value: repayAmount, + }); + + await repayTx.wait(); + const troveDebt = await liquity.troveManager.getTroveDebt(dsa.address); + const expectedTroveDebt = originalTroveDebt.sub(repayAmount); + + expect( + troveDebt, + `Trove debt should have decreased by ${repayAmount} ETH` + ).to.eq(expectedTroveDebt); + }); + + it("adjusts a Trove: deposit ETH and borrow LUSD", async () => { + await helpers.createDsaTrove( + dsa, + wallet, + liquity.hintHelpers, + liquity.sortedTroves + ); + + const originalTroveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + const originalTroveDebt = await liquity.troveManager.getTroveDebt( + dsa.address + ); + const depositAmount = ethers.utils.parseEther("1"); // 1 ETH + const borrowAmount = ethers.utils.parseUnits("500"); // 500 LUSD + const withdrawAmount = 0; + const repayAmount = 0; + const upperHint = ethers.constants.AddressZero; + const lowerHint = ethers.constants.AddressZero; + const maxFeePercentage = ethers.utils.parseUnits("0.5", 18); // 0.5% max fee + + const adjustSpell = { + connector: helpers.CONNECTOR_NAME, + method: "adjust", + args: [ + maxFeePercentage, + withdrawAmount, + depositAmount, + borrowAmount, + repayAmount, + upperHint, + lowerHint, + 0, + 0, + 0, + 0, + ], + }; + + const adjustTx = await dsa + .connect(wallet) + .cast(...encodeSpells([adjustSpell]), wallet.address, { + value: depositAmount, + gasLimit: helpers.MAX_GAS, + }); + + await adjustTx.wait(); + const troveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + const troveDebt = await liquity.troveManager.getTroveDebt(dsa.address); + const expectedTroveColl = originalTroveCollateral.add(depositAmount); + const expectedTroveDebt = originalTroveDebt.add(borrowAmount); + + expect( + troveCollateral, + `Trove collateral should have increased by ${depositAmount} ETH` + ).to.eq(expectedTroveColl); + + expect( + troveDebt, + `Trove debt should have increased by at least ${borrowAmount} ETH` + ).to.gte(expectedTroveDebt); + }); + + it("adjusts a Trove: withdraw ETH and repay LUSD", async () => { + await helpers.createDsaTrove( + dsa, + wallet, + liquity.hintHelpers, + liquity.sortedTroves + ); + + const originalTroveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + const originalTroveDebt = await liquity.troveManager.getTroveDebt( + dsa.address + ); + const depositAmount = 0; + const borrowAmount = 0; + const withdrawAmount = ethers.utils.parseEther("1"); // 1 ETH; + const repayAmount = ethers.utils.parseUnits("500"); // 500 LUSD; + const { upperHint, lowerHint } = await helpers.getTroveInsertionHints( + originalTroveCollateral.sub(withdrawAmount), + originalTroveDebt.sub(repayAmount), + liquity.hintHelpers, + liquity.sortedTroves + ); + const maxFeePercentage = ethers.utils.parseUnits("0.5", 18); // 0.5% max fee + + const adjustSpell = { + connector: helpers.CONNECTOR_NAME, + method: "adjust", + args: [ + maxFeePercentage, + withdrawAmount, + depositAmount, + borrowAmount, + repayAmount, + upperHint, + lowerHint, + 0, + 0, + 0, + 0, + ], + }; + + const adjustTx = await dsa + .connect(wallet) + .cast(...encodeSpells([adjustSpell]), wallet.address, { + value: depositAmount, + gasLimit: helpers.MAX_GAS, + }); + + await adjustTx.wait(); + const troveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + const troveDebt = await liquity.troveManager.getTroveDebt(dsa.address); + const expectedTroveColl = originalTroveCollateral.sub(withdrawAmount); + const expectedTroveDebt = originalTroveDebt.sub(repayAmount); + + expect( + troveCollateral, + `Trove collateral should have increased by ${depositAmount} ETH` + ).to.eq(expectedTroveColl); + + expect( + troveDebt, + `Trove debt should have increased by at least ${borrowAmount} ETH` + ).to.gte(expectedTroveDebt); + }); + + it("claims collateral from a redeemed Trove", async () => { + // Create a low collateralized Trove + const depositAmount = ethers.utils.parseEther("1.5"); + const borrowAmount = ethers.utils.parseUnits("2500", 18); + + await helpers.createDsaTrove( + dsa, + wallet, + liquity.hintHelpers, + liquity.sortedTroves, + depositAmount, + borrowAmount + ); + + // Redeem lots of LUSD to cause the Trove to become redeemed + const redeemAmount = ethers.utils.parseUnits("10000000", 18); + await helpers.sendToken( + liquity.lusdToken, + redeemAmount, + contracts.STABILITY_POOL_ADDRESS, + wallet.address + ); + const { + partialRedemptionHintNicr, + firstRedemptionHint, + upperHint, + lowerHint, + } = await helpers.getRedemptionHints( + redeemAmount, + liquity.hintHelpers, + liquity.sortedTroves, + liquity.priceFeed + ); + const maxFeePercentage = ethers.utils.parseUnits("0.5", 18); // 0.5% max fee + + await liquity.troveManager + .connect(wallet) + .redeemCollateral( + redeemAmount, + firstRedemptionHint, + upperHint, + lowerHint, + partialRedemptionHintNicr, + 0, + maxFeePercentage, + { + gasLimit: helpers.MAX_GAS, // permit max gas + } + ); + + const ethBalanceBefore = await ethers.provider.getBalance(dsa.address); + + // Claim the remaining collateral from the redeemed Trove + const claimCollateralFromRedemptionSpell = { + connector: helpers.CONNECTOR_NAME, + method: "claimCollateralFromRedemption", + args: [], + }; + + const claimTx = await dsa + .connect(wallet) + .cast( + ...encodeSpells([claimCollateralFromRedemptionSpell]), + wallet.address + ); + + await claimTx.wait(); + + const ethBalanceAfter = await ethers.provider.getBalance(dsa.address); + + const expectedRemainingCollateral = "527014573774047160"; // ~0.25 ETH based on this mainnet fork's blockNumber + expect(ethBalanceAfter).to.be.gt(ethBalanceBefore); + expect(ethBalanceAfter).to.eq(expectedRemainingCollateral); + }); + }); + + describe("Stability Pool", () => { + it("deposits into Stability Pool", async () => { + const amount = ethers.utils.parseUnits("100", 18); + const frontendTag = ethers.constants.AddressZero; + + await helpers.sendToken( + liquity.lusdToken, + amount, + contracts.STABILITY_POOL_ADDRESS, + dsa.address + ); + + const stabilityDepositSpell = { + connector: helpers.CONNECTOR_NAME, + method: "stabilityDeposit", + args: [amount, frontendTag, 0], + }; + + const depositTx = await dsa + .connect(wallet) + .cast(...encodeSpells([stabilityDepositSpell]), wallet.address); + + await depositTx.wait(); + const depositedAmount = await liquity.stabilityPool.getCompoundedLUSDDeposit( + dsa.address + ); + expect(depositedAmount).to.eq(amount); + }); + + it("withdraws from Stability Pool", async () => { + // The current block number has liquidatable Troves. + // Remove them otherwise Stability Pool withdrawals are disabled + await liquity.troveManager.connect(wallet).liquidateTroves(90, { + gasLimit: helpers.MAX_GAS, + }); + const amount = ethers.utils.parseUnits("100", 18); + const frontendTag = ethers.constants.AddressZero; + + await helpers.sendToken( + liquity.lusdToken, + amount, + contracts.STABILITY_POOL_ADDRESS, + dsa.address + ); + + const stabilityDepositSpell = { + connector: helpers.CONNECTOR_NAME, + method: "stabilityDeposit", + args: [amount, frontendTag, 0], + }; + + // Withdraw half of the deposit + const stabilitWithdrawSpell = { + connector: helpers.CONNECTOR_NAME, + method: "stabilityWithdraw", + args: [amount.div(2), 0], + }; + const spells = [stabilityDepositSpell, stabilitWithdrawSpell]; + + const spellsTx = await dsa + .connect(wallet) + .cast(...encodeSpells(spells), wallet.address); + + await spellsTx.wait(); + + const depositedAmount = await liquity.stabilityPool.getCompoundedLUSDDeposit( + dsa.address + ); + const dsaLusdBalance = await liquity.lusdToken.balanceOf(dsa.address); + + expect(depositedAmount).to.eq(amount.div(2)); + expect(dsaLusdBalance).to.eq(amount.div(2)); + }); + + it("moves ETH gain from Stability Pool to Trove", async () => { + // Start this test from fresh so that we definitely have a liquidatable Trove within this block + liqiuty = await helpers.deployAndConnect(contracts); + + // Create a DSA owned Trove to capture ETH liquidation gains + await helpers.createDsaTrove( + dsa, + wallet, + liquity.hintHelpers, + liquity.sortedTroves + ); + const troveCollateralBefore = await liquity.troveManager.getTroveColl( + dsa.address + ); + + // Create a Stability Deposit using the Trove's borrowed LUSD + const amount = ethers.utils.parseUnits("100", 18); + const frontendTag = ethers.constants.AddressZero; + const stabilityDepositSpell = { + connector: helpers.CONNECTOR_NAME, + method: "stabilityDeposit", + args: [amount, frontendTag, 0], + }; + + const depositTx = await dsa + .connect(wallet) + .cast(...encodeSpells([stabilityDepositSpell]), wallet.address); + + await depositTx.wait(); + + // Liquidate a Trove to create an ETH gain for the new DSA Trove + await liquity.troveManager + .connect(wallet) + .liquidate(helpers.LIQUIDATABLE_TROVE_ADDRESS, { + gasLimit: helpers.MAX_GAS, // permit max gas + }); + + const ethGainFromLiquidation = await liquity.stabilityPool.getDepositorETHGain( + dsa.address + ); + + // Move ETH gain to Trove + const moveEthGainSpell = { + connector: helpers.CONNECTOR_NAME, + method: "stabilityMoveEthGainToTrove", + args: [ethers.constants.AddressZero, ethers.constants.AddressZero], + }; + + const moveEthGainTx = await dsa + .connect(wallet) + .cast(...encodeSpells([moveEthGainSpell]), wallet.address); + + await moveEthGainTx.wait(); + + const ethGainAfterMove = await liquity.stabilityPool.getDepositorETHGain( + dsa.address + ); + const troveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + const expectedTroveCollateral = troveCollateralBefore.add( + ethGainFromLiquidation + ); + + expect(ethGainAfterMove).to.eq(0); + expect(troveCollateral).to.eq(expectedTroveCollateral); + }); + }); + + describe("Staking", () => { + it("stakes LQTY", async () => { + const totalStakingBalanceBefore = await liquity.lqtyToken.balanceOf( + contracts.STAKING_ADDRESS + ); + + const amount = ethers.utils.parseUnits("1", 18); + await helpers.sendToken( + liquity.lqtyToken, + amount, + helpers.JUSTIN_SUN_ADDRESS, + dsa.address + ); + + const stakeSpell = { + connector: helpers.CONNECTOR_NAME, + method: "stake", + args: [amount, 0], + }; + + const stakeTx = await dsa + .connect(wallet) + .cast(...encodeSpells([stakeSpell]), wallet.address); + + await stakeTx.wait(); + + const lqtyBalance = await liquity.lqtyToken.balanceOf(dsa.address); + expect(lqtyBalance).to.eq(0); + + const totalStakingBalance = await liquity.lqtyToken.balanceOf( + contracts.STAKING_ADDRESS + ); + expect(totalStakingBalance).to.eq( + totalStakingBalanceBefore.add(amount) + ); + }); + + it("unstakes LQTY", async () => { + const amount = ethers.utils.parseUnits("1", 18); + await helpers.sendToken( + liquity.lqtyToken, + amount, + helpers.JUSTIN_SUN_ADDRESS, + dsa.address + ); + + const stakeSpell = { + connector: helpers.CONNECTOR_NAME, + method: "stake", + args: [amount, 0], + }; + + const stakeTx = await dsa + .connect(wallet) + .cast(...encodeSpells([stakeSpell]), wallet.address); + + await stakeTx.wait(); + + const totalStakingBalanceBefore = await liquity.lqtyToken.balanceOf( + contracts.STAKING_ADDRESS + ); + + const unstakeSpell = { + connector: helpers.CONNECTOR_NAME, + method: "unstake", + args: [amount, 0], + }; + + const unstakeTx = await dsa + .connect(wallet) + .cast(...encodeSpells([unstakeSpell]), wallet.address); + + await unstakeTx.wait(); + + const lqtyBalance = await liquity.lqtyToken.balanceOf(dsa.address); + expect(lqtyBalance).to.eq(amount); + + const totalStakingBalance = await liquity.lqtyToken.balanceOf( + contracts.STAKING_ADDRESS + ); + expect(totalStakingBalance).to.eq( + totalStakingBalanceBefore.sub(amount) + ); + }); + }); + }); +}); + +// TODO add set of tests to verify log return values are generated correctly