diff --git a/contracts/mainnet/connectors/liquity/events.sol b/contracts/mainnet/connectors/liquity/events.sol new file mode 100644 index 00000000..cb48bc99 --- /dev/null +++ b/contracts/mainnet/connectors/liquity/events.sol @@ -0,0 +1,58 @@ +pragma solidity ^0.7.6; + +contract Events { + + /* Trove */ + event LogOpen( + address indexed borrower, + uint maxFeePercentage, + uint depositAmount, + uint borrowAmount, + uint256[] getIds, + uint256[] setIds + ); + event LogClose(address indexed borrower, uint setId); + event LogDeposit(address indexed borrower, uint amount, uint getId, uint setId); + event LogWithdraw(address indexed borrower, uint amount, uint getId, uint setId); + event LogBorrow(address indexed borrower, uint amount, uint getId, uint setId); + event LogRepay(address indexed borrower, uint amount, uint getId, uint setId); + event LogAdjust( + address indexed borrower, + uint maxFeePercentage, + uint depositAmount, + uint withdrawAmount, + uint borrowAmount, + uint repayAmount, + uint256[] getIds, + uint256[] setIds + ); + event LogClaimCollateralFromRedemption(address indexed borrower, uint amount, uint setId); + + /* Stability Pool */ + event LogStabilityDeposit( + address indexed borrower, + uint amount, + uint ethGain, + uint lqtyGain, + address frontendTag, + uint getDepositId, + uint setDepositId, + uint setEthGainId, + uint setLqtyGainId + ); + event LogStabilityWithdraw(address indexed borrower, + uint amount, + uint ethGain, + uint lqtyGain, + uint getWithdrawId, + uint setWithdrawId, + uint setEthGainId, + uint setLqtyGainId + ); + event LogStabilityMoveEthGainToTrove(address indexed borrower, uint amount); + + /* Staking */ + event LogStake(address indexed borrower, uint amount, uint getStakeId, uint setStakeId, uint setEthGainId, uint setLusdGainId); + event LogUnstake(address indexed borrower, uint amount, uint getUnstakeId, uint setUnstakeId, uint setEthGainId, uint setLusdGainId); + event LogClaimStakingGains(address indexed borrower, uint ethGain, uint lusdGain, uint setEthGainId, uint setLusdGainId); +} diff --git a/contracts/mainnet/connectors/liquity/helpers.sol b/contracts/mainnet/connectors/liquity/helpers.sol new file mode 100644 index 00000000..0fc9ef1c --- /dev/null +++ b/contracts/mainnet/connectors/liquity/helpers.sol @@ -0,0 +1,37 @@ +pragma solidity ^0.7.6; + +import { DSMath } from "../../common/math.sol"; +import { Basic } from "../../common/basic.sol"; + +import { TokenInterface } from "../../common/interfaces.sol"; + +import { + BorrowerOperationsLike, + TroveManagerLike, + StabilityPoolLike, + StakingLike, + CollateralSurplusLike, + LqtyTokenLike +} from "./interface.sol"; + +abstract contract Helpers is DSMath, Basic { + + BorrowerOperationsLike internal constant borrowerOperations = BorrowerOperationsLike(0x24179CD81c9e782A4096035f7eC97fB8B783e007); + TroveManagerLike internal constant troveManager = TroveManagerLike(0xA39739EF8b0231DbFA0DcdA07d7e29faAbCf4bb2); + StabilityPoolLike internal constant stabilityPool = StabilityPoolLike(0x66017D22b0f8556afDd19FC67041899Eb65a21bb); + StakingLike internal constant staking = StakingLike(0x4f9Fbb3f1E99B56e0Fe2892e623Ed36A76Fc605d); + CollateralSurplusLike internal constant collateralSurplus = CollateralSurplusLike(0x3D32e8b97Ed5881324241Cf03b2DA5E2EBcE5521); + LqtyTokenLike internal constant lqtyToken = LqtyTokenLike(0x6DEA81C8171D0bA574754EF6F8b412F2Ed88c54D); + TokenInterface internal constant lusdToken = TokenInterface(0x5f98805A4E8be255a32880FDeC7F6728C6568bA0); + + // Prevents stack-too-deep error + struct AdjustTrove { + uint maxFeePercentage; + uint withdrawAmount; + uint depositAmount; + uint borrowAmount; + uint repayAmount; + bool isBorrow; + } + +} diff --git a/contracts/mainnet/connectors/liquity/interface.sol b/contracts/mainnet/connectors/liquity/interface.sol new file mode 100644 index 00000000..8ffd65d1 --- /dev/null +++ b/contracts/mainnet/connectors/liquity/interface.sol @@ -0,0 +1,74 @@ +pragma solidity ^0.7.6; + +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; + function getDepositorETHGain(address _depositor) external view returns (uint); + function getDepositorLQTYGain(address _depositor) external view returns (uint); + function getCompoundedLUSDDeposit(address _depositor) external view returns (uint); +} + +interface StakingLike { + function stake(uint _LQTYamount) external; + function unstake(uint _LQTYamount) external; + function getPendingETHGain(address _user) external view returns (uint); + function getPendingLUSDGain(address _user) external view returns (uint); + function stakes(address owner) external view returns (uint); +} + +interface CollateralSurplusLike { + function getCollateral(address _account) external view returns (uint); +} + +interface LqtyTokenLike { + function balanceOf(address account) external view returns (uint256); +} diff --git a/contracts/mainnet/connectors/liquity/main.sol b/contracts/mainnet/connectors/liquity/main.sol new file mode 100644 index 00000000..6bec9af8 --- /dev/null +++ b/contracts/mainnet/connectors/liquity/main.sol @@ -0,0 +1,458 @@ +pragma solidity ^0.7.6; + +/** + * @title Liquity. + * @dev Lending & Borrowing. + */ +import { + BorrowerOperationsLike, + TroveManagerLike, + StabilityPoolLike, + StakingLike, + CollateralSurplusLike, + LqtyTokenLike +} 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 { + + + /* 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 should now sit in the ordered Trove list + * @param lowerHint Address of the Trove near the lower bound of where the user's Trove should now sit in the ordered Trove list + * @param getIds Optional (default: 0) Optional storage slot to get deposit & borrow amounts stored using other spells + * @param setIds Optional (default: 0) Optional storage slot to set deposit & borrow amounts to be used in future spells + */ + function open( + uint depositAmount, + uint maxFeePercentage, + uint borrowAmount, + address upperHint, + address lowerHint, + uint[] memory getIds, + uint[] memory setIds + ) external payable returns (string memory _eventName, bytes memory _eventParam) { + + depositAmount = getUint(getIds[0], depositAmount); + borrowAmount = getUint(getIds[1], borrowAmount); + + depositAmount = depositAmount == uint(-1) ? address(this).balance : depositAmount; + + borrowerOperations.openTrove{value: depositAmount}( + maxFeePercentage, + borrowAmount, + upperHint, + lowerHint + ); + + setUint(setIds[0], depositAmount); + setUint(setIds[1], borrowAmount); + + _eventName = "LogOpen(address,uint256,uint256,uint256,uint256[],uint256[])"; + _eventParam = abi.encode(address(this), maxFeePercentage, depositAmount, borrowAmount, getIds, setIds); + } + + /** + * @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 payable 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,uint256)"; + _eventParam = abi.encode(address(this), 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 should now sit in the ordered Trove list + * @param lowerHint Address of the Trove near the lower bound of where the user's Trove should now sit in the ordered Trove list + * @param getId Optional storage slot to retrieve the ETH from + * @param setId Optional storage slot to set the ETH deposited + */ + function deposit( + uint amount, + address upperHint, + address lowerHint, + uint getId, + uint setId + ) external payable returns (string memory _eventName, bytes memory _eventParam) { + + uint _amount = getUint(getId, amount); + + _amount = _amount == uint(-1) ? address(this).balance : _amount; + + borrowerOperations.addColl{value: _amount}(upperHint, lowerHint); + + setUint(setId, _amount); + + _eventName = "LogDeposit(address,uint256,uint256,uint256)"; + _eventParam = abi.encode(address(this), _amount, getId, setId); + } + + /** + * @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 should now sit in the ordered Trove list + * @param lowerHint Address of the Trove near the lower bound of where the user's Trove should now sit in the ordered Trove list + * @param getId Optional storage slot to get the amount of ETH to withdraw + * @param setId Optional storage slot to store the withdrawn ETH in + */ + function withdraw( + uint amount, + address upperHint, + address lowerHint, + uint getId, + uint setId + ) external payable returns (string memory _eventName, bytes memory _eventParam) { + uint _amount = getUint(getId, amount); + + _amount = _amount == uint(-1) ? troveManager.getTroveColl(address(this)) : _amount; + + borrowerOperations.withdrawColl(_amount, upperHint, lowerHint); + + setUint(setId, _amount); + _eventName = "LogWithdraw(address,uint256,uint256,uint256)"; + _eventParam = abi.encode(address(this), _amount, getId, 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 should now sit in the ordered Trove list + * @param lowerHint Address of the Trove near the lower bound of where the user's Trove should now sit in the ordered Trove list + * @param getId Optional storage slot to retrieve the amount of LUSD to borrow + * @param setId Optional storage slot to store the final amount of LUSD borrowed + */ + function borrow( + uint maxFeePercentage, + uint amount, + address upperHint, + address lowerHint, + uint getId, + uint setId + ) external payable returns (string memory _eventName, bytes memory _eventParam) { + uint _amount = getUint(getId, amount); + + borrowerOperations.withdrawLUSD(maxFeePercentage, _amount, upperHint, lowerHint); + + setUint(setId, _amount); + + _eventName = "LogBorrow(address,uint256,uint256,uint256)"; + _eventParam = abi.encode(address(this), _amount, getId, 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 should now sit in the ordered Trove list + * @param lowerHint Address of the Trove near the lower bound of where the user's Trove should now sit in the ordered Trove list + * @param getId Optional storage slot to retrieve the amount of LUSD from + * @param setId Optional storage slot to store the final amount of LUSD repaid + */ + function repay( + uint amount, + address upperHint, + address lowerHint, + uint getId, + uint setId + ) external payable returns (string memory _eventName, bytes memory _eventParam) { + uint _amount = getUint(getId, amount); + + if (_amount == uint(-1)) { + uint _lusdBal = lusdToken.balanceOf(address(this)); + uint _totalDebt = troveManager.getTroveDebt(address(this)); + _amount = _lusdBal > _totalDebt ? _totalDebt : _lusdBal; + } + + borrowerOperations.repayLUSD(_amount, upperHint, lowerHint); + + setUint(setId, _amount); + + _eventName = "LogRepay(address,uint256,uint256,uint256)"; + _eventParam = abi.encode(address(this), _amount, getId, setId); + } + + /** + * @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 should now sit in the ordered Trove list + * @param lowerHint Address of the Trove near the lower bound of where the user's Trove should now sit in the ordered Trove list + * @param getIds Optional Get Ids for deposit, withdraw, borrow & repay + * @param setIds Optional Set Ids for deposit, withdraw, borrow & repay + */ + function adjust( + uint maxFeePercentage, + uint depositAmount, + uint withdrawAmount, + uint borrowAmount, + uint repayAmount, + address upperHint, + address lowerHint, + uint[] memory getIds, + uint[] memory setIds + ) external payable returns (string memory _eventName, bytes memory _eventParam) { + AdjustTrove memory adjustTrove; + + adjustTrove.maxFeePercentage = maxFeePercentage; + + depositAmount = getUint(getIds[0], depositAmount); + adjustTrove.depositAmount = depositAmount == uint(-1) ? address(this).balance : depositAmount; + + withdrawAmount = getUint(getIds[1], withdrawAmount); + adjustTrove.withdrawAmount = withdrawAmount == uint(-1) ? troveManager.getTroveColl(address(this)) : withdrawAmount; + + adjustTrove.borrowAmount = getUint(getIds[2], borrowAmount); + + repayAmount = getUint(getIds[3], repayAmount); + if (repayAmount == uint(-1)) { + uint _lusdBal = lusdToken.balanceOf(address(this)); + uint _totalDebt = troveManager.getTroveDebt(address(this)); + repayAmount = _lusdBal > _totalDebt ? _totalDebt : _lusdBal; + } + adjustTrove.repayAmount = repayAmount; + + adjustTrove.isBorrow = borrowAmount > 0; + + borrowerOperations.adjustTrove{value: adjustTrove.depositAmount}( + adjustTrove.maxFeePercentage, + adjustTrove.withdrawAmount, + adjustTrove.borrowAmount, + adjustTrove.isBorrow, + upperHint, + lowerHint + ); + + setUint(setIds[0], adjustTrove.depositAmount); + setUint(setIds[1], adjustTrove.withdrawAmount); + setUint(setIds[2], adjustTrove.borrowAmount); + setUint(setIds[3], adjustTrove.repayAmount); + + _eventName = "LogAdjust(address,uint256,uint256,uint256,uint256,uint256,uint256[],uint256[])"; + _eventParam = abi.encode(address(this), maxFeePercentage, adjustTrove.depositAmount, adjustTrove.withdrawAmount, adjustTrove.borrowAmount, adjustTrove.repayAmount, getIds, setIds); + } + + /** + * @dev Withdraw remaining ETH balance from user's redeemed Trove to their DSA + * @param setId Optional storage slot to store the ETH claimed + * @notice Claim remaining collateral from Trove + */ + function claimCollateralFromRedemption(uint setId) external payable returns(string memory _eventName, bytes memory _eventParam) { + uint amount = collateralSurplus.getCollateral(address(this)); + borrowerOperations.claimCollateral(); + setUint(setId, amount); + + _eventName = "LogClaimCollateralFromRedemption(address,uint256,uint256)"; + _eventParam = abi.encode(address(this), amount, setId); + } + /* 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 getDepositId Optional storage slot to retrieve the amount of LUSD from + * @param setDepositId Optional storage slot to store the final amount of LUSD deposited + * @param setEthGainId Optional storage slot to store any ETH gains in + * @param setLqtyGainId Optional storage slot to store any LQTY gains in + */ + function stabilityDeposit( + uint amount, + address frontendTag, + uint getDepositId, + uint setDepositId, + uint setEthGainId, + uint setLqtyGainId + ) external payable returns (string memory _eventName, bytes memory _eventParam) { + amount = getUint(getDepositId, amount); + + amount = amount == uint(-1) ? lusdToken.balanceOf(address(this)) : amount; + + uint ethGain = stabilityPool.getDepositorETHGain(address(this)); + uint lqtyBalanceBefore = lqtyToken.balanceOf(address(this)); + + stabilityPool.provideToSP(amount, frontendTag); + + uint lqtyBalanceAfter = lqtyToken.balanceOf(address(this)); + uint lqtyGain = sub(lqtyBalanceAfter, lqtyBalanceBefore); + + setUint(setDepositId, amount); + setUint(setEthGainId, ethGain); + setUint(setLqtyGainId, lqtyGain); + + _eventName = "LogStabilityDeposit(address,uint256,uint256,uint256,address,uint256,uint256,uint256,uint256)"; + _eventParam = abi.encode(address(this), amount, ethGain, lqtyGain, frontendTag, getDepositId, setDepositId, setEthGainId, setLqtyGainId); + } + + /** + * @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 getWithdrawId Optional storage slot to retrieve the amount of LUSD to withdraw from + * @param setWithdrawId Optional storage slot to store the withdrawn LUSD + * @param setEthGainId Optional storage slot to store any ETH gains in + * @param setLqtyGainId Optional storage slot to store any LQTY gains in + */ + function stabilityWithdraw( + uint amount, + uint getWithdrawId, + uint setWithdrawId, + uint setEthGainId, + uint setLqtyGainId + ) external payable returns (string memory _eventName, bytes memory _eventParam) { + amount = getUint(getWithdrawId, amount); + + amount = amount == uint(-1) ? stabilityPool.getCompoundedLUSDDeposit(address(this)) : amount; + + uint ethGain = stabilityPool.getDepositorETHGain(address(this)); + uint lqtyBalanceBefore = lqtyToken.balanceOf(address(this)); + + stabilityPool.withdrawFromSP(amount); + + uint lqtyBalanceAfter = lqtyToken.balanceOf(address(this)); + uint lqtyGain = sub(lqtyBalanceAfter, lqtyBalanceBefore); + + setUint(setWithdrawId, amount); + setUint(setEthGainId, ethGain); + setUint(setLqtyGainId, lqtyGain); + + _eventName = "LogStabilityWithdraw(address,uint256,uint256,uint256,uint256,uint256,uint256,uint256)"; + _eventParam = abi.encode(address(this), amount, ethGain, lqtyGain, getWithdrawId, setWithdrawId, setEthGainId, setLqtyGainId); + } + + /** + * @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 should now sit in the ordered Trove list + * @param lowerHint Address of the Trove near the lower bound of where the user's Trove should now sit in the ordered Trove list + */ + function stabilityMoveEthGainToTrove( + address upperHint, + address lowerHint + ) external payable returns (string memory _eventName, bytes memory _eventParam) { + uint amount = stabilityPool.getDepositorETHGain(address(this)); + stabilityPool.withdrawETHGainToTrove(upperHint, lowerHint); + _eventName = "LogStabilityMoveEthGainToTrove(address,uint256)"; + _eventParam = abi.encode(address(this), amount); + } + /* 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 getStakeId Optional storage slot to retrieve the amount of LQTY to stake + * @param setStakeId Optional storage slot to store the final staked amount (can differ if requested with max balance: uint(-1)) + * @param setEthGainId Optional storage slot to store any ETH gains + * @param setLusdGainId Optional storage slot to store any LUSD gains + */ + function stake( + uint amount, + uint getStakeId, + uint setStakeId, + uint setEthGainId, + uint setLusdGainId + ) external payable returns (string memory _eventName, bytes memory _eventParam) { + amount = getUint(getStakeId, amount); + amount = amount == uint(-1) ? lqtyToken.balanceOf(address(this)) : amount; + + uint ethGain = staking.getPendingETHGain(address(this)); + uint lusdGain = staking.getPendingLUSDGain(address(this)); + + staking.stake(amount); + setUint(setStakeId, amount); + setUint(setEthGainId, ethGain); + setUint(setLusdGainId, lusdGain); + + _eventName = "LogStake(address,uint256,uint256,uint256,uint256,uint256)"; + _eventParam = abi.encode(address(this), amount, getStakeId, setStakeId, setEthGainId, setLusdGainId); + } + + /** + * @dev Sends LQTY tokens from Staking Pool to user + * @notice Unstake LQTY in Staking Pool + * @param amount Amount of LQTY to unstake + * @param getUnstakeId Optional storage slot to retrieve the amount of LQTY to unstake + * @param setUnstakeId Optional storage slot to store the unstaked LQTY + * @param setEthGainId Optional storage slot to store any ETH gains + * @param setLusdGainId Optional storage slot to store any LUSD gains + */ + function unstake( + uint amount, + uint getUnstakeId, + uint setUnstakeId, + uint setEthGainId, + uint setLusdGainId + ) external payable returns (string memory _eventName, bytes memory _eventParam) { + amount = getUint(getUnstakeId, amount); + amount = amount == uint(-1) ? staking.stakes(address(this)) : amount; + + uint ethGain = staking.getPendingETHGain(address(this)); + uint lusdGain = staking.getPendingLUSDGain(address(this)); + + staking.unstake(amount); + setUint(setUnstakeId, amount); + setUint(setEthGainId, ethGain); + setUint(setLusdGainId, lusdGain); + + _eventName = "LogUnstake(address,uint256,uint256,uint256,uint256,uint256)"; + _eventParam = abi.encode(address(this), amount, getUnstakeId, setUnstakeId, setEthGainId, setLusdGainId); + } + + /** + * @dev Sends ETH and LUSD gains from Staking to user + * @notice Claim ETH and LUSD gains from Staking + * @param setEthGainId Optional storage slot to store the claimed ETH + * @param setLusdGainId Optional storage slot to store the claimed LUSD + */ + function claimStakingGains( + uint setEthGainId, + uint setLusdGainId + ) external payable returns (string memory _eventName, bytes memory _eventParam) { + uint ethGain = staking.getPendingETHGain(address(this)); + uint lusdGain = staking.getPendingLUSDGain(address(this)); + + // Gains are claimed when a user's stake is adjusted, so we unstake 0 to trigger the claim + staking.unstake(0); + setUint(setEthGainId, ethGain); + setUint(setLusdGainId, lusdGain); + + _eventName = "LogClaimStakingGains(address,uint256,uint256,uint256,uint256)"; + _eventParam = abi.encode(address(this), ethGain, lusdGain, setEthGainId, setLusdGainId); + } + /* End: Staking */ + +} + +contract ConnectV2Liquity is LiquityResolver { + string public name = "Liquity-v1"; +} diff --git a/contracts/mainnet/connectors/reflexer/helpers.sol b/contracts/mainnet/connectors/reflexer/helpers.sol index c46776a7..09d2b7a2 100644 --- a/contracts/mainnet/connectors/reflexer/helpers.sol +++ b/contracts/mainnet/connectors/reflexer/helpers.sol @@ -37,8 +37,7 @@ abstract contract Helpers is DSMath, Basic { * @dev Return Reflexer mapping Address. */ function getGebMappingAddress() internal pure returns (address) { - // TODO: Set the real deployed Reflexer mapping address - return 0x0000000000000000000000000000000000000000; + return 0x573e5132693C046D1A9F75Bac683889164bA41b4; } function getCollateralJoinAddress(bytes32 collateralType) internal view returns (address) { diff --git a/contracts/mainnet/mapping/reflexer.sol b/contracts/mainnet/mapping/reflexer.sol index c9092d3e..ef5728a2 100644 --- a/contracts/mainnet/mapping/reflexer.sol +++ b/contracts/mainnet/mapping/reflexer.sol @@ -20,8 +20,7 @@ contract Helpers { ConnectorsInterface public constant connectors = ConnectorsInterface(0x97b0B3A8bDeFE8cB9563a3c610019Ad10DB8aD11); // InstaConnectorsV2 IndexInterface public constant instaIndex = IndexInterface(0x2971AdFa57b20E5a416aE5a708A8655A9c74f723); - // TODO: add address for MappingController - MappingControllerInterface public constant mappingController = MappingControllerInterface(address(0)); + MappingControllerInterface public constant mappingController = MappingControllerInterface(0xDdd075D5e1024901E4038461e1e4BbC3A48a08d4); uint public version = 1; mapping (bytes32 => address) public collateralJoinMapping; @@ -55,8 +54,8 @@ contract Helpers { } -contract GebMapping is Helpers { - string constant public name = "Reflexer-Mapping-v1"; +contract InstaReflexerGebMapping is Helpers { + string constant public name = "Reflexer-Geb-Mapping-v1"; constructor() public { address[] memory collateralJoins = new address[](1); diff --git a/docs/connectors.json b/docs/connectors.json index f4e79b0f..ad0eb37a 100644 --- a/docs/connectors.json +++ b/docs/connectors.json @@ -1,6 +1,5 @@ { - "connectors": - { + "connectors": { "1" : { "AUTHORITY-A": "0x351Bb32e90C35647Df7a584f3c1a3A0c38F31c68", "BASIC-A": "0x9926955e0Dd681Dc303370C52f4Ad0a4dd061687", @@ -24,7 +23,8 @@ "MAKERDAO-CLAIM-A": "0x2f8cBE650af98602a215b6482F2aD60893C5A4E8", "WETH-A": "0x22075fa719eFb02Ca3cF298AFa9C974B7465E5D3", "REFINANCE-A": "0x9eA34bE6dA51aa9F6408FeA79c946FDCFA424442", - "INST-A": "0x52C2C4a0db049255fF345EB9D3Fb1f555b7a924A" + "INST-A": "0x52C2C4a0db049255fF345EB9D3Fb1f555b7a924A", + "REFLEXER-A": "0xaC6dc28a6251F49Bbe5755E630107Dccde9ae2C8" }, "137" : { "1INCH-A": "0xC0d9210496afE9763F5d8cEb8deFfBa817232A9e", @@ -38,5 +38,6 @@ "mappings": { "InstaMappingController": "0xDdd075D5e1024901E4038461e1e4BbC3A48a08d4", "InstaCompoundMapping": "0xe7a85d0adDB972A4f0A4e57B698B37f171519e88" + "InstaReflexerGebMapping": "0x573e5132693C046D1A9F75Bac683889164bA41b4" } } diff --git a/hardhat.config.js b/hardhat.config.js index 79716efc..86ed948b 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"); @@ -28,32 +27,32 @@ module.exports = { settings: { optimizer: { enabled: false, - runs: 200 - } - } + runs: 200, + }, + }, }, { - 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("30", "gwei")) + gasPrice: parseInt(utils.parseUnits("30", "gwei")), }, hardhat: { forking: { @@ -66,17 +65,17 @@ 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, }, mocha: { - timeout: 100 * 1000 - } + timeout: 100 * 1000, + }, }; diff --git a/scripts/constant/abis.js b/scripts/constant/abis.js index 3ed03f80..cb62ccfb 100644 --- a/scripts/constant/abis.js +++ b/scripts/constant/abis.js @@ -1,15 +1,15 @@ 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"), - "INSTAPOOL-A": require("./abi/connectors/instapool.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"), + "INSTAPOOL-A": require("./abi/connectors/instapool.json"), + }, + basic: { + erc20: require("./abi/basics/erc20.json"), + }, +}; diff --git a/scripts/constant/addresses.js b/scripts/constant/addresses.js index a1cb7d45..77ee389e 100644 --- a/scripts/constant/addresses.js +++ b/scripts/constant/addresses.js @@ -1,12 +1,11 @@ module.exports = { - connectors: { - basic: "0xe5398f279175962E56fE4c5E0b62dc7208EF36c6", - auth: "0xd1aff9f2acf800c876c409100d6f39aea93fc3d9", - "INSTAPOOL-A": "0x5806af7ab22e2916fa582ff05731bf7c682387b2" - }, - core: { - connectorsV2: "0x97b0B3A8bDeFE8cB9563a3c610019Ad10DB8aD11", - instaIndex: "0x2971AdFa57b20E5a416aE5a708A8655A9c74f723", - } - }; - \ No newline at end of file + connectors: { + basic: "0xe5398f279175962E56fE4c5E0b62dc7208EF36c6", + auth: "0xd1aff9f2acf800c876c409100d6f39aea93fc3d9", + "INSTAPOOL-A": "0x5806af7ab22e2916fa582ff05731bf7c682387b2", + }, + core: { + connectorsV2: "0x97b0B3A8bDeFE8cB9563a3c610019Ad10DB8aD11", + instaIndex: "0x2971AdFa57b20E5a416aE5a708A8655A9c74f723", + }, +}; diff --git a/test/liquity/liquity.contracts.js b/test/liquity/liquity.contracts.js new file mode 100644 index 00000000..f6a6df74 --- /dev/null +++ b/test/liquity/liquity.contracts.js @@ -0,0 +1,95 @@ +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)", + "function getDepositorLQTYGain(address _depositor) external view returns (uint)", +]; + +const STAKING_ADDRESS = "0x4f9Fbb3f1E99B56e0Fe2892e623Ed36A76Fc605d"; +const STAKING_ABI = [ + "function stake(uint _LQTYamount) external", + "function unstake(uint _LQTYamount) external", + "function getPendingETHGain(address _user) external view returns (uint)", + "function getPendingLUSDGain(address _user) external view returns (uint)", +]; + +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)", + "function approve(address spender, uint256 amount) external returns (bool)", +]; + +const COLL_SURPLUS_ADDRESS = "0x3D32e8b97Ed5881324241Cf03b2DA5E2EBcE5521"; +const COLL_SURPLUS_ABI = [ + "function getCollateral(address _account) external view returns (uint)", +]; + +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, + COLL_SURPLUS_ADDRESS, + COLL_SURPLUS_ABI, +}; diff --git a/test/liquity/liquity.helpers.js b/test/liquity/liquity.helpers.js new file mode 100644 index 00000000..f6aa3e34 --- /dev/null +++ b/test/liquity/liquity.helpers.js @@ -0,0 +1,344 @@ +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"); +const buildDSAv2 = require("../../scripts/buildDSAv2"); + +// 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 { ethers } = require("hardhat"); + +// Instadapp uses a fake address to represent native ETH +const { eth_addr: ETH_ADDRESS } = require("../../scripts/constant/constant"); + +const LIQUITY_CONNECTOR = "LIQUITY-v1-TEST"; +const LUSD_GAS_COMPENSATION = hre.ethers.utils.parseUnits("200", 18); // 200 LUSD gas compensation repaid after loan repayment +const LIQUIDATABLE_TROVES_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: LIQUIDATABLE_TROVES_BLOCK_NUMBER +const MAX_GAS = hardhatConfig.networks.hardhat.blockGasLimit; // Maximum gas limit (12000000) +const INSTADAPP_BASIC_V1_CONNECTOR = "Basic-v1"; + +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: LIQUITY_CONNECTOR, + method: "open", + args: [ + depositAmount, + maxFeePercentage, + borrowAmount, + upperHint, + lowerHint, + [0, 0], + [0, 0], + ], + }; + + return await dsa + .connect(signer) + .cast(...encodeSpells([openTroveSpell]), address, { + value: depositAmount, + }); +}; + +const createDsaTrove = async ( + dsa, + signer, + liquity, + 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, + liquity + ); + 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, { + gasPrice: 0, + }); +}; + +const resetInitialState = async (walletAddress, contracts, isDebug = false) => { + const liquity = await deployAndConnect(contracts, isDebug); + const dsa = await buildDSAv2(walletAddress); + + return [liquity, dsa]; +}; + +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(LIQUIDATABLE_TROVES_BLOCK_NUMBER); + const liquity = { + troveManager: null, + borrowerOperations: null, + stabilityPool: null, + lusdToken: null, + lqtyToken: null, + activePool: null, + priceFeed: null, + hintHelpers: null, + sortedTroves: null, + staking: null, + collSurplus: null, + }; + + const masterSigner = await getMasterSigner(); + const instaConnectorsV2 = await ethers.getContractAt( + instadappAbi.core.connectorsV2, + instadappAddresses.core.connectorsV2 + ); + const connector = await deployAndEnableConnector({ + connectorName: LIQUITY_CONNECTOR, + contractArtifact: connectV2LiquityArtifacts, + signer: masterSigner, + connectors: instaConnectorsV2, + }); + isDebug && + console.log(`${LIQUITY_CONNECTOR} 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 + ); + + liquity.borrowerOperations = new ethers.Contract( + contracts.BORROWER_OPERATIONS_ADDRESS, + contracts.BORROWER_OPERATIONS_ABI, + ethers.provider + ); + + liquity.stabilityPool = new ethers.Contract( + contracts.STABILITY_POOL_ADDRESS, + contracts.STABILITY_POOL_ABI, + ethers.provider + ); + + liquity.lusdToken = new ethers.Contract( + contracts.LUSD_TOKEN_ADDRESS, + contracts.LUSD_TOKEN_ABI, + ethers.provider + ); + + liquity.lqtyToken = new ethers.Contract( + contracts.LQTY_TOKEN_ADDRESS, + contracts.LQTY_TOKEN_ABI, + ethers.provider + ); + + liquity.activePool = new ethers.Contract( + contracts.ACTIVE_POOL_ADDRESS, + contracts.ACTIVE_POOL_ABI, + ethers.provider + ); + + liquity.priceFeed = new ethers.Contract( + contracts.PRICE_FEED_ADDRESS, + contracts.PRICE_FEED_ABI, + ethers.provider + ); + + liquity.hintHelpers = new ethers.Contract( + contracts.HINT_HELPERS_ADDRESS, + contracts.HINT_HELPERS_ABI, + ethers.provider + ); + + liquity.sortedTroves = new ethers.Contract( + contracts.SORTED_TROVES_ADDRESS, + contracts.SORTED_TROVES_ABI, + ethers.provider + ); + + liquity.staking = new ethers.Contract( + contracts.STAKING_ADDRESS, + contracts.STAKING_ABI, + ethers.provider + ); + liquity.collSurplus = new ethers.Contract( + contracts.COLL_SURPLUS_ADDRESS, + contracts.COLL_SURPLUS_ABI, + ethers.provider + ); + + return liquity; +}; + +const getTroveInsertionHints = async (depositAmount, borrowAmount, liquity) => { + const nominalCR = await liquity.hintHelpers.computeNominalCR( + depositAmount, + borrowAmount + ); + + const { + hintAddress, + latestRandomSeed, + } = await liquity.hintHelpers.getApproxHint(nominalCR, 50, 1298379, { + gasLimit: MAX_GAS, + }); + randomSeed = latestRandomSeed; + + const { + 0: upperHint, + 1: lowerHint, + } = await liquity.sortedTroves.findInsertPosition( + nominalCR, + hintAddress, + hintAddress, + { + gasLimit: MAX_GAS, + } + ); + + return { + upperHint, + lowerHint, + }; +}; + +let randomSeed = 4223; + +const getRedemptionHints = async (amount, liquity) => { + const ethPrice = await liquity.priceFeed.callStatic.fetchPrice(); + const [ + firstRedemptionHint, + partialRedemptionHintNicr, + ] = await liquity.hintHelpers.getRedemptionHints(amount, ethPrice, 0); + + const { + hintAddress, + latestRandomSeed, + } = await liquity.hintHelpers.getApproxHint( + partialRedemptionHintNicr, + 50, + randomSeed, + { + gasLimit: MAX_GAS, + } + ); + randomSeed = latestRandomSeed; + + const { + 0: upperHint, + 1: lowerHint, + } = await liquity.sortedTroves.findInsertPosition( + partialRedemptionHintNicr, + hintAddress, + hintAddress, + { + gasLimit: MAX_GAS, + } + ); + + return { + partialRedemptionHintNicr, + firstRedemptionHint, + upperHint, + lowerHint, + }; +}; + +const redeem = async (amount, from, wallet, liquity) => { + await sendToken(liquity.lusdToken, amount, from, wallet.address); + const { + partialRedemptionHintNicr, + firstRedemptionHint, + upperHint, + lowerHint, + } = await getRedemptionHints(amount, liquity); + const maxFeePercentage = ethers.utils.parseUnits("0.5", 18); // 0.5% max fee + + return await liquity.troveManager + .connect(wallet) + .redeemCollateral( + amount, + firstRedemptionHint, + upperHint, + lowerHint, + partialRedemptionHintNicr, + 0, + maxFeePercentage, + { + gasLimit: MAX_GAS, // permit max gas + } + ); +}; + +module.exports = { + deployAndConnect, + resetInitialState, + createDsaTrove, + sendToken, + getTroveInsertionHints, + getRedemptionHints, + redeem, + LIQUITY_CONNECTOR, + LUSD_GAS_COMPENSATION, + JUSTIN_SUN_ADDRESS, + LIQUIDATABLE_TROVE_ADDRESS, + MAX_GAS, + INSTADAPP_BASIC_V1_CONNECTOR, + ETH_ADDRESS, +}; diff --git a/test/liquity/liquity.test.js b/test/liquity/liquity.test.js new file mode 100644 index 00000000..6b4ae743 --- /dev/null +++ b/test/liquity/liquity.test.js @@ -0,0 +1,2708 @@ +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.contracts"); + +// Liquity helpers +const helpers = require("./liquity.helpers"); + +describe("Liquity", () => { + const { waffle, ethers } = hre; + const { provider } = waffle; + + // Waffle test account 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 (holds 1000 ETH) + const userWallet = 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(userWallet.address); + expect(dsa.address).to.exist; + }); + + describe("Main (Connector)", () => { + describe("Trove", () => { + describe("open()", () => { + 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( + userWallet.address + ); + const originalDsaBalance = await ethers.provider.getBalance( + dsa.address + ); + + const openTroveSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "open", + args: [ + depositAmount, + maxFeePercentage, + borrowAmount, + upperHint, + lowerHint, + [0, 0], + [0, 0], + ], + }; + + await dsa + .connect(userWallet) + .cast(...encodeSpells([openTroveSpell]), userWallet.address, { + value: depositAmount, + gasPrice: 0, + }); + + const userBalance = await ethers.provider.getBalance( + userWallet.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).eq( + originalUserBalance.sub(depositAmount), + "User's Ether balance should decrease by the amount they deposited" + ); + + 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 borrowed" + ).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( + userWallet.address + ); + const originalDsaBalance = await ethers.provider.getBalance( + dsa.address + ); + const depositId = 1; // Choose an ID to store and retrieve the deposited ETH + + const depositEthSpell = { + connector: helpers.INSTADAPP_BASIC_V1_CONNECTOR, + method: "deposit", + args: [helpers.ETH_ADDRESS, depositAmount, 0, depositId], + }; + + const openTroveSpell = { + connector: helpers.LIQUITY_CONNECTOR, + 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], + [0, 0], + ], + }; + + const spells = [depositEthSpell, openTroveSpell]; + await dsa + .connect(userWallet) + .cast(...encodeSpells(spells), userWallet.address, { + value: depositAmount, + gasPrice: 0, + }); + + const userBalance = await ethers.provider.getBalance( + userWallet.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).eq( + originalUserBalance.sub(depositAmount), + "User's Ether balance should decrease by the amount they deposited" + ); + + expect(dsaEthBalance).to.eq( + originalDsaBalance, + "DSA balance should not change" + ); + + expect( + dsaLusdBalance, + "DSA account should now hold the amount the user borrowed" + ).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( + userWallet.address + ); + const originalDsaBalance = await ethers.provider.getBalance( + dsa.address + ); + const borrowId = 1; + + const openTroveSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "open", + args: [ + depositAmount, + maxFeePercentage, + borrowAmount, + upperHint, + lowerHint, + [0, 0], + [borrowId, 0], + ], + }; + + const withdrawLusdSpell = { + connector: helpers.INSTADAPP_BASIC_V1_CONNECTOR, + method: "withdraw", + args: [ + contracts.LUSD_TOKEN_ADDRESS, + 0, // Amount comes from the previous spell's setId + dsa.address, + borrowId, + 0, + ], + }; + + const spells = [openTroveSpell, withdrawLusdSpell]; + await dsa + .connect(userWallet) + .cast(...encodeSpells(spells), userWallet.address, { + value: depositAmount, + gasPrice: 0, + }); + + const userBalance = await ethers.provider.getBalance( + userWallet.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).eq( + originalUserBalance.sub(depositAmount), + "User's Ether balance should decrease by the amount they deposited" + ); + + 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 borrowed" + ).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("returns Instadapp event name and data", async () => { + const depositAmount = ethers.utils.parseEther("5"); + const borrowAmount = ethers.utils.parseUnits("2000", 18); + const maxFeePercentage = ethers.utils.parseUnits("0.5", 18); + const upperHint = ethers.constants.AddressZero; + const lowerHint = ethers.constants.AddressZero; + + const openTroveSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "open", + args: [ + depositAmount, + maxFeePercentage, + borrowAmount, + upperHint, + lowerHint, + [0, 0], + [0, 0], + ], + }; + + const openTx = await dsa.cast( + ...encodeSpells([openTroveSpell]), + userWallet.address, + { + value: depositAmount, + } + ); + const receipt = await openTx.wait(); + const castLogEvent = receipt.events.find((e) => e.event === "LogCast") + .args; + expect(castLogEvent.eventNames[0]).eq( + "LogOpen(address,uint256,uint256,uint256,uint256[],uint256[])" + ); + const expectedEventParams = ethers.utils.defaultAbiCoder.encode( + [ + "address", + "uint256", + "uint256", + "uint256", + "uint256[]", + "uint256[]", + ], + [ + dsa.address, + maxFeePercentage, + depositAmount, + borrowAmount, + [0, 0], + [0, 0], + ] + ); + expect(castLogEvent.eventParams[0]).eq(expectedEventParams); + }); + }); + + describe("close()", () => { + it("closes a Trove", async () => { + const depositAmount = ethers.utils.parseEther("5"); + const borrowAmount = ethers.utils.parseUnits("2000", 18); + // Create a dummy Trove + await helpers.createDsaTrove( + dsa, + userWallet, + liquity, + depositAmount, + borrowAmount + ); + + const troveDebtBefore = await liquity.troveManager.getTroveDebt( + dsa.address + ); + + const troveCollateralBefore = await liquity.troveManager.getTroveColl( + dsa.address + ); + + // Send DSA account enough LUSD (from Stability Pool) to close their Trove + const extraLusdRequiredToCloseTrove = troveDebtBefore.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(troveDebtBefore); + + const closeTroveSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "close", + args: [0], + }; + + await dsa + .connect(userWallet) + .cast(...encodeSpells([closeTroveSpell]), userWallet.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(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(troveCollateralBefore); + + 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 () => { + // Create a dummy Trove + await helpers.createDsaTrove(dsa, userWallet, liquity); + + const troveDebtBefore = await liquity.troveManager.getTroveDebt( + dsa.address + ); + const troveCollateralBefore = 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, + troveDebtBefore, + contracts.STABILITY_POOL_ADDRESS, + userWallet.address + ); + + // Allow DSA to spend user's LUSD + await liquity.lusdToken + .connect(userWallet) + .approve(dsa.address, troveDebtBefore); + + // Simulate a spell which would have pulled LUSD from somewhere (e.g. Uniswap) into InstaMemory + // In this case we're simply running a deposit spell from the user's EOA + const depositLusdSpell = { + connector: helpers.INSTADAPP_BASIC_V1_CONNECTOR, + method: "deposit", + args: [contracts.LUSD_TOKEN_ADDRESS, troveDebtBefore, 0, 0], + }; + + const closeTroveSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "close", + args: [0], + }; + const spells = [depositLusdSpell, closeTroveSpell]; + + await dsa + .connect(userWallet) + .cast(...encodeSpells(spells), userWallet.address); + + 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(troveCollateralBefore); + }); + + 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); + // Create a dummy Trove + await helpers.createDsaTrove( + dsa, + userWallet, + liquity, + depositAmount, + borrowAmount + ); + + const troveDebtBefore = await liquity.troveManager.getTroveDebt( + dsa.address + ); + const troveCollateralBefore = await liquity.troveManager.getTroveColl( + dsa.address + ); + + // Send DSA account enough LUSD (from Stability Pool) to close their Trove + const extraLusdRequiredToCloseTrove = troveDebtBefore.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(troveDebtBefore); + + const collateralWithdrawId = 1; + + const closeTroveSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "close", + args: [collateralWithdrawId], + }; + + const withdrawEthSpell = { + connector: helpers.INSTADAPP_BASIC_V1_CONNECTOR, + method: "withdraw", + args: [ + helpers.ETH_ADDRESS, + 0, // amount comes from the previous spell's setId + dsa.address, + collateralWithdrawId, + 0, + ], + }; + + await dsa + .connect(userWallet) + .cast( + ...encodeSpells([closeTroveSpell, withdrawEthSpell]), + userWallet.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(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(troveCollateralBefore); + + 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("returns Instadapp event name and data", async () => { + const depositAmount = ethers.utils.parseEther("5"); + const borrowAmount = ethers.utils.parseUnits("2000", 18); + // Create a dummy Trove + await helpers.createDsaTrove( + dsa, + userWallet, + liquity, + depositAmount, + borrowAmount + ); + await helpers.sendToken( + liquity.lusdToken, + ethers.utils.parseUnits("2500", 18), + contracts.STABILITY_POOL_ADDRESS, + dsa.address + ); + + const closeTroveSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "close", + args: [0], + }; + + const closeTx = await dsa + .connect(userWallet) + .cast(...encodeSpells([closeTroveSpell]), userWallet.address); + + const receipt = await closeTx.wait(); + const castLogEvent = receipt.events.find((e) => e.event === "LogCast") + .args; + const expectedEventParams = ethers.utils.defaultAbiCoder.encode( + ["address", "uint256"], + [dsa.address, 0] + ); + expect(castLogEvent.eventNames[0]).eq("LogClose(address,uint256)"); + expect(castLogEvent.eventParams[0]).eq(expectedEventParams); + }); + }); + + describe("deposit()", () => { + it("deposits ETH into a Trove", async () => { + // Create a dummy Trove + await helpers.createDsaTrove(dsa, userWallet, liquity); + + const troveCollateralBefore = 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.LIQUITY_CONNECTOR, + method: "deposit", + args: [topupAmount, upperHint, lowerHint, 0, 0], + }; + + await dsa + .connect(userWallet) + .cast(...encodeSpells([depositEthSpell]), userWallet.address, { + value: topupAmount, + }); + + const troveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + + const expectedTroveCollateral = troveCollateralBefore.add( + topupAmount + ); + + expect( + troveCollateral, + `Trove collateral should have increased by ${topupAmount} ETH` + ).to.eq(expectedTroveCollateral); + }); + + it("deposits using ETH gained from a previous spell", async () => { + // Create a dummy Trove + await helpers.createDsaTrove(dsa, userWallet, liquity); + const troveCollateralBefore = await liquity.troveManager.getTroveColl( + dsa.address + ); + + const topupAmount = ethers.utils.parseEther("1"); + const depositId = 1; + const depositEthSpell = { + connector: helpers.INSTADAPP_BASIC_V1_CONNECTOR, + method: "deposit", + args: [helpers.ETH_ADDRESS, topupAmount, 0, depositId], + }; + + const upperHint = ethers.constants.AddressZero; + const lowerHint = ethers.constants.AddressZero; + const depositEthToTroveSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "deposit", + args: [0, upperHint, lowerHint, depositId, 0], + }; + const spells = [depositEthSpell, depositEthToTroveSpell]; + + await dsa + .connect(userWallet) + .cast(...encodeSpells(spells), userWallet.address, { + value: topupAmount, + }); + + const troveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + + const expectedTroveCollateral = troveCollateralBefore.add( + topupAmount + ); + + expect( + troveCollateral, + `Trove collateral should have increased by ${topupAmount} ETH` + ).to.eq(expectedTroveCollateral); + }); + + it("returns Instadapp event name and data", async () => { + // Create a dummy Trove + await helpers.createDsaTrove(dsa, userWallet, liquity); + + const topupAmount = ethers.utils.parseEther("1"); + const upperHint = ethers.constants.AddressZero; + const lowerHint = ethers.constants.AddressZero; + const depositEthSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "deposit", + args: [topupAmount, upperHint, lowerHint, 0, 0], + }; + + const depositTx = await dsa + .connect(userWallet) + .cast(...encodeSpells([depositEthSpell]), userWallet.address, { + value: topupAmount, + }); + + const receipt = await depositTx.wait(); + const castLogEvent = receipt.events.find((e) => e.event === "LogCast") + .args; + const expectedEventParams = ethers.utils.defaultAbiCoder.encode( + ["address", "uint256", "uint256", "uint256"], + [dsa.address, topupAmount, 0, 0] + ); + expect(castLogEvent.eventNames[0]).eq( + "LogDeposit(address,uint256,uint256,uint256)" + ); + expect(castLogEvent.eventParams[0]).eq(expectedEventParams); + }); + }); + + describe("withdraw()", () => { + it("withdraws ETH from a Trove", async () => { + // Create a dummy Trove + await helpers.createDsaTrove(dsa, userWallet, liquity); + + const troveCollateralBefore = 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.LIQUITY_CONNECTOR, + method: "withdraw", + args: [withdrawAmount, upperHint, lowerHint, 0, 0], + }; + + await dsa + .connect(userWallet) + .cast(...encodeSpells([withdrawEthSpell]), userWallet.address); + + const troveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + const expectedTroveCollateral = troveCollateralBefore.sub( + withdrawAmount + ); + + expect( + troveCollateral, + `Trove collateral should have decreased by ${withdrawAmount} ETH` + ).to.eq(expectedTroveCollateral); + }); + + it("withdraws ETH from a Trove and stores the ETH for other spells to use", async () => { + // Create a dummy Trove + await helpers.createDsaTrove(dsa, userWallet, liquity); + + const troveCollateralBefore = await liquity.troveManager.getTroveColl( + dsa.address + ); + const originalUserEthBalance = await ethers.provider.getBalance( + userWallet.address + ); + + const withdrawAmount = ethers.utils.parseEther("1"); + const upperHint = ethers.constants.AddressZero; + const lowerHint = ethers.constants.AddressZero; + const withdrawId = 1; + const withdrawEthFromTroveSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "withdraw", + args: [withdrawAmount, upperHint, lowerHint, 0, withdrawId], + }; + + const withdrawEthSpell = { + connector: helpers.INSTADAPP_BASIC_V1_CONNECTOR, + method: "withdraw", + args: [helpers.ETH_ADDRESS, 0, userWallet.address, withdrawId, 0], + }; + const spells = [withdrawEthFromTroveSpell, withdrawEthSpell]; + await dsa + .connect(userWallet) + .cast(...encodeSpells(spells), userWallet.address, { + gasPrice: 0, // Remove gas costs so we can check balances have changed correctly + }); + + const troveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + const expectedTroveCollateral = troveCollateralBefore.sub( + withdrawAmount + ); + const userEthBalance = await ethers.provider.getBalance( + userWallet.address + ); + + expect( + troveCollateral, + `Trove collateral should have decreased by ${withdrawAmount} ETH` + ).to.eq(expectedTroveCollateral); + + expect( + userEthBalance, + `User ETH balance should have increased by ${withdrawAmount} ETH` + ).to.eq(originalUserEthBalance.add(withdrawAmount)); + }); + + it("returns Instadapp event name and data", async () => { + // Create a dummy Trove + await helpers.createDsaTrove(dsa, userWallet, liquity); + + const withdrawAmount = ethers.utils.parseEther("1"); + const upperHint = ethers.constants.AddressZero; + const lowerHint = ethers.constants.AddressZero; + const withdrawEthSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "withdraw", + args: [withdrawAmount, upperHint, lowerHint, 0, 0], + }; + + const withdrawTx = await dsa + .connect(userWallet) + .cast(...encodeSpells([withdrawEthSpell]), userWallet.address); + + const receipt = await withdrawTx.wait(); + const castLogEvent = receipt.events.find((e) => e.event === "LogCast") + .args; + const expectedEventParams = ethers.utils.defaultAbiCoder.encode( + ["address", "uint256", "uint256", "uint256"], + [dsa.address, withdrawAmount, 0, 0] + ); + expect(castLogEvent.eventNames[0]).eq( + "LogWithdraw(address,uint256,uint256,uint256)" + ); + expect(castLogEvent.eventParams[0]).eq(expectedEventParams); + }); + }); + + describe("borrow()", () => { + it("borrows LUSD from a Trove", async () => { + // Create a dummy Trove + await helpers.createDsaTrove(dsa, userWallet, liquity); + + const troveDebtBefore = await liquity.troveManager.getTroveDebt( + dsa.address + ); + + const borrowAmount = ethers.utils.parseUnits("1000", 18); // 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.LIQUITY_CONNECTOR, + method: "borrow", + args: [maxFeePercentage, borrowAmount, upperHint, lowerHint, 0, 0], + }; + + // Borrow more LUSD from the Trove + await dsa + .connect(userWallet) + .cast(...encodeSpells([borrowSpell]), userWallet.address); + + const troveDebt = await liquity.troveManager.getTroveDebt( + dsa.address + ); + const expectedTroveDebt = troveDebtBefore.add(borrowAmount); + + expect( + troveDebt, + `Trove debt should have increased by at least ${borrowAmount} ETH` + ).to.gte(expectedTroveDebt); + }); + + it("borrows LUSD from a Trove and stores the LUSD for other spells to use", async () => { + // Create a dummy Trove + await helpers.createDsaTrove(dsa, userWallet, liquity); + + const troveDebtBefore = await liquity.troveManager.getTroveDebt( + dsa.address + ); + + const borrowAmount = ethers.utils.parseUnits("1000", 18); // 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 borrowId = 1; + const borrowSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "borrow", + args: [ + maxFeePercentage, + borrowAmount, + upperHint, + lowerHint, + 0, + borrowId, + ], + }; + const withdrawSpell = { + connector: helpers.INSTADAPP_BASIC_V1_CONNECTOR, + method: "withdraw", + args: [ + liquity.lusdToken.address, + 0, + userWallet.address, + borrowId, + 0, + ], + }; + const spells = [borrowSpell, withdrawSpell]; + + // Borrow more LUSD from the Trove + await dsa + .connect(userWallet) + .cast(...encodeSpells(spells), userWallet.address); + + const troveDebt = await liquity.troveManager.getTroveDebt( + dsa.address + ); + const expectedTroveDebt = troveDebtBefore.add(borrowAmount); + const userLusdBalance = await liquity.lusdToken.balanceOf( + userWallet.address + ); + + expect( + troveDebt, + `Trove debt should have increased by at least ${borrowAmount} ETH` + ).to.gte(expectedTroveDebt); + + expect( + userLusdBalance, + `User LUSD balance should equal the borrowed LUSD due to the second withdraw spell` + ).eq(borrowAmount); + }); + + it("returns Instadapp event name and data", async () => { + // Create a dummy Trove + await helpers.createDsaTrove(dsa, userWallet, liquity); + + const borrowAmount = ethers.utils.parseUnits("1000", 18); // 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.LIQUITY_CONNECTOR, + method: "borrow", + args: [maxFeePercentage, borrowAmount, upperHint, lowerHint, 0, 0], + }; + + const borrowTx = await dsa + .connect(userWallet) + .cast(...encodeSpells([borrowSpell]), userWallet.address); + + const receipt = await borrowTx.wait(); + const castLogEvent = receipt.events.find((e) => e.event === "LogCast") + .args; + const expectedEventParams = ethers.utils.defaultAbiCoder.encode( + ["address", "uint256", "uint256", "uint256"], + [dsa.address, borrowAmount, 0, 0] + ); + expect(castLogEvent.eventNames[0]).eq( + "LogBorrow(address,uint256,uint256,uint256)" + ); + expect(castLogEvent.eventParams[0]).eq(expectedEventParams); + }); + }); + + describe("repay()", () => { + it("repays LUSD to a Trove", async () => { + const depositAmount = ethers.utils.parseEther("5"); + const borrowAmount = ethers.utils.parseUnits("2500", 18); + + // Create a dummy Trove + await helpers.createDsaTrove( + dsa, + userWallet, + liquity, + depositAmount, + borrowAmount + ); + + const troveDebtBefore = await liquity.troveManager.getTroveDebt( + dsa.address + ); + // DSA account is holding 2500 LUSD from opening a Trove, so we use some of that to repay + const repayAmount = ethers.utils.parseUnits("100", 18); // 100 LUSD + + const { upperHint, lowerHint } = await helpers.getTroveInsertionHints( + depositAmount, + borrowAmount, + liquity + ); + const repaySpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "repay", + args: [repayAmount, upperHint, lowerHint, 0, 0], + }; + + await dsa + .connect(userWallet) + .cast(...encodeSpells([repaySpell]), userWallet.address); + + const troveDebt = await liquity.troveManager.getTroveDebt( + dsa.address + ); + const expectedTroveDebt = troveDebtBefore.sub(repayAmount); + + expect( + troveDebt, + `Trove debt should have decreased by ${repayAmount} ETH` + ).to.eq(expectedTroveDebt); + }); + + it("repays LUSD to a Trove using LUSD collected from a previous spell", async () => { + const depositAmount = ethers.utils.parseEther("5"); + const borrowAmount = ethers.utils.parseUnits("2500", 18); + + // Create a dummy Trove + await helpers.createDsaTrove( + dsa, + userWallet, + liquity, + depositAmount, + borrowAmount + ); + + const troveDebtBefore = await liquity.troveManager.getTroveDebt( + dsa.address + ); + + const repayAmount = ethers.utils.parseUnits("100", 18); // 100 LUSD + const { upperHint, lowerHint } = await helpers.getTroveInsertionHints( + depositAmount, + borrowAmount, + liquity + ); + + // Drain the DSA's LUSD balance so that we ensure we are repaying using LUSD from a previous spell + await helpers.sendToken( + liquity.lusdToken, + borrowAmount, + dsa.address, + userWallet.address + ); + + // Allow DSA to spend user's LUSD + await liquity.lusdToken + .connect(userWallet) + .approve(dsa.address, repayAmount); + + const lusdDepositId = 1; + const depositSpell = { + connector: helpers.INSTADAPP_BASIC_V1_CONNECTOR, + method: "deposit", + args: [liquity.lusdToken.address, repayAmount, 0, lusdDepositId], + }; + const borrowSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "repay", + args: [0, upperHint, lowerHint, lusdDepositId, 0], + }; + + const spells = [depositSpell, borrowSpell]; + + await dsa + .connect(userWallet) + .cast(...encodeSpells(spells), userWallet.address); + + const troveDebt = await liquity.troveManager.getTroveDebt( + dsa.address + ); + const expectedTroveDebt = troveDebtBefore.sub(repayAmount); + + expect( + troveDebt, + `Trove debt should have decreased by ${repayAmount} ETH` + ).to.eq(expectedTroveDebt); + }); + + it("returns Instadapp event name and data", async () => { + // Create a dummy Trove + const depositAmount = ethers.utils.parseEther("5"); + const borrowAmount = ethers.utils.parseUnits("2500", 18); + await helpers.createDsaTrove( + dsa, + userWallet, + liquity, + depositAmount, + borrowAmount + ); + + const repayAmount = ethers.utils.parseUnits("100", 18); // 100 LUSD + const { upperHint, lowerHint } = await helpers.getTroveInsertionHints( + depositAmount, + borrowAmount, + liquity + ); + + const borrowSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "repay", + args: [repayAmount, upperHint, lowerHint, 0, 0], + }; + + const repayTx = await dsa + .connect(userWallet) + .cast(...encodeSpells([borrowSpell]), userWallet.address, { + value: repayAmount, + }); + + const receipt = await repayTx.wait(); + const castLogEvent = receipt.events.find((e) => e.event === "LogCast") + .args; + const expectedEventParams = ethers.utils.defaultAbiCoder.encode( + ["address", "uint256", "uint256", "uint256"], + [dsa.address, repayAmount, 0, 0] + ); + expect(castLogEvent.eventNames[0]).eq( + "LogRepay(address,uint256,uint256,uint256)" + ); + expect(castLogEvent.eventParams[0]).eq(expectedEventParams); + }); + }); + + describe("adjust()", () => { + it("adjusts a Trove: deposit ETH and borrow LUSD", async () => { + // Create a dummy Trove + await helpers.createDsaTrove(dsa, userWallet, liquity); + + const troveCollateralBefore = await liquity.troveManager.getTroveColl( + dsa.address + ); + const troveDebtBefore = await liquity.troveManager.getTroveDebt( + dsa.address + ); + const depositAmount = ethers.utils.parseEther("1"); // 1 ETH + const borrowAmount = ethers.utils.parseUnits("500", 18); // 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.LIQUITY_CONNECTOR, + method: "adjust", + args: [ + maxFeePercentage, + depositAmount, + withdrawAmount, + borrowAmount, + repayAmount, + upperHint, + lowerHint, + [0, 0, 0, 0], + [0, 0, 0, 0], + ], + }; + + // Adjust Trove by depositing ETH and borrowing LUSD + await dsa + .connect(userWallet) + .cast(...encodeSpells([adjustSpell]), userWallet.address, { + value: depositAmount, + gasLimit: helpers.MAX_GAS, + }); + + const troveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + const troveDebt = await liquity.troveManager.getTroveDebt( + dsa.address + ); + const expectedTroveColl = troveCollateralBefore.add(depositAmount); + const expectedTroveDebt = troveDebtBefore.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 () => { + // Create a dummy Trove + await helpers.createDsaTrove(dsa, userWallet, liquity); + + const troveCollateralBefore = await liquity.troveManager.getTroveColl( + dsa.address + ); + const troveDebtBefore = 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", 18); // 500 LUSD; + const { upperHint, lowerHint } = await helpers.getTroveInsertionHints( + troveCollateralBefore.sub(withdrawAmount), + troveDebtBefore.sub(repayAmount), + liquity + ); + const maxFeePercentage = ethers.utils.parseUnits("0.5", 18); // 0.5% max fee + + const adjustSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "adjust", + args: [ + maxFeePercentage, + depositAmount, + withdrawAmount, + borrowAmount, + repayAmount, + upperHint, + lowerHint, + [0, 0, 0, 0], + [0, 0, 0, 0], + ], + }; + + // Adjust Trove by withdrawing ETH and repaying LUSD + await dsa + .connect(userWallet) + .cast(...encodeSpells([adjustSpell]), userWallet.address, { + value: depositAmount, + gasLimit: helpers.MAX_GAS, + }); + + const troveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + const troveDebt = await liquity.troveManager.getTroveDebt( + dsa.address + ); + const expectedTroveColl = troveCollateralBefore.sub(withdrawAmount); + const expectedTroveDebt = troveDebtBefore.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("adjusts a Trove: deposit ETH and repay LUSD using previous spells", async () => { + // Create a dummy Trove + await helpers.createDsaTrove(dsa, userWallet, liquity); + + const troveCollateralBefore = await liquity.troveManager.getTroveColl( + dsa.address + ); + const troveDebtBefore = await liquity.troveManager.getTroveDebt( + dsa.address + ); + const depositAmount = ethers.utils.parseEther("1"); // 1 ETH + const borrowAmount = 0; + const withdrawAmount = 0; + const repayAmount = ethers.utils.parseUnits("100", 18); // 100 lUSD + const upperHint = ethers.constants.AddressZero; + const lowerHint = ethers.constants.AddressZero; + const maxFeePercentage = ethers.utils.parseUnits("0.5", 18); // 0.5% max fee + + const ethDepositId = 1; + const lusdRepayId = 2; + + const depositEthSpell = { + connector: helpers.INSTADAPP_BASIC_V1_CONNECTOR, + method: "deposit", + args: [helpers.ETH_ADDRESS, depositAmount, 0, ethDepositId], + }; + + const depositLusdSpell = { + connector: helpers.INSTADAPP_BASIC_V1_CONNECTOR, + method: "deposit", + args: [liquity.lusdToken.address, repayAmount, 0, lusdRepayId], + }; + + const adjustSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "adjust", + args: [ + maxFeePercentage, + 0, // Deposit amount comes from a previous spell's storage slot + withdrawAmount, + borrowAmount, + 0, // Repay amount comes from a previous spell's storage slot + upperHint, + lowerHint, + [ethDepositId, 0, 0, 0], + [0, 0, 0, 0], + ], + }; + const spells = [depositEthSpell, depositLusdSpell, adjustSpell]; + + // Send user some LUSD so they can repay + await helpers.sendToken( + liquity.lusdToken, + repayAmount, + helpers.JUSTIN_SUN_ADDRESS, + userWallet.address + ); + + // Allow DSA to spend user's LUSD + await liquity.lusdToken + .connect(userWallet) + .approve(dsa.address, repayAmount); + + // Adjust Trove by depositing ETH and borrowing LUSD + await dsa + .connect(userWallet) + .cast(...encodeSpells(spells), userWallet.address, { + value: depositAmount, + gasLimit: helpers.MAX_GAS, + }); + + const troveCollateral = await liquity.troveManager.getTroveColl( + dsa.address + ); + const troveDebt = await liquity.troveManager.getTroveDebt( + dsa.address + ); + const expectedTroveColl = troveCollateralBefore.add(depositAmount); + const expectedTroveDebt = troveDebtBefore.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.eq(expectedTroveDebt); + }); + + it("adjusts a Trove: withdraw ETH, borrow LUSD, and store the amounts for other spells", async () => { + // Create a dummy Trove + await helpers.createDsaTrove(dsa, userWallet, liquity); + + const userEthBalanceBefore = await ethers.provider.getBalance( + userWallet.address + ); + const userLusdBalanceBefore = await liquity.lusdToken.balanceOf( + userWallet.address + ); + + const depositAmount = 0; + const borrowAmount = ethers.utils.parseUnits("100", 18); // 100 LUSD + const withdrawAmount = ethers.utils.parseEther("1"); // 1 ETH + 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 ethWithdrawId = 1; + const lusdBorrowId = 2; + + const adjustSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "adjust", + args: [ + maxFeePercentage, + depositAmount, + withdrawAmount, + borrowAmount, + repayAmount, + upperHint, + lowerHint, + [0, 0, 0, 0], + [0, ethWithdrawId, lusdBorrowId, 0], + ], + }; + + const withdrawEthSpell = { + connector: helpers.INSTADAPP_BASIC_V1_CONNECTOR, + method: "withdraw", + args: [ + helpers.ETH_ADDRESS, + 0, + userWallet.address, + ethWithdrawId, + 0, + ], + }; + + const withdrawLusdSpell = { + connector: helpers.INSTADAPP_BASIC_V1_CONNECTOR, + method: "withdraw", + args: [ + liquity.lusdToken.address, + 0, + userWallet.address, + lusdBorrowId, + 0, + ], + }; + + const spells = [adjustSpell, withdrawEthSpell, withdrawLusdSpell]; + + // Adjust Trove by withdrawing ETH and borrowing LUSD + await dsa + .connect(userWallet) + .cast(...encodeSpells(spells), userWallet.address, { + gasLimit: helpers.MAX_GAS, + gasPrice: 0, + }); + + const userEthBalanceAfter = await ethers.provider.getBalance( + userWallet.address + ); + const userLusdBalanceAfter = await liquity.lusdToken.balanceOf( + userWallet.address + ); + expect(userEthBalanceAfter).eq( + userEthBalanceBefore.add(withdrawAmount) + ); + expect(userLusdBalanceAfter).eq( + userLusdBalanceBefore.add(borrowAmount) + ); + }); + + it("returns Instadapp event name and data", async () => { + // Create a dummy Trove + await helpers.createDsaTrove(dsa, userWallet, liquity); + + const depositAmount = ethers.utils.parseEther("1"); // 1 ETH + const borrowAmount = ethers.utils.parseUnits("500", 18); // 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.LIQUITY_CONNECTOR, + method: "adjust", + args: [ + maxFeePercentage, + depositAmount, + withdrawAmount, + borrowAmount, + repayAmount, + upperHint, + lowerHint, + [0, 0, 0, 0], + [0, 0, 0, 0], + ], + }; + + const adjustTx = await dsa + .connect(userWallet) + .cast(...encodeSpells([adjustSpell]), userWallet.address, { + value: depositAmount, + gasLimit: helpers.MAX_GAS, + }); + + const receipt = await adjustTx.wait(); + const castLogEvent = receipt.events.find((e) => e.event === "LogCast") + .args; + const expectedEventParams = ethers.utils.defaultAbiCoder.encode( + [ + "address", + "uint256", + "uint256", + "uint256", + "uint256", + "uint256", + "uint256[]", + "uint256[]", + ], + [ + dsa.address, + maxFeePercentage, + depositAmount, + withdrawAmount, + borrowAmount, + repayAmount, + [0, 0, 0, 0], + [0, 0, 0, 0], + ] + ); + expect(castLogEvent.eventNames[0]).eq( + "LogAdjust(address,uint256,uint256,uint256,uint256,uint256,uint256[],uint256[])" + ); + expect(castLogEvent.eventParams[0]).eq(expectedEventParams); + }); + }); + + describe("claimCollateralFromRedemption()", () => { + 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, + userWallet, + liquity, + 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, + userWallet.address + ); + const { + partialRedemptionHintNicr, + firstRedemptionHint, + upperHint, + lowerHint, + } = await helpers.getRedemptionHints(redeemAmount, liquity); + const maxFeePercentage = ethers.utils.parseUnits("0.5", 18); // 0.5% max fee + + await liquity.troveManager + .connect(userWallet) + .redeemCollateral( + redeemAmount, + firstRedemptionHint, + upperHint, + lowerHint, + partialRedemptionHintNicr, + 0, + maxFeePercentage, + { + gasLimit: helpers.MAX_GAS, // permit max gas + } + ); + + const remainingEthCollateral = await liquity.collSurplus.getCollateral( + dsa.address + ); + + // Claim the remaining collateral from the redeemed Trove + const claimCollateralFromRedemptionSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "claimCollateralFromRedemption", + args: [0], + }; + + await dsa + .connect(userWallet) + .cast( + ...encodeSpells([claimCollateralFromRedemptionSpell]), + userWallet.address + ); + + const ethBalance = await ethers.provider.getBalance(dsa.address); + + expect(ethBalance).to.eq(remainingEthCollateral); + }); + + it("returns Instadapp event name and data", async () => { + // Create a low collateralized Trove + const depositAmount = ethers.utils.parseEther("1.5"); + const borrowAmount = ethers.utils.parseUnits("2500", 18); + + await helpers.createDsaTrove( + dsa, + userWallet, + liquity, + depositAmount, + borrowAmount + ); + + // Redeem lots of LUSD to cause the Trove to become redeemed + const redeemAmount = ethers.utils.parseUnits("10000000", 18); + const setId = 0; + await helpers.sendToken( + liquity.lusdToken, + redeemAmount, + contracts.STABILITY_POOL_ADDRESS, + userWallet.address + ); + const { + partialRedemptionHintNicr, + firstRedemptionHint, + upperHint, + lowerHint, + } = await helpers.getRedemptionHints(redeemAmount, liquity); + const maxFeePercentage = ethers.utils.parseUnits("0.5", 18); // 0.5% max fee + + await liquity.troveManager + .connect(userWallet) + .redeemCollateral( + redeemAmount, + firstRedemptionHint, + upperHint, + lowerHint, + partialRedemptionHintNicr, + 0, + maxFeePercentage, + { + gasLimit: helpers.MAX_GAS, // permit max gas + } + ); + const claimAmount = await liquity.collSurplus.getCollateral( + dsa.address + ); + + const claimCollateralFromRedemptionSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "claimCollateralFromRedemption", + args: [setId], + }; + + const claimTx = await dsa + .connect(userWallet) + .cast( + ...encodeSpells([claimCollateralFromRedemptionSpell]), + userWallet.address + ); + + const receipt = await claimTx.wait(); + const castLogEvent = receipt.events.find((e) => e.event === "LogCast") + .args; + const expectedEventParams = ethers.utils.defaultAbiCoder.encode( + ["address", "uint256", "uint256"], + [dsa.address, claimAmount, setId] + ); + expect(castLogEvent.eventNames[0]).eq( + "LogClaimCollateralFromRedemption(address,uint256,uint256)" + ); + expect(castLogEvent.eventParams[0]).eq(expectedEventParams); + }); + }); + }); + + describe("Stability Pool", () => { + describe("stabilityDeposit()", () => { + 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.LIQUITY_CONNECTOR, + method: "stabilityDeposit", + args: [amount, frontendTag, 0, 0, 0, 0], + }; + + await dsa + .connect(userWallet) + .cast(...encodeSpells([stabilityDepositSpell]), userWallet.address); + + const depositedAmount = await liquity.stabilityPool.getCompoundedLUSDDeposit( + dsa.address + ); + expect(depositedAmount).to.eq(amount); + }); + + it("deposits into Stability Pool using LUSD collected from a previous spell", async () => { + const amount = ethers.utils.parseUnits("100", 18); + const frontendTag = ethers.constants.AddressZero; + + await helpers.sendToken( + liquity.lusdToken, + amount, + contracts.STABILITY_POOL_ADDRESS, + userWallet.address + ); + const lusdDepositId = 1; + + const depositLusdSpell = { + connector: helpers.INSTADAPP_BASIC_V1_CONNECTOR, + method: "deposit", + args: [liquity.lusdToken.address, amount, 0, lusdDepositId], + }; + const stabilityDepositSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "stabilityDeposit", + args: [0, frontendTag, lusdDepositId, 0, 0, 0], + }; + const spells = [depositLusdSpell, stabilityDepositSpell]; + + // Allow DSA to spend user's LUSD + await liquity.lusdToken + .connect(userWallet) + .approve(dsa.address, amount); + + await dsa + .connect(userWallet) + .cast(...encodeSpells(spells), userWallet.address); + + const depositedAmount = await liquity.stabilityPool.getCompoundedLUSDDeposit( + dsa.address + ); + expect(depositedAmount).to.eq(amount); + }); + + it("returns Instadapp event name and data", async () => { + const amount = ethers.utils.parseUnits("100", 18); + const halfAmount = amount.div(2); + const frontendTag = ethers.constants.AddressZero; + const getDepositId = 0; + const setDepositId = 0; + const setEthGainId = 0; + const setLqtyGainId = 0; + + await helpers.sendToken( + liquity.lusdToken, + amount, + contracts.STABILITY_POOL_ADDRESS, + dsa.address + ); + + const stabilityDepositSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "stabilityDeposit", + args: [ + halfAmount, + frontendTag, + getDepositId, + setDepositId, + setEthGainId, + setLqtyGainId, + ], + }; + + // Create a Stability deposit for this DSA + await dsa + .connect(userWallet) + .cast(...encodeSpells([stabilityDepositSpell]), userWallet.address); + + // Liquidate a Trove to cause an ETH gain + await liquity.troveManager.connect(userWallet).liquidateTroves(1, { + gasLimit: helpers.MAX_GAS, + }); + + // Fast forward in time so we have an LQTY gain + await provider.send("evm_increaseTime", [600]); + await provider.send("evm_mine"); + + // Create a Stability Pool deposit with a differen DSA so that LQTY gains can be calculated + // See: https://github.com/liquity/dev/#lqty-reward-events-and-payouts + const tempDsa = await buildDSAv2(userWallet.address); + await helpers.sendToken( + liquity.lusdToken, + amount, + contracts.STABILITY_POOL_ADDRESS, + tempDsa.address + ); + await tempDsa + .connect(userWallet) + .cast(...encodeSpells([stabilityDepositSpell]), userWallet.address); + + const ethGain = await liquity.stabilityPool.getDepositorETHGain( + dsa.address + ); + const lqtyGain = await liquity.stabilityPool.getDepositorLQTYGain( + dsa.address + ); + + // Top up the user's deposit so that we can track their ETH and LQTY gain + const depositAgainTx = await dsa + .connect(userWallet) + .cast(...encodeSpells([stabilityDepositSpell]), userWallet.address); + + const receipt = await depositAgainTx.wait(); + const castLogEvent = receipt.events.find((e) => e.event === "LogCast") + .args; + const expectedEventParams = ethers.utils.defaultAbiCoder.encode( + [ + "address", + "uint256", + "uint256", + "uint256", + "address", + "uint256", + "uint256", + "uint256", + "uint256", + ], + [ + dsa.address, + halfAmount, + ethGain, + lqtyGain, + frontendTag, + getDepositId, + setDepositId, + setEthGainId, + setLqtyGainId, + ] + ); + expect(castLogEvent.eventNames[0]).eq( + "LogStabilityDeposit(address,uint256,uint256,uint256,address,uint256,uint256,uint256,uint256)" + ); + expect(castLogEvent.eventParams[0]).eq(expectedEventParams); + }); + }); + + describe("stabilityWithdraw()", () => { + it("withdraws from Stability Pool", async () => { + // Start this test from scratch since we need to remove any liquidatable Troves withdrawing from Stability Pool + [liquity, dsa] = await helpers.resetInitialState( + userWallet.address, + contracts + ); + + // The current block number has liquidatable Troves. + // Remove them otherwise Stability Pool withdrawals are disabled + await liquity.troveManager.connect(userWallet).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.LIQUITY_CONNECTOR, + method: "stabilityDeposit", + args: [amount, frontendTag, 0, 0, 0, 0], + }; + + // Withdraw half of the deposit + const stabilityWithdrawSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "stabilityWithdraw", + args: [amount.div(2), 0, 0, 0, 0], + }; + const spells = [stabilityDepositSpell, stabilityWithdrawSpell]; + + await dsa + .connect(userWallet) + .cast(...encodeSpells(spells), userWallet.address); + + 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("withdraws from Stability Pool and stores the LUSD for other spells", async () => { + // Start this test from scratch since we need to remove any liquidatable Troves withdrawing from Stability Pool + [liquity, dsa] = await helpers.resetInitialState( + userWallet.address, + contracts + ); + + // The current block number has liquidatable Troves. + // Remove them otherwise Stability Pool withdrawals are disabled + await liquity.troveManager.connect(userWallet).liquidateTroves(90, { + gasLimit: helpers.MAX_GAS, + }); + const amount = ethers.utils.parseUnits("100", 18); + const frontendTag = ethers.constants.AddressZero; + const withdrawId = 1; + + await helpers.sendToken( + liquity.lusdToken, + amount, + contracts.STABILITY_POOL_ADDRESS, + dsa.address + ); + + const stabilityDepositSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "stabilityDeposit", + args: [amount, frontendTag, 0, 0, 0, 0], + }; + + // Withdraw half of the deposit + const stabilityWithdrawSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "stabilityWithdraw", + args: [amount.div(2), 0, 0, 0, withdrawId], + }; + + const withdrawLusdSpell = { + connector: helpers.INSTADAPP_BASIC_V1_CONNECTOR, + method: "withdraw", + args: [ + liquity.lusdToken.address, + 0, + userWallet.address, + withdrawId, + 0, + ], + }; + + const spells = [ + stabilityDepositSpell, + stabilityWithdrawSpell, + withdrawLusdSpell, + ]; + + await dsa + .connect(userWallet) + .cast(...encodeSpells(spells), userWallet.address); + + const depositedAmount = await liquity.stabilityPool.getCompoundedLUSDDeposit( + dsa.address + ); + const walletLusdBalance = await liquity.lusdToken.balanceOf( + dsa.address + ); + + expect(depositedAmount).to.eq(amount.div(2)); + expect(walletLusdBalance).to.eq(amount.div(2)); + }); + + it("returns Instadapp event name and data", async () => { + // Start this test from scratch since we need to remove any liquidatable Troves withdrawing from Stability Pool + [liquity, dsa] = await helpers.resetInitialState( + userWallet.address, + contracts + ); + + 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.LIQUITY_CONNECTOR, + method: "stabilityDeposit", + args: [amount, frontendTag, 0, 0, 0, 0], + }; + + // Withdraw half of the deposit + const withdrawAmount = amount.div(2); + const getWithdrawId = 0; + const setWithdrawId = 0; + const setEthGainId = 0; + const setLqtyGainId = 0; + + // Create a Stability Pool deposit + await dsa + .connect(userWallet) + .cast(...encodeSpells([stabilityDepositSpell]), userWallet.address); + + // The current block number has liquidatable Troves. + // Remove them otherwise Stability Pool withdrawals are disabled + await liquity.troveManager.connect(userWallet).liquidateTroves(90, { + gasLimit: helpers.MAX_GAS, + }); + + // Fast forward in time so we have an LQTY gain + await provider.send("evm_increaseTime", [600]); + await provider.send("evm_mine"); + + // Create another Stability Pool deposit so that LQTY gains are realized + // See: https://github.com/liquity/dev/#lqty-reward-events-and-payouts + const tempDsa = await buildDSAv2(userWallet.address); + await helpers.sendToken( + liquity.lusdToken, + amount, + contracts.STABILITY_POOL_ADDRESS, + tempDsa.address + ); + await tempDsa + .connect(userWallet) + .cast(...encodeSpells([stabilityDepositSpell]), userWallet.address); + + const ethGain = await liquity.stabilityPool.getDepositorETHGain( + dsa.address + ); + const lqtyGain = await liquity.stabilityPool.getDepositorLQTYGain( + dsa.address + ); + + const stabilityWithdrawSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "stabilityWithdraw", + args: [ + withdrawAmount, + getWithdrawId, + setWithdrawId, + setEthGainId, + setLqtyGainId, + ], + }; + + const withdrawTx = await dsa + .connect(userWallet) + .cast( + ...encodeSpells([stabilityWithdrawSpell]), + userWallet.address + ); + + const receipt = await withdrawTx.wait(); + const castLogEvent = receipt.events.find((e) => e.event === "LogCast") + .args; + const expectedEventParams = ethers.utils.defaultAbiCoder.encode( + [ + "address", + "uint256", + "uint256", + "uint256", + "uint256", + "uint256", + "uint256", + "uint256", + ], + [ + dsa.address, + withdrawAmount, + ethGain, + lqtyGain, + getWithdrawId, + setWithdrawId, + setEthGainId, + setLqtyGainId, + ] + ); + expect(castLogEvent.eventNames[0]).eq( + "LogStabilityWithdraw(address,uint256,uint256,uint256,uint256,uint256,uint256,uint256)" + ); + expect(castLogEvent.eventParams[0]).eq(expectedEventParams); + }); + }); + + describe("stabilityMoveEthGainToTrove()", () => { + beforeEach(async () => { + // Start these test from fresh so that we definitely have a liquidatable Trove within this block + [liquity, dsa] = await helpers.resetInitialState( + userWallet.address, + contracts + ); + }); + + it("moves ETH gain from Stability Pool to Trove", async () => { + // Create a DSA owned Trove to capture ETH liquidation gains + // Create a dummy Trove + await helpers.createDsaTrove(dsa, userWallet, liquity); + 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.LIQUITY_CONNECTOR, + method: "stabilityDeposit", + args: [amount, frontendTag, 0, 0, 0, 0], + }; + + await dsa + .connect(userWallet) + .cast(...encodeSpells([stabilityDepositSpell]), userWallet.address); + + // Liquidate a Trove to create an ETH gain for the new DSA Trove + await liquity.troveManager + .connect(userWallet) + .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.LIQUITY_CONNECTOR, + method: "stabilityMoveEthGainToTrove", + args: [ethers.constants.AddressZero, ethers.constants.AddressZero], + }; + + await dsa + .connect(userWallet) + .cast(...encodeSpells([moveEthGainSpell]), userWallet.address); + + 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); + }); + + it("returns Instadapp event name and data", async () => { + // Create a DSA owned Trove to capture ETH liquidation gains + // Create a dummy Trove + await helpers.createDsaTrove(dsa, userWallet, liquity); + + // 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.LIQUITY_CONNECTOR, + method: "stabilityDeposit", + args: [amount, frontendTag, 0, 0, 0, 0], + }; + + await dsa + .connect(userWallet) + .cast(...encodeSpells([stabilityDepositSpell]), userWallet.address); + + // Liquidate a Trove to create an ETH gain for the new DSA Trove + await liquity.troveManager + .connect(userWallet) + .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.LIQUITY_CONNECTOR, + method: "stabilityMoveEthGainToTrove", + args: [ethers.constants.AddressZero, ethers.constants.AddressZero], + }; + + const moveEthGainTx = await dsa + .connect(userWallet) + .cast(...encodeSpells([moveEthGainSpell]), userWallet.address); + + const receipt = await moveEthGainTx.wait(); + + const castLogEvent = receipt.events.find((e) => e.event === "LogCast") + .args; + const expectedEventParams = ethers.utils.defaultAbiCoder.encode( + ["address", "uint256"], + [dsa.address, ethGainFromLiquidation] + ); + expect(castLogEvent.eventNames[0]).eq( + "LogStabilityMoveEthGainToTrove(address,uint256)" + ); + expect(castLogEvent.eventParams[0]).eq(expectedEventParams); + }); + }); + }); + + describe("Staking", () => { + describe("stake()", () => { + 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.LIQUITY_CONNECTOR, + method: "stake", + args: [amount, 0, 0, 0, 0], + }; + + await dsa + .connect(userWallet) + .cast(...encodeSpells([stakeSpell]), userWallet.address); + + 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("stakes LQTY using LQTY obtained from a previous spell", 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, + userWallet.address + ); + + const lqtyDepositId = 1; + const depositSpell = { + connector: helpers.INSTADAPP_BASIC_V1_CONNECTOR, + method: "deposit", + args: [liquity.lqtyToken.address, amount, 0, lqtyDepositId], + }; + const stakeSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "stake", + args: [0, lqtyDepositId, 0, 0, 0], + }; + const spells = [depositSpell, stakeSpell]; + + // Allow DSA to spend user's LQTY + await liquity.lqtyToken + .connect(userWallet) + .approve(dsa.address, amount); + + await dsa + .connect(userWallet) + .cast(...encodeSpells(spells), userWallet.address); + + 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("returns Instadapp event name and data", async () => { + const amount = ethers.utils.parseUnits("1", 18); + await helpers.sendToken( + liquity.lqtyToken, + amount, + helpers.JUSTIN_SUN_ADDRESS, + dsa.address + ); + + const getStakeId = 0; + const setStakeId = 0; + const setEthGainId = 0; + const setLusdGainId = 0; + const stakeSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "stake", + args: [amount, getStakeId, setStakeId, setEthGainId, setLusdGainId], + }; + + const stakeTx = await dsa + .connect(userWallet) + .cast(...encodeSpells([stakeSpell]), userWallet.address); + + const receipt = await stakeTx.wait(); + + const castLogEvent = receipt.events.find((e) => e.event === "LogCast") + .args; + const expectedEventParams = ethers.utils.defaultAbiCoder.encode( + ["address", "uint256", "uint256", "uint256", "uint256", "uint256"], + [ + dsa.address, + amount, + getStakeId, + setStakeId, + setEthGainId, + setLusdGainId, + ] + ); + expect(castLogEvent.eventNames[0]).eq( + "LogStake(address,uint256,uint256,uint256,uint256,uint256)" + ); + expect(castLogEvent.eventParams[0]).eq(expectedEventParams); + }); + }); + + describe("unstake()", () => { + 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.LIQUITY_CONNECTOR, + method: "stake", + args: [amount, 0, 0, 0, 0], + }; + + await dsa + .connect(userWallet) + .cast(...encodeSpells([stakeSpell]), userWallet.address); + + const totalStakingBalanceBefore = await liquity.lqtyToken.balanceOf( + contracts.STAKING_ADDRESS + ); + const unstakeSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "unstake", + args: [amount, 0, 0, 0, 0], + }; + + await dsa + .connect(userWallet) + .cast(...encodeSpells([unstakeSpell]), userWallet.address); + + 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) + ); + }); + + it("unstakes LQTY and stores the LQTY for other spells", async () => { + const amount = ethers.utils.parseUnits("1", 18); + await helpers.sendToken( + liquity.lqtyToken, + amount, + helpers.JUSTIN_SUN_ADDRESS, + dsa.address + ); + + const stakeSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "stake", + args: [amount, 0, 0, 0, 0], + }; + + await dsa + .connect(userWallet) + .cast(...encodeSpells([stakeSpell]), userWallet.address); + + const totalStakingBalanceBefore = await liquity.lqtyToken.balanceOf( + contracts.STAKING_ADDRESS + ); + const withdrawId = 1; + const unstakeSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "unstake", + args: [amount, 0, withdrawId, 0, 0], + }; + + const withdrawLqtySpell = { + connector: helpers.INSTADAPP_BASIC_V1_CONNECTOR, + method: "withdraw", + args: [ + liquity.lqtyToken.address, + 0, + userWallet.address, + withdrawId, + 0, + ], + }; + const spells = [unstakeSpell, withdrawLqtySpell]; + await dsa + .connect(userWallet) + .cast(...encodeSpells(spells), userWallet.address); + + const lqtyBalance = await liquity.lqtyToken.balanceOf(dsa.address); + const totalStakingBalance = await liquity.lqtyToken.balanceOf( + contracts.STAKING_ADDRESS + ); + const userLqtyBalance = await liquity.lqtyToken.balanceOf( + userWallet.address + ); + expect(lqtyBalance).to.eq(0); + expect(totalStakingBalance).to.eq( + totalStakingBalanceBefore.sub(amount) + ); + expect(userLqtyBalance).to.eq(amount); + }); + + it("returns Instadapp event name and data", async () => { + const amount = ethers.utils.parseUnits("1", 18); + await helpers.sendToken( + liquity.lqtyToken, + amount, + helpers.JUSTIN_SUN_ADDRESS, + dsa.address + ); + + const stakeSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "stake", + args: [amount, 0, 0, 0, 0], + }; + + await dsa + .connect(userWallet) + .cast(...encodeSpells([stakeSpell]), userWallet.address); + + const getUnstakeId = 0; + const setUnstakeId = 0; + const setEthGainId = 0; + const setLusdGainId = 0; + const unstakeSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "unstake", + args: [ + amount, + getUnstakeId, + setUnstakeId, + setEthGainId, + setLusdGainId, + ], + }; + + const unstakeTx = await dsa + .connect(userWallet) + .cast(...encodeSpells([unstakeSpell]), userWallet.address); + + const receipt = await unstakeTx.wait(); + + const castLogEvent = receipt.events.find((e) => e.event === "LogCast") + .args; + const expectedEventParams = ethers.utils.defaultAbiCoder.encode( + ["address", "uint256", "uint256", "uint256", "uint256", "uint256"], + [ + dsa.address, + amount, + getUnstakeId, + setUnstakeId, + setEthGainId, + setLusdGainId, + ] + ); + expect(castLogEvent.eventNames[0]).eq( + "LogUnstake(address,uint256,uint256,uint256,uint256,uint256)" + ); + expect(castLogEvent.eventParams[0]).eq(expectedEventParams); + }); + }); + + describe("claimStakingGains()", () => { + it("claims gains from staking", async () => { + const stakerDsa = await buildDSAv2(userWallet.address); + const amount = ethers.utils.parseUnits("1000", 18); // 1000 LQTY + + // Stake lots of LQTY + await helpers.sendToken( + liquity.lqtyToken, + amount, + helpers.JUSTIN_SUN_ADDRESS, + stakerDsa.address + ); + const stakeSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "stake", + args: [amount, 0, 0, 0, 0], + }; + await stakerDsa + .connect(userWallet) + .cast(...encodeSpells([stakeSpell]), userWallet.address); + + // Open a Trove to cause an ETH issuance gain for stakers + await helpers.createDsaTrove(dsa, userWallet, liquity); + + // Redeem some ETH to cause an LUSD redemption gain for stakers + await helpers.redeem( + ethers.utils.parseUnits("1000", 18), + contracts.STABILITY_POOL_ADDRESS, + userWallet, + liquity + ); + + const setEthGainId = 0; + const setLusdGainId = 0; + const ethGain = await liquity.staking.getPendingETHGain( + stakerDsa.address + ); + const lusdGain = await liquity.staking.getPendingLUSDGain( + stakerDsa.address + ); + + const claimStakingGainsSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "claimStakingGains", + args: [setEthGainId, setLusdGainId], + }; + + const ethBalanceBefore = await ethers.provider.getBalance( + stakerDsa.address + ); + + // Claim gains + await stakerDsa + .connect(userWallet) + .cast( + ...encodeSpells([claimStakingGainsSpell]), + userWallet.address + ); + + const ethBalanceAfter = await ethers.provider.getBalance( + stakerDsa.address + ); + const lusdBalanceAfter = await liquity.lusdToken.balanceOf( + stakerDsa.address + ); + expect(ethBalanceAfter).to.eq(ethBalanceBefore.add(ethGain)); + expect(lusdBalanceAfter).to.eq(lusdGain); + }); + + it("claims gains from staking and stores them for other spells", async () => { + const stakerDsa = await buildDSAv2(userWallet.address); + const amount = ethers.utils.parseUnits("1000", 18); // 1000 LQTY + + // Stake lots of LQTY + await helpers.sendToken( + liquity.lqtyToken, + amount, + helpers.JUSTIN_SUN_ADDRESS, + stakerDsa.address + ); + const stakeSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "stake", + args: [amount, 0, 0, 0, 0], + }; + await stakerDsa + .connect(userWallet) + .cast(...encodeSpells([stakeSpell]), userWallet.address); + + // Open a Trove to cause an ETH issuance gain for stakers + await helpers.createDsaTrove(dsa, userWallet, liquity); + + // Redeem some ETH to cause an LUSD redemption gain for stakers + await helpers.redeem( + ethers.utils.parseUnits("1000", 18), + contracts.STABILITY_POOL_ADDRESS, + userWallet, + liquity + ); + + const ethGain = await liquity.staking.getPendingETHGain( + stakerDsa.address + ); + const lusdGain = await liquity.staking.getPendingLUSDGain( + stakerDsa.address + ); + const lusdBalanceBefore = await liquity.lusdToken.balanceOf( + userWallet.address + ); + const ethBalanceBefore = await ethers.provider.getBalance( + userWallet.address + ); + const ethGainId = 111; + const lusdGainId = 222; + + const claimStakingGainsSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "claimStakingGains", + args: [ethGainId, lusdGainId], + }; + + const withdrawEthSpell = { + connector: helpers.INSTADAPP_BASIC_V1_CONNECTOR, + method: "withdraw", + args: [helpers.ETH_ADDRESS, 0, userWallet.address, ethGainId, 0], + }; + + const withdrawLusdSpell = { + connector: helpers.INSTADAPP_BASIC_V1_CONNECTOR, + method: "withdraw", + args: [ + liquity.lusdToken.address, + 0, + userWallet.address, + lusdGainId, + 0, + ], + }; + + const spells = [ + claimStakingGainsSpell, + withdrawEthSpell, + withdrawLusdSpell, + ]; + + // Claim gains + await stakerDsa + .connect(userWallet) + .cast(...encodeSpells(spells), userWallet.address, { + gasPrice: 0, + }); + + const ethBalanceAfter = await ethers.provider.getBalance( + userWallet.address + ); + const lusdBalanceAfter = await liquity.lusdToken.balanceOf( + userWallet.address + ); + + expect( + ethBalanceAfter, + "User's ETH balance should have increased by the issuance gain from staking" + ).to.eq(ethBalanceBefore.add(ethGain)); + expect( + lusdBalanceAfter, + "User's LUSD balance should have increased by the redemption gain from staking" + ).to.eq(lusdBalanceBefore.add(lusdGain)); + }); + + it("returns Instadapp event name and data", async () => { + const stakerDsa = await buildDSAv2(userWallet.address); + const amount = ethers.utils.parseUnits("1000", 18); // 1000 LQTY + + // Stake lots of LQTY + await helpers.sendToken( + liquity.lqtyToken, + amount, + helpers.JUSTIN_SUN_ADDRESS, + stakerDsa.address + ); + const stakeSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "stake", + args: [amount, 0, 0, 0, 0], + }; + await stakerDsa + .connect(userWallet) + .cast(...encodeSpells([stakeSpell]), userWallet.address); + + // Open a Trove to cause an ETH issuance gain for stakers + await helpers.createDsaTrove(dsa, userWallet, liquity); + + // Redeem some ETH to cause an LUSD redemption gain for stakers + await helpers.redeem( + ethers.utils.parseUnits("1000", 18), + contracts.STABILITY_POOL_ADDRESS, + userWallet, + liquity + ); + + const setEthGainId = 0; + const setLusdGainId = 0; + const ethGain = await liquity.staking.getPendingETHGain( + stakerDsa.address + ); + const lusdGain = await liquity.staking.getPendingLUSDGain( + stakerDsa.address + ); + + const claimStakingGainsSpell = { + connector: helpers.LIQUITY_CONNECTOR, + method: "claimStakingGains", + args: [setEthGainId, setLusdGainId], + }; + + // Claim gains + const claimGainsTx = await stakerDsa + .connect(userWallet) + .cast( + ...encodeSpells([claimStakingGainsSpell]), + userWallet.address + ); + + const receipt = await claimGainsTx.wait(); + + const castLogEvent = receipt.events.find((e) => e.event === "LogCast") + .args; + const expectedEventParams = ethers.utils.defaultAbiCoder.encode( + ["address", "uint256", "uint256", "uint256", "uint256"], + [stakerDsa.address, ethGain, lusdGain, setEthGainId, setLusdGainId] + ); + expect(castLogEvent.eventNames[0]).eq( + "LogClaimStakingGains(address,uint256,uint256,uint256,uint256)" + ); + expect(castLogEvent.eventParams[0]).eq(expectedEventParams); + }); + }); + }); + }); +});