From f2d67d14caed7d72cb2b135dfab4f03f22df71f8 Mon Sep 17 00:00:00 2001 From: Alpha Guy Date: Mon, 30 Aug 2021 18:13:35 +0530 Subject: [PATCH] Add Debt Issuance Module V2 (#125) * Add AaveDebtIssuanceModule contract * Module designed to issue/redeem SetTokens which hold one or more aTokens as components. * Refactor integration tests * Moved validation logic to IssuanceUtils library * Fix compilation bugs * Fix imports * Refactor IssuanceUtils library contract * Add new function which considers external positions * Rename functions * Refactor DebtIssuanceModuleV2 contract * Override _resolveDebtPositions function * Use new IssuanceUtils library contract * Improve javadocs * Add IssuanceUtils mock contract * Refactor _setQuantity parameter in both library functions * Add standard token with rounding error mock * Returns a variable balance of value based on the error value set * Will be helpful to mock the behaviour of aTokens * Add tests for Debt issuance module V2 contract * Skip ALM <> DIM integration tests * Make validation logic statelesss * Earlier we were assuming that burn takes place before and mint takes place after the validation logic is called. But these assumptions make the validation logic stateful, which is not ideal. By removing those assumptions and passing in the required value from outside the function, the validation logic becomes stateless, which allows it to be used at multiple places flexibly. * The validation functions now perform just the check. All required state to perform the check is passed from outside. * Add external debt position to SetToken in tests * Improve coverage: Add tests for issue/redeem quantity is 0 * Rename IssuanceUtils to IssuanceValidationUtils * Update docs to specify redeem requires transferring in debt from caller * Add more tests and improve existing ones * Newly added tests are an EXACT copy of the tests for DebtIssuanceModule. Only difference is this SetToken contains tokenWithRoundingError instead of weth as a default position. This is to ensure the DebtIssuanceModuleV2 behaves exactly similar to DebtIssuanceModule when there is no rounding error present in it's constituent components. * Existing tests are improved by introducing fees and by different revert messages for different reverts * Add more tests and optimize tests to complete faster * Bump package to 0.0.52 --- .../StandardTokenWithRoundingErrorMock.sol | 180 ++++ .../lib/IssuanceValidationUtilsMock.sol | 58 ++ .../protocol/lib/IssuanceValidationUtils.sol | 93 ++ .../protocol/modules/DebtIssuanceModule.sol | 7 +- .../protocol/modules/DebtIssuanceModuleV2.sol | 276 ++++++ package.json | 2 +- .../aaveUniswapLeverageDebtIssuance.spec.ts | 18 +- .../modules/debtIssuanceModuleV2.spec.ts | 806 ++++++++++++++++++ utils/contracts/index.ts | 2 + utils/deploys/deployMocks.ts | 25 +- utils/deploys/deployModules.ts | 5 + 11 files changed, 1452 insertions(+), 20 deletions(-) create mode 100644 contracts/mocks/StandardTokenWithRoundingErrorMock.sol create mode 100644 contracts/mocks/protocol/lib/IssuanceValidationUtilsMock.sol create mode 100644 contracts/protocol/lib/IssuanceValidationUtils.sol create mode 100644 contracts/protocol/modules/DebtIssuanceModuleV2.sol create mode 100644 test/protocol/modules/debtIssuanceModuleV2.spec.ts diff --git a/contracts/mocks/StandardTokenWithRoundingErrorMock.sol b/contracts/mocks/StandardTokenWithRoundingErrorMock.sol new file mode 100644 index 000000000..9617287c8 --- /dev/null +++ b/contracts/mocks/StandardTokenWithRoundingErrorMock.sol @@ -0,0 +1,180 @@ +/* + Copyright 2020 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity 0.6.10; + +import "./StandardTokenMock.sol"; +import "@openzeppelin/contracts/math/SignedSafeMath.sol"; +import "@openzeppelin/contracts/math/SafeMath.sol"; +import "@openzeppelin/contracts/utils/SafeCast.sol"; + +contract StandardTokenWithRoundingErrorMock { + using SignedSafeMath for int256; + using SafeCast for int256; + using SafeMath for uint256; + event Transfer( + address indexed from, + address indexed to, + uint256 value + ); + + event Approval( + address indexed owner, + address indexed spender, + uint256 value + ); + + uint256 constant public decimals = 18; + string public name; + string public symbol; + int256 public err; + + mapping (address => uint256) public _balances; + + mapping (address => mapping (address => uint256)) public _allowed; + + uint256 public _totalSupply; + + + constructor( + address _initialAccount, + uint256 _initialBalance, + int256 _err, + string memory _name, + string memory _symbol, + uint8 _decimals + ) + public + + { + _balances[_initialAccount] = _initialBalance; + _totalSupply = _initialBalance; + name = _name; + symbol = _symbol; + err = _err; + } + + /** + * @dev Returns balance of owner with the rounding error applied + * @param owner address whose balance is to be returned + */ + function balanceOf(address owner) external view returns (uint256) { + uint256 balance = _balances[owner]; + if (err >= 0) { + return balance.add(err.toUint256()); + } else { + uint256 absoluteError = err.mul(-1).toUint256(); + if (balance >= absoluteError) { + return balance.sub(absoluteError); + } else { + return 0; + } + } + } + + /** + * @dev Transfer tokens from one address to another + * @param _from address The address which you want to send tokens from + * @param _to address The address which you want to transfer to + * @param _value uint256 the amount of tokens to be transferred + */ + function transferFrom(address _from, address _to, uint256 _value) external returns (bool) { + require(_to != address(0), "to null"); + require(_value <= _balances[_from], "value greater than from balance"); + require(_value <= _allowed[_from][msg.sender], "value greater than allowed"); + + _balances[_from] = _balances[_from].sub(_value); + _balances[_to] = _balances[_to].add(_value); + _allowed[_from][msg.sender] = _allowed[_from][msg.sender].sub(_value); + emit Transfer(_from, _to, _value); + return true; + } + + /** + * @dev Transfer tokens from one address to another + * @param _to The address to transfer to. + * @param _value The amount to be transferred. + */ + function transfer(address _to, uint256 _value) external returns (bool) { + require(_to != address(0), "to null"); + require(_value <= _balances[msg.sender], "value greater than sender balance"); + + _balances[msg.sender] = _balances[msg.sender].sub(_value); + _balances[_to] = _balances[_to].add(_value); + emit Transfer(msg.sender, _to, _value); + return true; + } + + function setError(int256 _err) external returns (bool) { + err = _err; + return true; + } + + function totalSupply() external view returns (uint256) { + return _totalSupply; + } + + function allowance( + address owner, + address spender + ) + external + view + returns (uint256) + { + return _allowed[owner][spender]; + } + + function approve(address spender, uint256 value) external returns (bool) { + require(spender != address(0)); + + _allowed[msg.sender][spender] = value; + emit Approval(msg.sender, spender, value); + return true; + } + + function increaseAllowance( + address spender, + uint256 addedValue + ) + external + returns (bool) + { + require(spender != address(0)); + + _allowed[msg.sender][spender] = ( + _allowed[msg.sender][spender].add(addedValue)); + emit Approval(msg.sender, spender, _allowed[msg.sender][spender]); + return true; + } + + function decreaseAllowance( + address spender, + uint256 subtractedValue + ) + external + returns (bool) + { + require(spender != address(0)); + + _allowed[msg.sender][spender] = ( + _allowed[msg.sender][spender].sub(subtractedValue)); + emit Approval(msg.sender, spender, _allowed[msg.sender][spender]); + return true; + } +} \ No newline at end of file diff --git a/contracts/mocks/protocol/lib/IssuanceValidationUtilsMock.sol b/contracts/mocks/protocol/lib/IssuanceValidationUtilsMock.sol new file mode 100644 index 000000000..30e46db47 --- /dev/null +++ b/contracts/mocks/protocol/lib/IssuanceValidationUtilsMock.sol @@ -0,0 +1,58 @@ +/* + Copyright 2021 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity 0.6.10; + +import { ISetToken } from "../../../interfaces/ISetToken.sol"; +import { IssuanceValidationUtils } from "../../../protocol/lib/IssuanceValidationUtils.sol"; + +contract IssuanceValidationUtilsMock { + /* ============ External Functions ============ */ + + function testValidateCollateralizationPostTransferInPreHook( + ISetToken _setToken, + address _component, + uint256 _initialSetSupply, + uint256 _componentQuantity + ) + external + view + { + IssuanceValidationUtils.validateCollateralizationPostTransferInPreHook( + _setToken, + _component, + _initialSetSupply, + _componentQuantity + ); + } + + function testValidateCollateralizationPostTransferOut( + ISetToken _setToken, + address _component, + uint256 _finalSetSupply + ) + external + view + { + IssuanceValidationUtils.validateCollateralizationPostTransferOut( + _setToken, + _component, + _finalSetSupply + ); + } +} \ No newline at end of file diff --git a/contracts/protocol/lib/IssuanceValidationUtils.sol b/contracts/protocol/lib/IssuanceValidationUtils.sol new file mode 100644 index 000000000..0333a908a --- /dev/null +++ b/contracts/protocol/lib/IssuanceValidationUtils.sol @@ -0,0 +1,93 @@ +/* + Copyright 2021 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity 0.6.10; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/SafeCast.sol"; +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; + +import { ISetToken } from "../../interfaces/ISetToken.sol"; +import { PreciseUnitMath } from "../../lib/PreciseUnitMath.sol"; + +/** + * @title IssuanceValidationUtils + * @author Set Protocol + * + * A collection of utility functions to help during issuance/redemption of SetToken. + */ +library IssuanceValidationUtils { + using SafeMath for uint256; + using SafeCast for int256; + using PreciseUnitMath for uint256; + + /** + * Validates component transfer IN to SetToken during issuance/redemption. Reverts if Set is undercollateralized post transfer. + * NOTE: Call this function immediately after transfer IN but before calling external hooks (if any). + * + * @param _setToken Instance of the SetToken being issued/redeemed + * @param _component Address of component being transferred in/out + * @param _initialSetSupply Initial SetToken supply before issuance/redemption + * @param _componentQuantity Amount of component transferred into SetToken + */ + function validateCollateralizationPostTransferInPreHook( + ISetToken _setToken, + address _component, + uint256 _initialSetSupply, + uint256 _componentQuantity + ) + internal + view + { + uint256 newComponentBalance = IERC20(_component).balanceOf(address(_setToken)); + + uint256 defaultPositionUnit = _setToken.getDefaultPositionRealUnit(address(_component)).toUint256(); + + require( + // Use preciseMulCeil to increase the lower bound and maintain over-collateralization + newComponentBalance >= _initialSetSupply.preciseMulCeil(defaultPositionUnit).add(_componentQuantity), + "Invalid transfer in. Results in undercollateralization" + ); + } + + /** + * Validates component transfer OUT of SetToken during issuance/redemption. Reverts if Set is undercollateralized post transfer. + * + * @param _setToken Instance of the SetToken being issued/redeemed + * @param _component Address of component being transferred in/out + * @param _finalSetSupply Final SetToken supply after issuance/redemption + */ + function validateCollateralizationPostTransferOut( + ISetToken _setToken, + address _component, + uint256 _finalSetSupply + ) + internal + view + { + uint256 newComponentBalance = IERC20(_component).balanceOf(address(_setToken)); + + uint256 defaultPositionUnit = _setToken.getDefaultPositionRealUnit(address(_component)).toUint256(); + + require( + // Use preciseMulCeil to increase lower bound and maintain over-collateralization + newComponentBalance >= _finalSetSupply.preciseMulCeil(defaultPositionUnit), + "Invalid transfer out. Results in undercollateralization" + ); + } +} \ No newline at end of file diff --git a/contracts/protocol/modules/DebtIssuanceModule.sol b/contracts/protocol/modules/DebtIssuanceModule.sol index 333dd3aa4..4e39fc86c 100644 --- a/contracts/protocol/modules/DebtIssuanceModule.sol +++ b/contracts/protocol/modules/DebtIssuanceModule.sol @@ -112,6 +112,7 @@ contract DebtIssuanceModule is ModuleBase, ReentrancyGuard { address _to ) external + virtual nonReentrant onlyValidAndInitializedSet(_setToken) { @@ -151,8 +152,9 @@ contract DebtIssuanceModule is ModuleBase, ReentrancyGuard { } /** - * Returns components from the SetToken, unwinds any external module component positions and burns - * the SetToken. If the token has a debt position all debt will be paid down first then equity positions + * Returns components from the SetToken, unwinds any external module component positions and burns the SetToken. + * If the token has debt positions, the module transfers in the required debt amounts from the caller and uses + * those funds to repay the debts on behalf of the SetToken. All debt will be paid down first then equity positions * will be returned to the minting address. If specified, a fee will be charged on redeem. * * @param _setToken Instance of the SetToken to redeem @@ -165,6 +167,7 @@ contract DebtIssuanceModule is ModuleBase, ReentrancyGuard { address _to ) external + virtual nonReentrant onlyValidAndInitializedSet(_setToken) { diff --git a/contracts/protocol/modules/DebtIssuanceModuleV2.sol b/contracts/protocol/modules/DebtIssuanceModuleV2.sol new file mode 100644 index 000000000..cd8564e20 --- /dev/null +++ b/contracts/protocol/modules/DebtIssuanceModuleV2.sol @@ -0,0 +1,276 @@ +/* + Copyright 2021 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity 0.6.10; +pragma experimental "ABIEncoderV2"; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/SafeERC20.sol"; + +import { DebtIssuanceModule } from "./DebtIssuanceModule.sol"; +import { IController } from "../../interfaces/IController.sol"; +import { Invoke } from "../lib/Invoke.sol"; +import { ISetToken } from "../../interfaces/ISetToken.sol"; +import { IssuanceValidationUtils } from "../lib/IssuanceValidationUtils.sol"; + +/** + * @title DebtIssuanceModuleV2 + * @author Set Protocol + * + * The DebtIssuanceModuleV2 is a module that enables users to issue and redeem SetTokens that contain default and all + * external positions, including debt positions. Module hooks are added to allow for syncing of positions, and component + * level hooks are added to ensure positions are replicated correctly. The manager can define arbitrary issuance logic + * in the manager hook, as well as specify issue and redeem fees. + * + * NOTE: + * DebtIssuanceModule contract confirmed increase/decrease in balance of component held by the SetToken after every transfer in/out + * for each component during issuance/redemption. This contract replaces those strict checks with slightly looser checks which + * ensure that the SetToken remains collateralized after every transfer in/out for each component during issuance/redemption. + * This module should be used to issue/redeem SetToken whose one or more components return a balance value with +/-1 wei error. + * For example, this module can be used to issue/redeem SetTokens which has one or more aTokens as its components. + */ +contract DebtIssuanceModuleV2 is DebtIssuanceModule { + + /* ============ Constructor ============ */ + + constructor(IController _controller) public DebtIssuanceModule(_controller) {} + + /* ============ External Functions ============ */ + + /** + * Deposits components to the SetToken, replicates any external module component positions and mints + * the SetToken. If the token has a debt position all collateral will be transferred in first then debt + * will be returned to the minting address. If specified, a fee will be charged on issuance. + * + * NOTE: Overrides DebtIssuanceModule#issue external function and adds undercollateralization checks in place of the + * previous default strict balances checks. The undercollateralization checks are implemented in IssuanceValidationUtils library and they + * revert upon undercollateralization of the SetToken post component transfer. + * + * @param _setToken Instance of the SetToken to issue + * @param _quantity Quantity of SetToken to issue + * @param _to Address to mint SetToken to + */ + function issue( + ISetToken _setToken, + uint256 _quantity, + address _to + ) + external + override + nonReentrant + onlyValidAndInitializedSet(_setToken) + { + require(_quantity > 0, "Issue quantity must be > 0"); + + address hookContract = _callManagerPreIssueHooks(_setToken, _quantity, msg.sender, _to); + + _callModulePreIssueHooks(_setToken, _quantity); + + + uint256 initialSetSupply = _setToken.totalSupply(); + + ( + uint256 quantityWithFees, + uint256 managerFee, + uint256 protocolFee + ) = calculateTotalFees(_setToken, _quantity, true); + + // Prevent stack too deep + { + ( + address[] memory components, + uint256[] memory equityUnits, + uint256[] memory debtUnits + ) = _calculateRequiredComponentIssuanceUnits(_setToken, quantityWithFees, true); + + uint256 finalSetSupply = initialSetSupply.add(quantityWithFees); + + _resolveEquityPositions(_setToken, quantityWithFees, _to, true, components, equityUnits, initialSetSupply, finalSetSupply); + _resolveDebtPositions(_setToken, quantityWithFees, true, components, debtUnits, initialSetSupply, finalSetSupply); + _resolveFees(_setToken, managerFee, protocolFee); + } + + _setToken.mint(_to, _quantity); + + emit SetTokenIssued( + _setToken, + msg.sender, + _to, + hookContract, + _quantity, + managerFee, + protocolFee + ); + } + + /** + * Returns components from the SetToken, unwinds any external module component positions and burns the SetToken. + * If the token has debt positions, the module transfers in the required debt amounts from the caller and uses + * those funds to repay the debts on behalf of the SetToken. All debt will be paid down first then equity positions + * will be returned to the minting address. If specified, a fee will be charged on redeem. + * + * NOTE: Overrides DebtIssuanceModule#redeem internal function and adds undercollateralization checks in place of the + * previous default strict balances checks. The undercollateralization checks are implemented in IssuanceValidationUtils library + * and they revert upon undercollateralization of the SetToken post component transfer. + * + * @param _setToken Instance of the SetToken to redeem + * @param _quantity Quantity of SetToken to redeem + * @param _to Address to send collateral to + */ + function redeem( + ISetToken _setToken, + uint256 _quantity, + address _to + ) + external + override + nonReentrant + onlyValidAndInitializedSet(_setToken) + { + require(_quantity > 0, "Redeem quantity must be > 0"); + + _callModulePreRedeemHooks(_setToken, _quantity); + + uint256 initialSetSupply = _setToken.totalSupply(); + + // Place burn after pre-redeem hooks because burning tokens may lead to false accounting of synced positions + _setToken.burn(msg.sender, _quantity); + + ( + uint256 quantityNetFees, + uint256 managerFee, + uint256 protocolFee + ) = calculateTotalFees(_setToken, _quantity, false); + + // Prevent stack too deep + { + ( + address[] memory components, + uint256[] memory equityUnits, + uint256[] memory debtUnits + ) = _calculateRequiredComponentIssuanceUnits(_setToken, quantityNetFees, false); + + uint256 finalSetSupply = initialSetSupply.sub(quantityNetFees); + + _resolveDebtPositions(_setToken, quantityNetFees, false, components, debtUnits, initialSetSupply, finalSetSupply); + _resolveEquityPositions(_setToken, quantityNetFees, _to, false, components, equityUnits, initialSetSupply, finalSetSupply); + _resolveFees(_setToken, managerFee, protocolFee); + } + + emit SetTokenRedeemed( + _setToken, + msg.sender, + _to, + _quantity, + managerFee, + protocolFee + ); + } + + /* ============ Internal Functions ============ */ + + /** + * Resolve equity positions associated with SetToken. On issuance, the total equity position for an asset (including default and external + * positions) is transferred in. Then any external position hooks are called to transfer the external positions to their necessary place. + * On redemption all external positions are recalled by the external position hook, then those position plus any default position are + * transferred back to the _to address. + */ + function _resolveEquityPositions( + ISetToken _setToken, + uint256 _quantity, + address _to, + bool _isIssue, + address[] memory _components, + uint256[] memory _componentEquityQuantities, + uint256 _initialSetSupply, + uint256 _finalSetSupply + ) + internal + { + for (uint256 i = 0; i < _components.length; i++) { + address component = _components[i]; + uint256 componentQuantity = _componentEquityQuantities[i]; + if (componentQuantity > 0) { + if (_isIssue) { + // Call SafeERC20#safeTransferFrom instead of ExplicitERC20#transferFrom + SafeERC20.safeTransferFrom( + IERC20(component), + msg.sender, + address(_setToken), + componentQuantity + ); + + IssuanceValidationUtils.validateCollateralizationPostTransferInPreHook(_setToken, component, _initialSetSupply, componentQuantity); + + _executeExternalPositionHooks(_setToken, _quantity, IERC20(component), true, true); + } else { + _executeExternalPositionHooks(_setToken, _quantity, IERC20(component), false, true); + + // Call Invoke#invokeTransfer instead of Invoke#strictInvokeTransfer + _setToken.invokeTransfer(component, _to, componentQuantity); + + IssuanceValidationUtils.validateCollateralizationPostTransferOut(_setToken, component, _finalSetSupply); + } + } + } + } + + /** + * Resolve debt positions associated with SetToken. On issuance, debt positions are entered into by calling the external position hook. The + * resulting debt is then returned to the calling address. On redemption, the module transfers in the required debt amount from the caller + * and uses those funds to repay the debt on behalf of the SetToken. + */ + function _resolveDebtPositions( + ISetToken _setToken, + uint256 _quantity, + bool _isIssue, + address[] memory _components, + uint256[] memory _componentDebtQuantities, + uint256 _initialSetSupply, + uint256 _finalSetSupply + ) + internal + { + for (uint256 i = 0; i < _components.length; i++) { + address component = _components[i]; + uint256 componentQuantity = _componentDebtQuantities[i]; + if (componentQuantity > 0) { + if (_isIssue) { + _executeExternalPositionHooks(_setToken, _quantity, IERC20(component), true, false); + + // Call Invoke#invokeTransfer instead of Invoke#strictInvokeTransfer + _setToken.invokeTransfer(component, msg.sender, componentQuantity); + + IssuanceValidationUtils.validateCollateralizationPostTransferOut(_setToken, component, _finalSetSupply); + } else { + // Call SafeERC20#safeTransferFrom instead of ExplicitERC20#transferFrom + SafeERC20.safeTransferFrom( + IERC20(component), + msg.sender, + address(_setToken), + componentQuantity + ); + + IssuanceValidationUtils.validateCollateralizationPostTransferInPreHook(_setToken, component, _initialSetSupply, componentQuantity); + + _executeExternalPositionHooks(_setToken, _quantity, IERC20(component), false, false); + } + } + } + } +} \ No newline at end of file diff --git a/package.json b/package.json index 55b8d2ddc..abf367736 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@setprotocol/set-protocol-v2", - "version": "0.0.51", + "version": "0.0.52", "description": "", "main": "dist", "types": "dist/types", diff --git a/test/integration/aaveUniswapLeverageDebtIssuance.spec.ts b/test/integration/aaveUniswapLeverageDebtIssuance.spec.ts index 9fa333cf4..a4450c2fa 100644 --- a/test/integration/aaveUniswapLeverageDebtIssuance.spec.ts +++ b/test/integration/aaveUniswapLeverageDebtIssuance.spec.ts @@ -5,7 +5,7 @@ import { Account } from "@utils/test/types"; import { AaveV2, AaveLeverageModule, - DebtIssuanceModule, + DebtIssuanceModuleV2, SetToken, UniswapV2ExchangeAdapter, } from "@utils/contracts"; @@ -37,9 +37,6 @@ import { ADDRESS_ZERO, ZERO, EMPTY_BYTES, MAX_UINT_256 } from "@utils/constants" const expect = getWaffleExpect(); -// TODO: The following tests have been skipped due to inconsistent test results as they often revert with "Invalid post transfer balance" -// in ExplicitERC20#transferFrom function. It might be because aToken interest accrual depends upon block.timestamp and time difference -// between subsequent invocations of the Aave protocol. It is being further investigated and will be fixed in a different PR. describe.skip("AaveUniswapLeverageDebtIssuance", () => { let owner: Account; let feeRecipient: Account; @@ -50,7 +47,7 @@ describe.skip("AaveUniswapLeverageDebtIssuance", () => { let aaveV2Library: AaveV2; let aaveLeverageModule: AaveLeverageModule; - let debtIssuanceModule: DebtIssuanceModule; + let debtIssuanceModule: DebtIssuanceModuleV2; let uniswapExchangeAdapter: UniswapV2ExchangeAdapter; let aWETH: AaveV2AToken; @@ -144,7 +141,7 @@ describe.skip("AaveUniswapLeverageDebtIssuance", () => { MAX_UINT_256 ); - debtIssuanceModule = await deployer.modules.deployDebtIssuanceModule(setup.controller.address); + debtIssuanceModule = await deployer.modules.deployDebtIssuanceModuleV2(setup.controller.address); await setup.controller.addModule(debtIssuanceModule.address); aaveV2Library = await deployer.libraries.deployAaveV2(); @@ -487,9 +484,6 @@ describe.skip("AaveUniswapLeverageDebtIssuance", () => { // ETH increases to $1000 to allow more borrow await aaveV2Setup.setAssetPriceInOracle(setup.usdc.address, ether(0.001)); // 1/1000 = .001 - - // TODO: Test Increase time - // await increaseTimeAsync(BigNumber.from(86400)); }); beforeEach(() => { @@ -542,7 +536,7 @@ describe.skip("AaveUniswapLeverageDebtIssuance", () => { expect(currentPositions.length).to.eq(2); expect(newSecondPosition.component).to.eq(setup.usdc.address); expect(newSecondPosition.positionState).to.eq(1); // External - expect(newSecondPosition.unit).to.eq(expectedSecondPositionUnit); + expect(newSecondPosition.unit.abs()).to.gte(expectedSecondPositionUnit.abs()); // Debt accrues expect(newSecondPosition.module).to.eq(aaveLeverageModule.address); }); @@ -687,7 +681,7 @@ describe.skip("AaveUniswapLeverageDebtIssuance", () => { expect(newThirdPosition.component).to.eq(setup.dai.address); expect(newThirdPosition.positionState).to.eq(1); // External - expect(newThirdPosition.unit).to.eq(expectedThirdPositionUnit); + expect(newThirdPosition.unit.abs()).to.gte(expectedThirdPositionUnit.abs()); // Debt accrues expect(newThirdPosition.module).to.eq(aaveLeverageModule.address); expect(newFourthPosition.component).to.eq(setup.usdc.address); @@ -1225,7 +1219,7 @@ describe.skip("AaveUniswapLeverageDebtIssuance", () => { expect(currentPositions.length).to.eq(2); expect(newSecondPosition.component).to.eq(setup.usdc.address); expect(newSecondPosition.positionState).to.eq(1); // External - expect(newSecondPositionNotional).to.eq(previousSecondPositionBalance); + expect(newSecondPositionNotional).to.gte(previousSecondPositionBalance); expect(newSecondPosition.module).to.eq(aaveLeverageModule.address); }); diff --git a/test/protocol/modules/debtIssuanceModuleV2.spec.ts b/test/protocol/modules/debtIssuanceModuleV2.spec.ts new file mode 100644 index 000000000..d69e86adf --- /dev/null +++ b/test/protocol/modules/debtIssuanceModuleV2.spec.ts @@ -0,0 +1,806 @@ +import "module-alias/register"; + +import { BigNumber } from "@ethersproject/bignumber"; + +import { Address } from "@utils/types"; +import { Account } from "@utils/test/types"; +import { ZERO, ONE, ADDRESS_ZERO } from "@utils/constants"; +import { + DebtIssuanceModuleV2, + DebtModuleMock, + ManagerIssuanceHookMock, + ModuleIssuanceHookMock, + SetToken, + StandardTokenWithRoundingErrorMock +} from "@utils/contracts"; +import DeployHelper from "@utils/deploys"; +import { + ether, + preciseMul, + preciseMulCeil, +} from "@utils/index"; +import { + addSnapshotBeforeRestoreAfterEach, + getAccounts, + getSystemFixture, + getWaffleExpect, +} from "@utils/test/index"; +import { SystemFixture } from "@utils/fixtures"; +import { ContractTransaction } from "ethers"; + +const expect = getWaffleExpect(); + +describe("DebtIssuanceModuleV2", () => { + let owner: Account; + let manager: Account; + let feeRecipient: Account; + let dummyModule: Account; + let recipient: Account; + let deployer: DeployHelper; + let setup: SystemFixture; + + let debtModule: DebtModuleMock; + let externalPositionModule: ModuleIssuanceHookMock; + let debtIssuance: DebtIssuanceModuleV2; + let issuanceHook: ManagerIssuanceHookMock; + let setToken: SetToken; + let tokenWithRoundingError: StandardTokenWithRoundingErrorMock; + + before(async () => { + [ + owner, + manager, + feeRecipient, + dummyModule, // Set as protocol fee recipient + recipient, + ] = await getAccounts(); + + deployer = new DeployHelper(owner.wallet); + setup = getSystemFixture(owner.address); + + await setup.initialize(); + + tokenWithRoundingError = await deployer.mocks.deployTokenWithErrorMock(owner.address, ether(1000000), ZERO); + debtIssuance = await deployer.modules.deployDebtIssuanceModuleV2(setup.controller.address); + debtModule = await deployer.mocks.deployDebtModuleMock(setup.controller.address, debtIssuance.address); + externalPositionModule = await deployer.mocks.deployModuleIssuanceHookMock(); + issuanceHook = await deployer.mocks.deployManagerIssuanceHookMock(); + + await setup.controller.addModule(debtIssuance.address); + await setup.controller.addModule(debtModule.address); + await setup.controller.addModule(externalPositionModule.address); + + setToken = await setup.createSetToken( + [tokenWithRoundingError.address], + [ether(1)], + [setup.issuanceModule.address, debtIssuance.address, debtModule.address, externalPositionModule.address], + manager.address, + "DebtToken", + "DBT" + ); + + await externalPositionModule.initialize(setToken.address); + }); + + addSnapshotBeforeRestoreAfterEach(); + + context("when DebtIssuanceModuleV2 is initialized", async () => { + let preIssueHook: Address; + let maxFee: BigNumber; + let issueFee: BigNumber; + let redeemFee: BigNumber; + + before(async () => { + await tokenWithRoundingError.setError(ZERO); + + preIssueHook = ADDRESS_ZERO; + maxFee = ether(0.02); + issueFee = ether(0.005); + redeemFee = ether(0.005); + }); + + beforeEach(async () => { + await debtIssuance.connect(manager.wallet).initialize( + setToken.address, + maxFee, + issueFee, + redeemFee, + feeRecipient.address, + preIssueHook + ); + await debtModule.connect(manager.wallet).initialize(setToken.address); + }); + + + context("when SetToken components do not have any rounding error", async () => { + // Note: Tests below are an EXACT copy of the tests for DebtIssuanceModule. Only difference is this SetToken contains + // tokenWithRoundingError instead of weth as a default position. This is to ensure the DebtIssuanceModuleV2 behaves + // exactly similar to DebtIssuanceModule when there is no rounding error present in it's constituent components. + + describe("#issue", async () => { + let subjectSetToken: Address; + let subjectQuantity: BigNumber; + let subjectTo: Address; + let subjectCaller: Account; + + const debtUnits: BigNumber = ether(100); + + beforeEach(async () => { + await debtModule.addDebt(setToken.address, setup.dai.address, debtUnits); + await setup.dai.transfer(debtModule.address, ether(100.5)); + + const [, equityFlows, ] = await debtIssuance.getRequiredComponentIssuanceUnits(setToken.address, ether(1)); + await tokenWithRoundingError.approve(debtIssuance.address, equityFlows[0].mul(ether(1.005))); + + subjectSetToken = setToken.address; + subjectQuantity = ether(1); + subjectTo = recipient.address; + subjectCaller = owner; + }); + + async function subject(): Promise { + return debtIssuance.connect(subjectCaller.wallet).issue( + subjectSetToken, + subjectQuantity, + subjectTo, + ); + } + + it("should mint SetTokens to the correct addresses", async () => { + await subject(); + + const feeQuantity = preciseMulCeil(subjectQuantity, issueFee); + const managerBalance = await setToken.balanceOf(feeRecipient.address); + const toBalance = await setToken.balanceOf(subjectTo); + + expect(toBalance).to.eq(subjectQuantity); + expect(managerBalance).to.eq(feeQuantity); + }); + + it("should have the correct token balances", async () => { + const preMinterWethBalance = await tokenWithRoundingError.balanceOf(subjectCaller.address); + const preSetWethBalance = await tokenWithRoundingError.balanceOf(subjectSetToken); + const preMinterDaiBalance = await setup.dai.balanceOf(subjectCaller.address); + const preSetDaiBalance = await setup.dai.balanceOf(subjectSetToken); + const preExternalDaiBalance = await setup.dai.balanceOf(debtModule.address); + + await subject(); + + const mintQuantity = preciseMul(subjectQuantity, ether(1).add(issueFee)); + const daiFlows = preciseMulCeil(mintQuantity, debtUnits); + const wethFlows = preciseMul(mintQuantity, ether(1)); + + const postMinterWethBalance = await tokenWithRoundingError.balanceOf(subjectCaller.address); + const postSetWethBalance = await tokenWithRoundingError.balanceOf(subjectSetToken); + const postMinterDaiBalance = await setup.dai.balanceOf(subjectCaller.address); + const postSetDaiBalance = await setup.dai.balanceOf(subjectSetToken); + const postExternalDaiBalance = await setup.dai.balanceOf(debtModule.address); + + expect(postMinterWethBalance).to.eq(preMinterWethBalance.sub(wethFlows)); + expect(postSetWethBalance).to.eq(preSetWethBalance.add(wethFlows)); + expect(postMinterDaiBalance).to.eq(preMinterDaiBalance.add(daiFlows)); + expect(postSetDaiBalance).to.eq(preSetDaiBalance); + expect(postExternalDaiBalance).to.eq(preExternalDaiBalance.sub(daiFlows)); + }); + + it("should have called the module issue hook", async () => { + await subject(); + + const hookCalled = await debtModule.moduleIssueHookCalled(); + + expect(hookCalled).to.be.true; + }); + + it("should emit the correct SetTokenIssued event", async () => { + const feeQuantity = preciseMulCeil(subjectQuantity, issueFee); + + await expect(subject()).to.emit(debtIssuance, "SetTokenIssued").withArgs( + setToken.address, + subjectCaller.address, + subjectTo, + preIssueHook, + subjectQuantity, + feeQuantity, + ZERO + ); + }); + + describe("when an external equity position is in place", async () => { + const externalUnits: BigNumber = ether(1); + + before(async () => { + await externalPositionModule.addExternalPosition(setToken.address, tokenWithRoundingError.address, externalUnits); + }); + + after(async () => { + await externalPositionModule.addExternalPosition(setToken.address, tokenWithRoundingError.address, ZERO); + }); + + it("should have the correct token balances", async () => { + const preMinterWethBalance = await tokenWithRoundingError.balanceOf(subjectCaller.address); + const preSetWethBalance = await tokenWithRoundingError.balanceOf(subjectSetToken); + const preExternalWethBalance = await tokenWithRoundingError.balanceOf(externalPositionModule.address); + const preMinterDaiBalance = await setup.dai.balanceOf(subjectCaller.address); + const preSetDaiBalance = await setup.dai.balanceOf(subjectSetToken); + const preExternalDaiBalance = await setup.dai.balanceOf(debtModule.address); + + await subject(); + + const mintQuantity = preciseMul(subjectQuantity, ether(1).add(issueFee)); + const daiFlows = preciseMulCeil(mintQuantity, debtUnits); + const wethDefaultFlows = preciseMul(mintQuantity, ether(1)); + const wethExternalFlows = preciseMul(mintQuantity, externalUnits); + + const postMinterWethBalance = await tokenWithRoundingError.balanceOf(subjectCaller.address); + const postSetWethBalance = await tokenWithRoundingError.balanceOf(subjectSetToken); + const postExternalWethBalance = await tokenWithRoundingError.balanceOf(externalPositionModule.address); + const postMinterDaiBalance = await setup.dai.balanceOf(subjectCaller.address); + const postSetDaiBalance = await setup.dai.balanceOf(subjectSetToken); + const postExternalDaiBalance = await setup.dai.balanceOf(debtModule.address); + + expect(postMinterWethBalance).to.eq(preMinterWethBalance.sub(wethDefaultFlows.add(wethExternalFlows))); + expect(postSetWethBalance).to.eq(preSetWethBalance.add(wethDefaultFlows)); + expect(postExternalWethBalance).to.eq(preExternalWethBalance.add(wethExternalFlows)); + expect(postMinterDaiBalance).to.eq(preMinterDaiBalance.add(daiFlows)); + expect(postSetDaiBalance).to.eq(preSetDaiBalance); + expect(postExternalDaiBalance).to.eq(preExternalDaiBalance.sub(daiFlows)); + }); + }); + + describe("when the manager issuance fee is 0", async () => { + before(async () => { + issueFee = ZERO; + }); + + after(async () => { + issueFee = ether(0.005); + }); + + it("should mint SetTokens to the correct addresses", async () => { + await subject(); + + const toBalance = await setToken.balanceOf(subjectTo); + + expect(toBalance).to.eq(subjectQuantity); + }); + + it("should have the correct token balances", async () => { + const preMinterWethBalance = await tokenWithRoundingError.balanceOf(subjectCaller.address); + const preSetWethBalance = await tokenWithRoundingError.balanceOf(subjectSetToken); + const preMinterDaiBalance = await setup.dai.balanceOf(subjectCaller.address); + const preSetDaiBalance = await setup.dai.balanceOf(subjectSetToken); + const preExternalDaiBalance = await setup.dai.balanceOf(debtModule.address); + + await subject(); + + const mintQuantity = preciseMul(subjectQuantity, ether(1).add(issueFee)); + const daiFlows = preciseMulCeil(mintQuantity, debtUnits); + const wethDefaultFlows = preciseMul(mintQuantity, ether(1)); + + const postMinterWethBalance = await tokenWithRoundingError.balanceOf(subjectCaller.address); + const postSetWethBalance = await tokenWithRoundingError.balanceOf(subjectSetToken); + const postMinterDaiBalance = await setup.dai.balanceOf(subjectCaller.address); + const postSetDaiBalance = await setup.dai.balanceOf(subjectSetToken); + const postExternalDaiBalance = await setup.dai.balanceOf(debtModule.address); + + expect(postMinterWethBalance).to.eq(preMinterWethBalance.sub(wethDefaultFlows)); + expect(postSetWethBalance).to.eq(preSetWethBalance.add(wethDefaultFlows)); + expect(postMinterDaiBalance).to.eq(preMinterDaiBalance.add(daiFlows)); + expect(postSetDaiBalance).to.eq(preSetDaiBalance); + expect(postExternalDaiBalance).to.eq(preExternalDaiBalance.sub(daiFlows)); + }); + }); + + describe("when protocol fees are enabled", async () => { + const protocolFee: BigNumber = ether(.2); + + beforeEach(async () => { + await setup.controller.addFee(debtIssuance.address, ZERO, protocolFee); + }); + + it("should mint SetTokens to the correct addresses", async () => { + await subject(); + + const feeQuantity = preciseMulCeil(subjectQuantity, issueFee); + const protocolSplit = preciseMul(feeQuantity, protocolFee); + + const managerBalance = await setToken.balanceOf(feeRecipient.address); + const protocolBalance = await setToken.balanceOf(dummyModule.address); // DummyModule is set as address in fixture setup + const toBalance = await setToken.balanceOf(subjectTo); + + expect(toBalance).to.eq(subjectQuantity); + expect(managerBalance).to.eq(feeQuantity.sub(protocolSplit)); + expect(protocolBalance).to.eq(protocolSplit); + }); + }); + + describe("when manager issuance hook is defined", async () => { + before(async () => { + preIssueHook = issuanceHook.address; + }); + + after(async () => { + preIssueHook = ADDRESS_ZERO; + }); + + it("should call the issuance hook", async () => { + await subject(); + + const setToken = await issuanceHook.retrievedSetToken(); + + expect(setToken).to.eq(subjectSetToken); + }); + }); + + describe("when the issue quantity is 0", async () => { + beforeEach(async () => { + subjectQuantity = ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Issue quantity must be > 0"); + }); + }); + + describe("when the SetToken is not enabled on the controller", async () => { + beforeEach(async () => { + const nonEnabledSetToken = await setup.createNonControllerEnabledSetToken( + [tokenWithRoundingError.address], + [ether(1)], + [debtIssuance.address] + ); + + subjectSetToken = nonEnabledSetToken.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be a valid and initialized SetToken"); + }); + }); + }); + + describe("#redeem", async () => { + let subjectSetToken: Address; + let subjectQuantity: BigNumber; + let subjectTo: Address; + let subjectCaller: Account; + + const debtUnits: BigNumber = ether(100); + + beforeEach(async () => { + await debtModule.addDebt(setToken.address, setup.dai.address, debtUnits); + await setup.dai.transfer(debtModule.address, ether(100.5)); + + const [, equityFlows, ] = await debtIssuance.getRequiredComponentRedemptionUnits(setToken.address, ether(1)); + await tokenWithRoundingError.approve(debtIssuance.address, equityFlows[0].mul(ether(1.005))); + + await debtIssuance.issue(setToken.address, ether(1), owner.address); + + await setup.dai.approve(debtIssuance.address, ether(100.5)); + + subjectSetToken = setToken.address; + subjectQuantity = ether(1); + subjectTo = recipient.address; + subjectCaller = owner; + }); + + async function subject(): Promise { + return debtIssuance.connect(subjectCaller.wallet).redeem( + subjectSetToken, + subjectQuantity, + subjectTo, + ); + } + + it("should mint SetTokens to the correct addresses", async () => { + const preManagerBalance = await setToken.balanceOf(feeRecipient.address); + const preCallerBalance = await setToken.balanceOf(subjectCaller.address); + + await subject(); + + const feeQuantity = preciseMulCeil(subjectQuantity, redeemFee); + const postManagerBalance = await setToken.balanceOf(feeRecipient.address); + const postCallerBalance = await setToken.balanceOf(subjectCaller.address); + + expect(postManagerBalance).to.eq(preManagerBalance.add(feeQuantity)); + expect(postCallerBalance).to.eq(preCallerBalance.sub(subjectQuantity)); + }); + + it("should have the correct token balances", async () => { + const preToWethBalance = await tokenWithRoundingError.balanceOf(subjectTo); + const preSetWethBalance = await tokenWithRoundingError.balanceOf(subjectSetToken); + const preRedeemerDaiBalance = await setup.dai.balanceOf(subjectCaller.address); + const preSetDaiBalance = await setup.dai.balanceOf(subjectSetToken); + const preExternalDaiBalance = await setup.dai.balanceOf(debtModule.address); + + await subject(); + + const redeemQuantity = preciseMul(subjectQuantity, ether(1).sub(redeemFee)); + const daiFlows = preciseMulCeil(redeemQuantity, debtUnits); + const wethFlows = preciseMul(redeemQuantity, ether(1)); + + const postToWethBalance = await tokenWithRoundingError.balanceOf(subjectTo); + const postSetWethBalance = await tokenWithRoundingError.balanceOf(subjectSetToken); + const postRedeemerDaiBalance = await setup.dai.balanceOf(subjectCaller.address); + const postSetDaiBalance = await setup.dai.balanceOf(subjectSetToken); + const postExternalDaiBalance = await setup.dai.balanceOf(debtModule.address); + + expect(postToWethBalance).to.eq(preToWethBalance.add(wethFlows)); + expect(postSetWethBalance).to.eq(preSetWethBalance.sub(wethFlows)); + expect(postRedeemerDaiBalance).to.eq(preRedeemerDaiBalance.sub(daiFlows)); + expect(postSetDaiBalance).to.eq(preSetDaiBalance); + expect(postExternalDaiBalance).to.eq(preExternalDaiBalance.add(daiFlows)); + }); + + it("should have called the module issue hook", async () => { + await subject(); + + const hookCalled = await debtModule.moduleRedeemHookCalled(); + + expect(hookCalled).to.be.true; + }); + + it("should emit the correct SetTokenRedeemed event", async () => { + const feeQuantity = preciseMulCeil(subjectQuantity, issueFee); + + await expect(subject()).to.emit(debtIssuance, "SetTokenRedeemed").withArgs( + setToken.address, + subjectCaller.address, + subjectTo, + subjectQuantity, + feeQuantity, + ZERO + ); + }); + + describe("when an external equity position is in place", async () => { + const externalUnits: BigNumber = ether(1); + + before(async () => { + await externalPositionModule.addExternalPosition(setToken.address, tokenWithRoundingError.address, externalUnits); + }); + + after(async () => { + await externalPositionModule.addExternalPosition(setToken.address, tokenWithRoundingError.address, ZERO); + }); + + it("should have the correct token balances", async () => { + const preToWethBalance = await tokenWithRoundingError.balanceOf(subjectTo); + const preSetWethBalance = await tokenWithRoundingError.balanceOf(subjectSetToken); + const preExternalWethBalance = await tokenWithRoundingError.balanceOf(externalPositionModule.address); + const preRedeemerDaiBalance = await setup.dai.balanceOf(subjectCaller.address); + const preSetDaiBalance = await setup.dai.balanceOf(subjectSetToken); + const preExternalDaiBalance = await setup.dai.balanceOf(debtModule.address); + + await subject(); + + const redeemQuantity = preciseMul(subjectQuantity, ether(1).sub(redeemFee)); + const daiFlows = preciseMulCeil(redeemQuantity, debtUnits); + const wethExternalFlows = preciseMul(redeemQuantity, externalUnits); + const wethDefaultFlows = preciseMul(redeemQuantity, ether(1)); + + const postToWethBalance = await tokenWithRoundingError.balanceOf(subjectTo); + const postSetWethBalance = await tokenWithRoundingError.balanceOf(subjectSetToken); + const postExternalWethBalance = await tokenWithRoundingError.balanceOf(externalPositionModule.address); + const postRedeemerDaiBalance = await setup.dai.balanceOf(subjectCaller.address); + const postSetDaiBalance = await setup.dai.balanceOf(subjectSetToken); + const postExternalDaiBalance = await setup.dai.balanceOf(debtModule.address); + + expect(postToWethBalance).to.eq(preToWethBalance.add(wethExternalFlows.add(wethDefaultFlows))); + expect(postSetWethBalance).to.eq(preSetWethBalance.sub(wethDefaultFlows)); + expect(postExternalWethBalance).to.eq(preExternalWethBalance.sub(wethExternalFlows)); + expect(postRedeemerDaiBalance).to.eq(preRedeemerDaiBalance.sub(daiFlows)); + expect(postSetDaiBalance).to.eq(preSetDaiBalance); + expect(postExternalDaiBalance).to.eq(preExternalDaiBalance.add(daiFlows)); + }); + }); + + describe("when the manager redemption fee is 0", async () => { + before(async () => { + redeemFee = ZERO; + }); + + after(async () => { + redeemFee = ether(0.005); + }); + + it("should mint SetTokens to the correct addresses", async () => { + await subject(); + + const toBalance = await setToken.balanceOf(subjectTo); + + expect(toBalance).to.eq(ZERO); + }); + + it("should have the correct token balances", async () => { + const preToWethBalance = await tokenWithRoundingError.balanceOf(subjectTo); + const preSetWethBalance = await tokenWithRoundingError.balanceOf(subjectSetToken); + const preRedeemerDaiBalance = await setup.dai.balanceOf(subjectCaller.address); + const preSetDaiBalance = await setup.dai.balanceOf(subjectSetToken); + const preExternalDaiBalance = await setup.dai.balanceOf(debtModule.address); + + await subject(); + + const redeemQuantity = preciseMul(subjectQuantity, ether(1).sub(redeemFee)); + const daiFlows = preciseMulCeil(redeemQuantity, debtUnits); + const wethFlows = preciseMul(redeemQuantity, ether(1)); + + const postToWethBalance = await tokenWithRoundingError.balanceOf(subjectTo); + const postSetWethBalance = await tokenWithRoundingError.balanceOf(subjectSetToken); + const postRedeemerDaiBalance = await setup.dai.balanceOf(subjectCaller.address); + const postSetDaiBalance = await setup.dai.balanceOf(subjectSetToken); + const postExternalDaiBalance = await setup.dai.balanceOf(debtModule.address); + + expect(postToWethBalance).to.eq(preToWethBalance.add(wethFlows)); + expect(postSetWethBalance).to.eq(preSetWethBalance.sub(wethFlows)); + expect(postRedeemerDaiBalance).to.eq(preRedeemerDaiBalance.sub(daiFlows)); + expect(postSetDaiBalance).to.eq(preSetDaiBalance); + expect(postExternalDaiBalance).to.eq(preExternalDaiBalance.add(daiFlows)); + }); + }); + + describe("when protocol fees are enabled", async () => { + const protocolFee: BigNumber = ether(.2); + + beforeEach(async () => { + await setup.controller.addFee(debtIssuance.address, ZERO, protocolFee); + }); + + it("should mint SetTokens to the correct addresses", async () => { + const preManagerBalance = await setToken.balanceOf(feeRecipient.address); + const preProtocolBalance = await setToken.balanceOf(dummyModule.address); + const preCallerBalance = await setToken.balanceOf(subjectCaller.address); + + await subject(); + + const feeQuantity = preciseMulCeil(subjectQuantity, redeemFee); + const protocolSplit = preciseMul(feeQuantity, protocolFee); + + const postManagerBalance = await setToken.balanceOf(feeRecipient.address); + const postProtocolBalance = await setToken.balanceOf(dummyModule.address); // DummyModule is set as address in fixture setup + const postCallerBalance = await setToken.balanceOf(subjectCaller.address); + + expect(postCallerBalance).to.eq(preCallerBalance.sub(subjectQuantity)); + expect(postManagerBalance).to.eq(preManagerBalance.add(feeQuantity.sub(protocolSplit))); + expect(postProtocolBalance).to.eq(preProtocolBalance.add(protocolSplit)); + }); + }); + + describe("when the issue quantity is 0", async () => { + beforeEach(async () => { + subjectQuantity = ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Redeem quantity must be > 0"); + }); + }); + + describe("when the SetToken is not enabled on the controller", async () => { + beforeEach(async () => { + const nonEnabledSetToken = await setup.createNonControllerEnabledSetToken( + [tokenWithRoundingError.address], + [ether(1)], + [debtIssuance.address] + ); + + subjectSetToken = nonEnabledSetToken.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be a valid and initialized SetToken"); + }); + }); + }); + }); + + context("when SetToken components do have rounding errors", async () => { + describe("#issue", async () => { + let subjectSetToken: Address; + let subjectQuantity: BigNumber; + let subjectTo: Address; + let subjectCaller: Account; + + const debtUnits: BigNumber = ether(100); + + beforeEach(async () => { + await debtModule.addDebt(setToken.address, setup.dai.address, debtUnits); + await setup.dai.transfer(debtModule.address, ether(100.5)); + + const [, equityFlows, ] = await debtIssuance.getRequiredComponentIssuanceUnits(setToken.address, ether(1)); + // send exact amount + await tokenWithRoundingError.approve(debtIssuance.address, equityFlows[0]); + + subjectSetToken = setToken.address; + subjectQuantity = ether(1); + subjectTo = recipient.address; + subjectCaller = owner; + }); + + async function subject(): Promise { + return debtIssuance.connect(subjectCaller.wallet).issue( + subjectSetToken, + subjectQuantity, + subjectTo, + ); + } + + describe("when rounding error is negative one", async () => { + beforeEach(async () => { + await tokenWithRoundingError.setError(BigNumber.from(-1)); + }); + + describe("when set is exactly collateralized", async () => { + it("should revert", async () => { + await expect(subject()).to.be.revertedWith( + "Invalid transfer in. Results in undercollateralization" + ); + }); + }); + + describe("when set is over-collateralized by at least 1 wei", async () => { + beforeEach(async () => { + await tokenWithRoundingError.connect(owner.wallet).transfer(setToken.address, ONE); + }); + + it("should mint SetTokens to the correct addresses", async () => { + await subject(); + + const feeQuantity = preciseMulCeil(subjectQuantity, issueFee); + const managerBalance = await setToken.balanceOf(feeRecipient.address); + const toBalance = await setToken.balanceOf(subjectTo); + + expect(toBalance).to.eq(subjectQuantity); + expect(managerBalance).to.eq(feeQuantity); + }); + }); + }); + + describe("when rounding error is positive one", async () => { + beforeEach(async () => { + await tokenWithRoundingError.setError(ONE); + }); + + describe("when set is exactly collateralized", async () => { + it("should mint SetTokens to the correct addresses", async () => { + await subject(); + + const feeQuantity = preciseMulCeil(subjectQuantity, issueFee); + const managerBalance = await setToken.balanceOf(feeRecipient.address); + const toBalance = await setToken.balanceOf(subjectTo); + + expect(toBalance).to.eq(subjectQuantity); + expect(managerBalance).to.eq(feeQuantity); + }); + }); + + describe("when set is over-collateralized by at least 1 wei", async () => { + beforeEach(async () => { + await tokenWithRoundingError.connect(owner.wallet).transfer(setToken.address, ONE); + }); + it("should mint SetTokens to the correct addresses", async () => { + await subject(); + + const feeQuantity = preciseMulCeil(subjectQuantity, issueFee); + const managerBalance = await setToken.balanceOf(feeRecipient.address); + const toBalance = await setToken.balanceOf(subjectTo); + + expect(toBalance).to.eq(subjectQuantity); + expect(managerBalance).to.eq(feeQuantity); + }); + }); + }); + }); + + describe("#redeem", async () => { + let subjectSetToken: Address; + let subjectQuantity: BigNumber; + let subjectTo: Address; + let subjectCaller: Account; + + const debtUnits: BigNumber = ether(100); + + beforeEach(async () => { + await debtModule.addDebt(setToken.address, setup.dai.address, debtUnits); + await setup.dai.transfer(debtModule.address, ether(100.5)); + + const [, equityFlows, ] = await debtIssuance.getRequiredComponentIssuanceUnits(setToken.address, ether(1)); + // Send exact amount + await tokenWithRoundingError.approve(debtIssuance.address, equityFlows[0]); + + await debtIssuance.issue(setToken.address, ether(1), owner.address); + + await setup.dai.approve(debtIssuance.address, ether(100.5)); + + subjectSetToken = setToken.address; + subjectQuantity = ether(1); + subjectTo = recipient.address; + subjectCaller = owner; + }); + + async function subject(): Promise { + return debtIssuance.connect(subjectCaller.wallet).redeem( + subjectSetToken, + subjectQuantity, + subjectTo, + ); + } + + describe("when rounding error is negative one", async () => { + beforeEach(async () => { + await tokenWithRoundingError.setError(BigNumber.from(-1)); + }); + + describe("when set is exactly collateralized", async () => { + it("should revert", async () => { + await expect(subject()).to.be.revertedWith( + "Invalid transfer out. Results in undercollateralization" + ); + }); + }); + + describe("when set is over-collateralized by at least 1 wei", async () => { + beforeEach(async () => { + await tokenWithRoundingError.connect(owner.wallet).transfer(setToken.address, ONE); + }); + it("should mint SetTokens to the correct addresses", async () => { + const preManagerBalance = await setToken.balanceOf(feeRecipient.address); + const preCallerBalance = await setToken.balanceOf(subjectCaller.address); + + await subject(); + + const feeQuantity = preciseMulCeil(subjectQuantity, redeemFee); + const postManagerBalance = await setToken.balanceOf(feeRecipient.address); + const postCallerBalance = await setToken.balanceOf(subjectCaller.address); + + expect(postManagerBalance).to.eq(preManagerBalance.add(feeQuantity)); + expect(postCallerBalance).to.eq(preCallerBalance.sub(subjectQuantity)); + }); + }); + }); + + describe("when rounding error is positive one", async () => { + beforeEach(async () => { + await tokenWithRoundingError.setError(ONE); + }); + + describe("when set is exactly collateralized", async () => { + it("should mint SetTokens to the correct addresses", async () => { + const preManagerBalance = await setToken.balanceOf(feeRecipient.address); + const preCallerBalance = await setToken.balanceOf(subjectCaller.address); + + await subject(); + + const feeQuantity = preciseMulCeil(subjectQuantity, redeemFee); + const postManagerBalance = await setToken.balanceOf(feeRecipient.address); + const postCallerBalance = await setToken.balanceOf(subjectCaller.address); + + expect(postManagerBalance).to.eq(preManagerBalance.add(feeQuantity)); + expect(postCallerBalance).to.eq(preCallerBalance.sub(subjectQuantity)); + }); + }); + + describe("when set is over-collateralized by at least 1 wei", async () => { + beforeEach(async () => { + await tokenWithRoundingError.connect(owner.wallet).transfer(setToken.address, ONE); + }); + + it("should mint SetTokens to the correct addresses", async () => { + const preManagerBalance = await setToken.balanceOf(feeRecipient.address); + const preCallerBalance = await setToken.balanceOf(subjectCaller.address); + + await subject(); + + const feeQuantity = preciseMulCeil(subjectQuantity, redeemFee); + const postManagerBalance = await setToken.balanceOf(feeRecipient.address); + const postCallerBalance = await setToken.balanceOf(subjectCaller.address); + + expect(postManagerBalance).to.eq(preManagerBalance.add(feeQuantity)); + expect(postCallerBalance).to.eq(preCallerBalance.sub(subjectQuantity)); + }); + }); + }); + }); + }); + }); +}); diff --git a/utils/contracts/index.ts b/utils/contracts/index.ts index 0162b8c1d..25ec4c4ea 100644 --- a/utils/contracts/index.ts +++ b/utils/contracts/index.ts @@ -35,6 +35,7 @@ export { CustomOracleNavIssuanceModule } from "../../typechain/CustomOracleNavIs export { CustomSetValuerMock } from "../../typechain/CustomSetValuerMock"; export { DebtIssuanceMock } from "../../typechain/DebtIssuanceMock"; export { DebtIssuanceModule } from "../../typechain/DebtIssuanceModule"; +export { DebtIssuanceModuleV2 } from "../../typechain/DebtIssuanceModuleV2"; export { DebtModuleMock } from "../../typechain/DebtModuleMock"; export { DelegateRegistry } from "../../typechain/DelegateRegistry"; export { ExplicitERC20Mock } from "../../typechain/ExplicitERC20Mock"; @@ -81,6 +82,7 @@ export { StakingAdapterMock } from "../../typechain/StakingAdapterMock"; export { StakingModule } from "../../typechain/StakingModule"; export { StakingRewards } from "../../typechain/StakingRewards"; export { StandardTokenMock } from "../../typechain/StandardTokenMock"; +export { StandardTokenWithRoundingErrorMock } from "../../typechain/StandardTokenWithRoundingErrorMock"; export { StandardTokenWithFeeMock } from "../../typechain/StandardTokenWithFeeMock"; export { StreamingFeeModule } from "../../typechain/StreamingFeeModule"; export { SynthetixExchangeAdapter } from "../../typechain/SynthetixExchangeAdapter"; diff --git a/utils/deploys/deployMocks.ts b/utils/deploys/deployMocks.ts index 37e819285..4584ebba0 100644 --- a/utils/deploys/deployMocks.ts +++ b/utils/deploys/deployMocks.ts @@ -35,6 +35,7 @@ import { ResourceIdentifierMock, StakingAdapterMock, StandardTokenMock, + StandardTokenWithRoundingErrorMock, StandardTokenWithFeeMock, TradeAdapterMock, SynthMock, @@ -82,6 +83,7 @@ import { PreciseUnitMathMock__factory } from "../../typechain/factories/PreciseU import { ResourceIdentifierMock__factory } from "../../typechain/factories/ResourceIdentifierMock__factory"; import { StakingAdapterMock__factory } from "../../typechain/factories/StakingAdapterMock__factory"; import { StandardTokenMock__factory } from "../../typechain/factories/StandardTokenMock__factory"; +import { StandardTokenWithRoundingErrorMock__factory } from "../../typechain/factories/StandardTokenWithRoundingErrorMock__factory"; import { StandardTokenWithFeeMock__factory } from "../../typechain/factories/StandardTokenWithFeeMock__factory"; import { TradeAdapterMock__factory } from "../../typechain/factories/TradeAdapterMock__factory"; import { Uint256ArrayUtilsMock__factory } from "../../typechain/factories/Uint256ArrayUtilsMock__factory"; @@ -173,10 +175,10 @@ export default class DeployMocks { receiveQuantity: BigNumber, ): Promise { return await new ZeroExMock__factory(this._deployerSigner).deploy( - sendToken, - receiveToken, - sendQuantity, - receiveQuantity, + sendToken, + receiveToken, + sendQuantity, + receiveQuantity, ); } @@ -235,6 +237,19 @@ export default class DeployMocks { .deploy(initialAccount, initialBalance, name, symbol, fee); } + public async deployTokenWithErrorMock( + initialAccount: Address, + initialBalance: BigNumberish, + error: BigNumberish, + name: string = "Token", + symbol: string = "Symbol", + decimals: BigNumberish = BigNumber.from(18) + ): Promise { + return await new StandardTokenWithRoundingErrorMock__factory(this._deployerSigner).deploy( + initialAccount, initialBalance, error, name, symbol, decimals + ); + } + public async deployTradeAdapterMock(): Promise { return await new TradeAdapterMock__factory(this._deployerSigner).deploy(); } @@ -324,7 +339,7 @@ export default class DeployMocks { sBtc: Address, currencyKeys: any, rates: any - ): Promise { + ): Promise { return await new SynthetixExchangerMock__factory(this._deployerSigner).deploy( sUsd, sEth, diff --git a/utils/deploys/deployModules.ts b/utils/deploys/deployModules.ts index 33adbd36c..46283713a 100644 --- a/utils/deploys/deployModules.ts +++ b/utils/deploys/deployModules.ts @@ -31,6 +31,7 @@ import { ClaimModule__factory } from "../../typechain/factories/ClaimModule__fac import { CompoundLeverageModule__factory } from "../../typechain/factories/CompoundLeverageModule__factory"; import { CustomOracleNavIssuanceModule__factory } from "../../typechain/factories/CustomOracleNavIssuanceModule__factory"; import { DebtIssuanceModule__factory } from "../../typechain/factories/DebtIssuanceModule__factory"; +import { DebtIssuanceModuleV2__factory } from "../../typechain/factories/DebtIssuanceModuleV2__factory"; import { GeneralIndexModule__factory } from "../../typechain/factories/GeneralIndexModule__factory"; import { GovernanceModule__factory } from "../../typechain/factories/GovernanceModule__factory"; import { IssuanceModule__factory } from "../../typechain/factories/IssuanceModule__factory"; @@ -61,6 +62,10 @@ export default class DeployModules { return await new DebtIssuanceModule__factory(this._deployerSigner).deploy(controller); } + public async deployDebtIssuanceModuleV2(controller: Address): Promise { + return await new DebtIssuanceModuleV2__factory(this._deployerSigner).deploy(controller); + } + public async deployAmmModule(controller: Address): Promise { return await new AmmModule__factory(this._deployerSigner).deploy(controller); }