From 82003017ad2a681d051e3050ad25b091e5e2d4a2 Mon Sep 17 00:00:00 2001 From: Noah Citron Date: Tue, 14 Dec 2021 21:13:46 -0500 Subject: [PATCH 1/6] add BasicIssuanceModuleV2 --- .../module/ManagerIssuanceHookMock.sol | 26 +- .../modules/BasicIssuanceModuleV2.sol | 283 ++++++++ .../modules/basicIssuanceModuleV2.spec.ts | 628 ++++++++++++++++++ utils/contracts/index.ts | 1 + utils/deploys/deployModules.ts | 6 + 5 files changed, 934 insertions(+), 10 deletions(-) create mode 100644 contracts/protocol/modules/BasicIssuanceModuleV2.sol create mode 100644 test/protocol/modules/basicIssuanceModuleV2.spec.ts diff --git a/contracts/mocks/protocol/module/ManagerIssuanceHookMock.sol b/contracts/mocks/protocol/module/ManagerIssuanceHookMock.sol index 7c9c0dfab..853745f2b 100644 --- a/contracts/mocks/protocol/module/ManagerIssuanceHookMock.sol +++ b/contracts/mocks/protocol/module/ManagerIssuanceHookMock.sol @@ -20,22 +20,28 @@ pragma solidity 0.6.10; import { ISetToken } from "../../../interfaces/ISetToken.sol"; contract ManagerIssuanceHookMock { - ISetToken public retrievedSetToken; + + ISetToken public retrievedIssueSetToken; uint256 public retrievedIssueQuantity; - address public retrievedSender; - address public retrievedTo; + address public retrievedIssueSender; + address public retrievedIssueTo; + + ISetToken public retrievedRedeemSetToken; + uint256 public retrievedRedeemQuantity; + address public retrievedRedeemSender; + address public retrievedRedeemTo; function invokePreIssueHook(ISetToken _setToken, uint256 _issueQuantity, address _sender, address _to) external { - retrievedSetToken = _setToken; + retrievedIssueSetToken = _setToken; retrievedIssueQuantity = _issueQuantity; - retrievedSender = _sender; - retrievedTo = _to; + retrievedIssueSender = _sender; + retrievedIssueTo = _to; } function invokePreRedeemHook(ISetToken _setToken, uint256 _redeemQuantity, address _sender, address _to) external { - retrievedSetToken = _setToken; - retrievedIssueQuantity = _redeemQuantity; - retrievedSender = _sender; - retrievedTo = _to; + retrievedRedeemSetToken = _setToken; + retrievedRedeemQuantity = _redeemQuantity; + retrievedRedeemSender = _sender; + retrievedRedeemTo = _to; } } \ No newline at end of file diff --git a/contracts/protocol/modules/BasicIssuanceModuleV2.sol b/contracts/protocol/modules/BasicIssuanceModuleV2.sol new file mode 100644 index 000000000..a281cc087 --- /dev/null +++ b/contracts/protocol/modules/BasicIssuanceModuleV2.sol @@ -0,0 +1,283 @@ +/* + 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; +pragma experimental "ABIEncoderV2"; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/SafeCast.sol"; +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; + +import { IController } from "../../interfaces/IController.sol"; +import { IManagerIssuanceHook } from "../../interfaces/IManagerIssuanceHook.sol"; +import { Invoke } from "../lib/Invoke.sol"; +import { ISetToken } from "../../interfaces/ISetToken.sol"; +import { ModuleBase } from "../lib/ModuleBase.sol"; +import { Position } from "../lib/Position.sol"; +import { PreciseUnitMath } from "../../lib/PreciseUnitMath.sol"; + +/** + * @title BasicIssuanceModuleV2 + * @author Set Protocol + * + * Module that enables issuance and redemption functionality on a SetToken. This is a module that is + * required to bring the totalSupply of a Set above 0. + */ +contract BasicIssuanceModuleV2 is ModuleBase, ReentrancyGuard { + using Invoke for ISetToken; + using Position for ISetToken.Position; + using Position for ISetToken; + using PreciseUnitMath for uint256; + using SafeMath for uint256; + using SafeCast for int256; + + /* ============ Events ============ */ + + event SetTokenIssued( + address indexed _setToken, + address indexed _issuer, + address indexed _to, + address _hookContract, + uint256 _quantity + ); + event SetTokenRedeemed( + address indexed _setToken, + address indexed _redeemer, + address indexed _to, + address _hookContract, + uint256 _quantity + ); + + /* ============ State Variables ============ */ + + // Mapping of SetToken to Issuance hook configurations + mapping(ISetToken => IManagerIssuanceHook) public managerIssuanceHook; + + /* ============ Constructor ============ */ + + /** + * Set state controller state variable + * + * @param _controller Address of controller contract + */ + constructor(IController _controller) public ModuleBase(_controller) {} + + /* ============ External Functions ============ */ + + /** + * Deposits the SetToken's position components into the SetToken and mints the SetToken of the given quantity + * to the specified _to address. This function only handles Default Positions (positionState = 0). + * + * @param _setToken Instance of the SetToken contract + * @param _quantity Quantity of the SetToken to mint + * @param _to Address to mint SetToken to + */ + function issue( + ISetToken _setToken, + uint256 _quantity, + address _to + ) + external + nonReentrant + onlyValidAndInitializedSet(_setToken) + { + require(_quantity > 0, "Issue quantity must be > 0"); + + address hookContract = _callPreIssueHooks(_setToken, _quantity, msg.sender, _to); + + ( + address[] memory components, + uint256[] memory componentQuantities + ) = getRequiredComponentUnitsForIssue(_setToken, _quantity); + + // For each position, transfer the required underlying to the SetToken + for (uint256 i = 0; i < components.length; i++) { + // Transfer the component to the SetToken + transferFrom( + IERC20(components[i]), + msg.sender, + address(_setToken), + componentQuantities[i] + ); + } + + // Mint the SetToken + _setToken.mint(_to, _quantity); + + emit SetTokenIssued(address(_setToken), msg.sender, _to, hookContract, _quantity); + } + + /** + * Redeems the SetToken's positions and sends the components of the given + * quantity to the caller. This function only handles Default Positions (positionState = 0). + * + * @param _setToken Instance of the SetToken contract + * @param _quantity Quantity of the SetToken to redeem + * @param _to Address to send component assets to + */ + function redeem( + ISetToken _setToken, + uint256 _quantity, + address _to + ) + external + nonReentrant + onlyValidAndInitializedSet(_setToken) + { + require(_quantity > 0, "Redeem quantity must be > 0"); + + address hookContract = _callPreRedeemHooks(_setToken, _quantity, msg.sender, _to); + + // Burn the SetToken - ERC20's internal burn already checks that the user has enough balance + _setToken.burn(msg.sender, _quantity); + + // For each position, invoke the SetToken to transfer the tokens to the user + address[] memory components = _setToken.getComponents(); + for (uint256 i = 0; i < components.length; i++) { + address component = components[i]; + require(!_setToken.hasExternalPosition(component), "Only default positions are supported"); + + uint256 unit = _setToken.getDefaultPositionRealUnit(component).toUint256(); + + // Use preciseMul to round down to ensure overcollateration when small redeem quantities are provided + uint256 componentQuantity = _quantity.preciseMul(unit); + + // Instruct the SetToken to transfer the component to the user + _setToken.strictInvokeTransfer( + component, + _to, + componentQuantity + ); + } + + emit SetTokenRedeemed(address(_setToken), msg.sender, _to, hookContract, _quantity); + } + + /** + * Initializes this module to the SetToken with issuance-related hooks. Only callable by the SetToken's manager. + * Hook addresses are optional. Address(0) means that no hook will be called + * + * @param _setToken Instance of the SetToken to issue + * @param _preIssueHook Instance of the Manager Contract with the Pre-Issuance Hook function + */ + function initialize( + ISetToken _setToken, + IManagerIssuanceHook _preIssueHook + ) + external + onlySetManager(_setToken, msg.sender) + onlyValidAndPendingSet(_setToken) + { + managerIssuanceHook[_setToken] = _preIssueHook; + + _setToken.initializeModule(); + } + + function updateManagerIssuanceHook( + ISetToken _setToken, + IManagerIssuanceHook _newHook + ) + external + onlySetManager(_setToken, msg.sender) + onlyValidAndInitializedSet(_setToken) + { + managerIssuanceHook[_setToken] = _newHook; + } + + /** + * SET TOKEN ONLY: Allows removal (and deletion of state) of BasicIssuanceModuleV2 + */ + function removeModule() external override { + delete managerIssuanceHook[ISetToken(msg.sender)]; + } + + /* ============ External Getter Functions ============ */ + + /** + * Retrieves the addresses and units required to mint a particular quantity of SetToken. + * + * @param _setToken Instance of the SetToken to issue + * @param _quantity Quantity of SetToken to issue + * @return address[] List of component addresses + * @return uint256[] List of component units required to issue the quantity of SetTokens + */ + function getRequiredComponentUnitsForIssue( + ISetToken _setToken, + uint256 _quantity + ) + public + view + onlyValidAndInitializedSet(_setToken) + returns (address[] memory, uint256[] memory) + { + address[] memory components = _setToken.getComponents(); + + uint256[] memory notionalUnits = new uint256[](components.length); + + for (uint256 i = 0; i < components.length; i++) { + require(!_setToken.hasExternalPosition(components[i]), "Only default positions are supported"); + + notionalUnits[i] = _setToken.getDefaultPositionRealUnit(components[i]).toUint256().preciseMulCeil(_quantity); + } + + return (components, notionalUnits); + } + + /* ============ Internal Functions ============ */ + + /** + * If a pre-issue hook has been configured, call the external-protocol contract's pre-issue function. + * Pre-issue hook logic can contain arbitrary logic including validations, external function calls, etc. + */ + function _callPreIssueHooks( + ISetToken _setToken, + uint256 _quantity, + address _caller, + address _to + ) + internal + returns(address) + { + IManagerIssuanceHook preIssueHook = managerIssuanceHook[_setToken]; + if (address(preIssueHook) != address(0)) { + preIssueHook.invokePreIssueHook(_setToken, _quantity, _caller, _to); + return address(preIssueHook); + } + + return address(0); + } + + /** + * If a pre-issue hook has been configured, call the external-protocol contract's pre-redeem function. + * Pre-issue hook logic can contain arbitrary logic including validations, external function calls, etc. + */ + function _callPreRedeemHooks( + ISetToken _setToken, + uint256 _quantity, + address _caller, + address _to + ) + internal + returns(address) + { + IManagerIssuanceHook preIssueHook = managerIssuanceHook[_setToken]; + if (address(preIssueHook) != address(0)) { + preIssueHook.invokePreRedeemHook(_setToken, _quantity, _caller, _to); + return address(preIssueHook); + } + + return address(0); + } +} \ No newline at end of file diff --git a/test/protocol/modules/basicIssuanceModuleV2.spec.ts b/test/protocol/modules/basicIssuanceModuleV2.spec.ts new file mode 100644 index 000000000..96dcd8520 --- /dev/null +++ b/test/protocol/modules/basicIssuanceModuleV2.spec.ts @@ -0,0 +1,628 @@ +import "module-alias/register"; +import { BigNumber, ContractTransaction } from "ethers"; + +import { Address } from "@utils/types"; +import { Account } from "@utils/test/types"; +import { ADDRESS_ZERO, ZERO, ONE } from "@utils/constants"; +import { BasicIssuanceModuleV2, ManagerIssuanceHookMock, SetToken } from "@utils/contracts"; +import DeployHelper from "@utils/deploys"; +import { + bitcoin, + ether, +} from "@utils/index"; +import { + addSnapshotBeforeRestoreAfterEach, + getAccounts, + getRandomAccount, + getRandomAddress, + getWaffleExpect, + getSystemFixture, +} from "@utils/test/index"; +import { SystemFixture } from "@utils/fixtures"; + +const expect = getWaffleExpect(); + +describe("BasicIssuanceModuleV2", () => { + let owner: Account; + let recipient: Account; + let deployer: DeployHelper; + let setup: SystemFixture; + + let issuanceModule: BasicIssuanceModuleV2; + + before(async () => { + [ + owner, + recipient, + ] = await getAccounts(); + + deployer = new DeployHelper(owner.wallet); + setup = getSystemFixture(owner.address); + await setup.initialize(); + + issuanceModule = await deployer.modules.deployBasicIssuanceModuleV2(setup.controller.address); + await setup.controller.addModule(issuanceModule.address); + }); + + addSnapshotBeforeRestoreAfterEach(); + + describe("#initialize", async () => { + let setToken: SetToken; + let subjectSetToken: Address; + let subjectPreIssuanceHook: Address; + let subjectCaller: Account; + + beforeEach(async () => { + setToken = await setup.createSetToken( + [setup.weth.address], + [ether(1)], + [issuanceModule.address] + ); + subjectSetToken = setToken.address; + subjectPreIssuanceHook = await getRandomAddress(); + subjectCaller = owner; + }); + + async function subject(): Promise { + return issuanceModule.connect(subjectCaller.wallet).initialize( + subjectSetToken, + subjectPreIssuanceHook, + ); + } + + it("should enable the Module on the SetToken", async () => { + await subject(); + const isModuleEnabled = await setToken.isInitializedModule(issuanceModule.address); + expect(isModuleEnabled).to.eq(true); + }); + + it("should properly set the issuance hooks", async () => { + await subject(); + const preIssuanceHooks = await issuanceModule.managerIssuanceHook(subjectSetToken); + expect(preIssuanceHooks).to.eq(subjectPreIssuanceHook); + }); + + describe("when the caller is not the SetToken manager", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be the SetToken manager"); + }); + }); + + describe("when SetToken is not in pending state", async () => { + beforeEach(async () => { + const newModule = await getRandomAddress(); + await setup.controller.addModule(newModule); + + const issuanceModuleNotPendingSetToken = await setup.createSetToken( + [setup.weth.address], + [ether(1)], + [newModule] + ); + + subjectSetToken = issuanceModuleNotPendingSetToken.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be pending initialization"); + }); + }); + + describe("when the SetToken is not enabled on the controller", async () => { + beforeEach(async () => { + const nonEnabledSetToken = await setup.createNonControllerEnabledSetToken( + [setup.weth.address], + [ether(1)], + [issuanceModule.address] + ); + + subjectSetToken = nonEnabledSetToken.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be controller-enabled SetToken"); + }); + }); + }); + + describe("#removeModule", async () => { + let subjectSetToken: SetToken; + let subjectCaller: Account; + + beforeEach(async () => { + subjectCaller = owner; + subjectSetToken = await setup.createSetToken( + [setup.weth.address, setup.wbtc.address], + [ether(1), bitcoin(2)], + [issuanceModule.address] + ); + + await issuanceModule.initialize(subjectSetToken.address, await getRandomAddress()); + }); + + async function subject(): Promise { + return subjectSetToken.connect(subjectCaller.wallet).removeModule(issuanceModule.address); + } + + it("should remove the module", async () => { + await subject(); + + const moduleState = await subjectSetToken.moduleStates(issuanceModule.address); + expect(moduleState).to.eq(0); // ModuleState.NONE + }); + + it("should clear the issuance hook entry for the set", async () => { + await subject(); + + const issuanceHook = await issuanceModule.managerIssuanceHook(subjectSetToken.address); + expect(issuanceHook).to.eq(ADDRESS_ZERO); + }); + }); + + describe("#updateManagerIssuanceHook", async () => { + let subjectSetToken: SetToken; + let subjectNewManagerIssuanceHook: Address; + let subjectCaller: Account; + + let isInitialized: boolean; + + before(() => { + isInitialized = true; + }); + + beforeEach(async () => { + subjectCaller = owner; + subjectSetToken = await setup.createSetToken( + [setup.weth.address, setup.wbtc.address], + [ether(1), bitcoin(2)], + [issuanceModule.address] + ); + + if (isInitialized) { + await issuanceModule.initialize(subjectSetToken.address, await getRandomAddress()); + } + + subjectNewManagerIssuanceHook = await getRandomAddress(); + }); + + async function subject(): Promise { + return await issuanceModule.connect(subjectCaller.wallet).updateManagerIssuanceHook( + subjectSetToken.address, + subjectNewManagerIssuanceHook + ); + } + + it("should update the manager issuance hook", async () => { + await subject(); + + const issuanceHook = await issuanceModule.managerIssuanceHook(subjectSetToken.address); + expect(issuanceHook).to.eq(subjectNewManagerIssuanceHook); + }); + + context("when caller is not the manager", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be the SetToken manager"); + }); + }); + + context("when module is not initialized", async () => { + before(() => { + isInitialized = false; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be a valid and initialized SetToken"); + }); + }); + }); + + describe("#issue", async () => { + let setToken: SetToken; + + let subjectSetToken: Address; + let subjectIssueQuantity: BigNumber; + let subjectTo: Account; + let subjectIssuanceHook: Address; + let subjectCaller: Account; + + context("when the components are WBTC and WETH", async () => { + beforeEach(async () => { + setToken = await setup.createSetToken( + [setup.weth.address, setup.wbtc.address], + [ether(1), bitcoin(2)], + [issuanceModule.address] + ); + await issuanceModule.initialize(setToken.address, subjectIssuanceHook); + + // Approve tokens to the issuance mdoule + await setup.weth.approve(issuanceModule.address, ether(5)); + await setup.wbtc.approve(issuanceModule.address, bitcoin(10)); + + subjectSetToken = setToken.address; + subjectIssueQuantity = ether(2); + subjectTo = recipient; + subjectCaller = owner; + }); + + context("when there are no hooks", async () => { + before(() => { + subjectIssuanceHook = ADDRESS_ZERO; + }); + + async function subject(): Promise { + return issuanceModule.connect(subjectCaller.wallet).issue( + subjectSetToken, + subjectIssueQuantity, + subjectTo.address + ); + } + + it("should issue the Set to the recipient", async () => { + await subject(); + const issuedBalance = await setToken.balanceOf(recipient.address); + expect(issuedBalance).to.eq(subjectIssueQuantity); + }); + + it("should have deposited the components into the SetToken", async () => { + await subject(); + const depositedWETHBalance = await setup.weth.balanceOf(setToken.address); + const expectedBTCBalance = subjectIssueQuantity; + expect(depositedWETHBalance).to.eq(expectedBTCBalance); + + const depositedBTCBalance = await setup.wbtc.balanceOf(setToken.address); + const expectedBalance = subjectIssueQuantity.mul(bitcoin(2)).div(ether(1)); + expect(depositedBTCBalance).to.eq(expectedBalance); + }); + + it("should emit the SetTokenIssued event", async () => { + await expect(subject()).to.emit(issuanceModule, "SetTokenIssued").withArgs( + subjectSetToken, + subjectCaller.address, + subjectTo.address, + ADDRESS_ZERO, + subjectIssueQuantity, + ); + }); + + describe("when the issue quantity is extremely small", async () => { + beforeEach(async () => { + subjectIssueQuantity = ONE; + }); + + it("should transfer the minimal units of components to the SetToken", async () => { + await subject(); + const depositedWETHBalance = await setup.weth.balanceOf(setToken.address); + const expectedWETHBalance = ONE; + expect(depositedWETHBalance).to.eq(expectedWETHBalance); + + const depositedBTCBalance = await setup.wbtc.balanceOf(setToken.address); + const expectedBTCBalance = ONE; + expect(depositedBTCBalance).to.eq(expectedBTCBalance); + }); + }); + + describe("when a SetToken position is not in default state", async () => { + beforeEach(async () => { + // Add self as module and update the position state + await setup.controller.addModule(owner.address); + await setToken.addModule(owner.address); + await setToken.initializeModule(); + + const retrievedPosition = (await setToken.getPositions())[0]; + + await setToken.addExternalPositionModule(retrievedPosition.component, retrievedPosition.module); + await setToken.editExternalPositionUnit(retrievedPosition.component, retrievedPosition.module, retrievedPosition.unit); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Only default positions are supported"); + }); + }); + + describe("when one of the components has a recipient-related fee", async () => { + beforeEach(async () => { + // Add self as module and update the position state + await setup.controller.addModule(owner.address); + await setToken.addModule(owner.address); + await setToken.initializeModule(); + + const tokenWithFee = await deployer.mocks.deployTokenWithFeeMock(owner.address, ether(20), ether(0.1)); + await tokenWithFee.approve(issuanceModule.address, ether(100)); + + const retrievedPosition = (await setToken.getPositions())[0]; + + await setToken.addComponent(tokenWithFee.address); + await setToken.editDefaultPositionUnit(tokenWithFee.address, retrievedPosition.unit); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid post transfer balance"); + }); + }); + + describe("when the issue quantity is 0", async () => { + beforeEach(async () => { + subjectIssueQuantity = 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( + [setup.weth.address], + [ether(1)], + [issuanceModule.address] + ); + + subjectSetToken = nonEnabledSetToken.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be a valid and initialized SetToken"); + }); + }); + }); + + context("when a preIssueHook has been set", async () => { + let issuanceHookContract: ManagerIssuanceHookMock; + + before(async () => { + issuanceHookContract = await deployer.mocks.deployManagerIssuanceHookMock(); + + subjectIssuanceHook = issuanceHookContract.address; + }); + + async function subject(): Promise { + return issuanceModule.issue(subjectSetToken, subjectIssueQuantity, subjectTo.address); + } + + it("should properly call the pre-issue hooks", async () => { + await subject(); + const retrievedSetToken = await issuanceHookContract.retrievedIssueSetToken(); + const retrievedIssueQuantity = await issuanceHookContract.retrievedIssueQuantity(); + const retrievedSender = await issuanceHookContract.retrievedIssueSender(); + const retrievedTo = await issuanceHookContract.retrievedIssueTo(); + + expect(retrievedSetToken).to.eq(subjectSetToken); + expect(retrievedIssueQuantity).to.eq(subjectIssueQuantity); + expect(retrievedSender).to.eq(owner.address); + expect(retrievedTo).to.eq(subjectTo.address); + }); + + it("should emit the SetTokenIssued event", async () => { + await expect(subject()).to.emit(issuanceModule, "SetTokenIssued").withArgs( + subjectSetToken, + subjectCaller.address, + subjectTo.address, + subjectIssuanceHook, + subjectIssueQuantity, + ); + }); + }); + }); + }); + + describe("#redeem", async () => { + let setToken: SetToken; + + let subjectSetToken: Address; + let subjectRedeemQuantity: BigNumber; + let subjectTo: Account; + let subjectIssuanceHook: Address; + let subjectCaller: Account; + + context("when the components are WBTC and WETH", async () => { + beforeEach(async () => { + setToken = await setup.createSetToken( + [setup.weth.address, setup.wbtc.address], + [ether(1), bitcoin(2)], + [issuanceModule.address] + ); + await issuanceModule.initialize(setToken.address, subjectIssuanceHook); + + // Approve tokens to the issuance mdoule + await setup.weth.approve(issuanceModule.address, ether(5)); + await setup.wbtc.approve(issuanceModule.address, bitcoin(10)); + + subjectSetToken = setToken.address; + subjectRedeemQuantity = ether(1); + subjectTo = recipient; + subjectCaller = owner; + + const issueQuantity = ether(2); + await issuanceModule.issue(subjectSetToken, issueQuantity, subjectCaller.address); + }); + + context("when there are no hooks", async () => { + before(() => { + subjectIssuanceHook = ADDRESS_ZERO; + }); + + async function subject(): Promise { + return issuanceModule.connect(subjectCaller.wallet).redeem(subjectSetToken, subjectRedeemQuantity, subjectTo.address); + } + + it("should redeem the Set", async () => { + await subject(); + const redeemBalance = await setToken.balanceOf(subjectCaller.address); + expect(redeemBalance).to.eq(ether(1)); + }); + + it("should have deposited the components to the recipients account", async () => { + const beforeWETHBalance = await setup.weth.balanceOf(recipient.address); + const beforeBTCBalance = await setup.wbtc.balanceOf(recipient.address); + + await subject(); + const afterWETHBalance = await setup.weth.balanceOf(recipient.address); + const expectedBTCBalance = beforeWETHBalance.add(subjectRedeemQuantity); + expect(afterWETHBalance).to.eq(expectedBTCBalance); + + const afterBTCBalance = await setup.wbtc.balanceOf(recipient.address); + const expectedBalance = beforeBTCBalance.add(subjectRedeemQuantity.mul(bitcoin(2)).div(ether(1))); + expect(afterBTCBalance).to.eq(expectedBalance); + }); + + it("should have subtracted from the components from the SetToken", async () => { + const beforeWETHBalance = await setup.weth.balanceOf(setToken.address); + const beforeBTCBalance = await setup.wbtc.balanceOf(setToken.address); + + await subject(); + const afterWETHBalance = await setup.weth.balanceOf(setToken.address); + const expectedBTCBalance = beforeWETHBalance.sub(subjectRedeemQuantity); + expect(afterWETHBalance).to.eq(expectedBTCBalance); + + const afterBTCBalance = await setup.wbtc.balanceOf(setToken.address); + const expectedBalance = beforeBTCBalance.sub(subjectRedeemQuantity.mul(bitcoin(2)).div(ether(1))); + expect(afterBTCBalance).to.eq(expectedBalance); + }); + + it("should emit the SetTokenRedeemed event", async () => { + await expect(subject()).to.emit(issuanceModule, "SetTokenRedeemed").withArgs( + subjectSetToken, + subjectCaller.address, + subjectTo.address, + subjectIssuanceHook, + subjectRedeemQuantity + ); + }); + + describe("when the issue quantity is extremely small", async () => { + beforeEach(async () => { + subjectRedeemQuantity = ONE; + }); + + it("should transfer the minimal units of components to the SetToken", async () => { + const previousCallerBTCBalance = await setup.wbtc.balanceOf(subjectCaller.address); + + await subject(); + + const afterCallerBTCBalance = await setup.wbtc.balanceOf(subjectCaller.address); + expect(previousCallerBTCBalance).to.eq(afterCallerBTCBalance); + }); + }); + + describe("when the issue quantity is greater than the callers balance", async () => { + beforeEach(async () => { + subjectRedeemQuantity = ether(4); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("ERC20: burn amount exceeds balance"); + }); + }); + + describe("when one of the components has a recipient-related fee", async () => { + beforeEach(async () => { + // Add self as module and update the position state + await setup.controller.addModule(owner.address); + await setToken.addModule(owner.address); + await setToken.initializeModule(); + + const tokenWithFee = await deployer.mocks.deployTokenWithFeeMock(setToken.address, ether(20), ether(0.1)); + + const retrievedPosition = (await setToken.getPositions())[0]; + + await setToken.addComponent(tokenWithFee.address); + await setToken.editDefaultPositionUnit(tokenWithFee.address, retrievedPosition.unit); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid post transfer balance"); + }); + }); + + describe("when a SetToken position is not in default state", async () => { + beforeEach(async () => { + // Add self as module and update the position state + await setup.controller.addModule(owner.address); + await setToken.addModule(owner.address); + await setToken.initializeModule(); + + const retrievedPosition = (await setToken.getPositions())[0]; + + await setToken.addExternalPositionModule(retrievedPosition.component, retrievedPosition.module); + await setToken.editExternalPositionUnit(retrievedPosition.component, retrievedPosition.module, retrievedPosition.unit); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Only default positions are supported"); + }); + }); + + describe("when the issue quantity is 0", async () => { + beforeEach(async () => { + subjectRedeemQuantity = 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( + [setup.weth.address], + [ether(1)], + [issuanceModule.address] + ); + + subjectSetToken = nonEnabledSetToken.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be a valid and initialized SetToken"); + }); + }); + }); + + context("when a preIssueHook has been set", async () => { + let issuanceHookContract: ManagerIssuanceHookMock; + + before(async () => { + issuanceHookContract = await deployer.mocks.deployManagerIssuanceHookMock(); + + subjectIssuanceHook = issuanceHookContract.address; + }); + + async function subject(): Promise { + return issuanceModule.redeem(subjectSetToken, subjectRedeemQuantity, subjectTo.address); + } + + it("should properly call the pre-issue hooks", async () => { + await subject(); + const retrievedSetToken = await issuanceHookContract.retrievedRedeemSetToken(); + const retrievedQuantity = await issuanceHookContract.retrievedRedeemQuantity(); + const retrievedSender = await issuanceHookContract.retrievedRedeemSender(); + const retrievedTo = await issuanceHookContract.retrievedRedeemTo(); + + expect(retrievedSetToken).to.eq(subjectSetToken); + expect(retrievedQuantity).to.eq(subjectRedeemQuantity); + expect(retrievedSender).to.eq(owner.address); + expect(retrievedTo).to.eq(subjectTo.address); + }); + + it("should emit the SetTokenIssued event", async () => { + await expect(subject()).to.emit(issuanceModule, "SetTokenRedeemed").withArgs( + subjectSetToken, + subjectCaller.address, + subjectTo.address, + subjectIssuanceHook, + subjectRedeemQuantity, + ); + }); + }); + }); + }); +}); diff --git a/utils/contracts/index.ts b/utils/contracts/index.ts index 35f37f1fa..75ec3e2f7 100644 --- a/utils/contracts/index.ts +++ b/utils/contracts/index.ts @@ -17,6 +17,7 @@ export { AssetLimitHook } from "../../typechain/AssetLimitHook"; export { AxieInfinityMigrationWrapAdapter } from "../../typechain/AxieInfinityMigrationWrapAdapter"; export { BalancerV1IndexExchangeAdapter } from "../../typechain/BalancerV1IndexExchangeAdapter"; export { BasicIssuanceModule } from "../../typechain/BasicIssuanceModule"; +export { BasicIssuanceModuleV2 } from "../../typechain/BasicIssuanceModuleV2"; export { ChainlinkAggregatorMock } from "../../typechain/ChainlinkAggregatorMock"; export { ClaimAdapterMock } from "../../typechain/ClaimAdapterMock"; export { ClaimModule } from "../../typechain/ClaimModule"; diff --git a/utils/deploys/deployModules.ts b/utils/deploys/deployModules.ts index 116f1a7e5..a91ad168b 100644 --- a/utils/deploys/deployModules.ts +++ b/utils/deploys/deployModules.ts @@ -5,6 +5,7 @@ import { AirdropModule, AmmModule, BasicIssuanceModule, + BasicIssuanceModuleV2, ClaimModule, CompoundLeverageModule, CustomOracleNavIssuanceModule, @@ -28,6 +29,7 @@ import { AaveLeverageModule__factory } from "../../typechain/factories/AaveLever import { AirdropModule__factory } from "../../typechain/factories/AirdropModule__factory"; import { AmmModule__factory } from "../../typechain/factories/AmmModule__factory"; import { BasicIssuanceModule__factory } from "../../typechain/factories/BasicIssuanceModule__factory"; +import { BasicIssuanceModuleV2__factory } from "../../typechain/factories/BasicIssuanceModuleV2__factory"; import { ClaimModule__factory } from "../../typechain/factories/ClaimModule__factory"; import { CompoundLeverageModule__factory } from "../../typechain/factories/CompoundLeverageModule__factory"; import { CustomOracleNavIssuanceModule__factory } from "../../typechain/factories/CustomOracleNavIssuanceModule__factory"; @@ -57,6 +59,10 @@ export default class DeployModules { return await new BasicIssuanceModule__factory(this._deployerSigner).deploy(controller); } + public async deployBasicIssuanceModuleV2(controller: Address): Promise { + return await new BasicIssuanceModuleV2__factory(this._deployerSigner).deploy(controller); + } + public async deployIssuanceModule(controller: Address): Promise { return await new IssuanceModule__factory(this._deployerSigner).deploy(controller); } From 6e6100d3f57b9bdce8ddc43ab3eff2904ed3e523 Mon Sep 17 00:00:00 2001 From: Noah Citron Date: Wed, 15 Dec 2021 01:17:41 -0500 Subject: [PATCH 2/6] fix license spacing --- contracts/protocol/modules/BasicIssuanceModule.sol | 4 ++++ contracts/protocol/modules/BasicIssuanceModuleV2.sol | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/contracts/protocol/modules/BasicIssuanceModule.sol b/contracts/protocol/modules/BasicIssuanceModule.sol index 57707c053..5d1a0c43a 100644 --- a/contracts/protocol/modules/BasicIssuanceModule.sol +++ b/contracts/protocol/modules/BasicIssuanceModule.sol @@ -1,14 +1,18 @@ /* 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 */ diff --git a/contracts/protocol/modules/BasicIssuanceModuleV2.sol b/contracts/protocol/modules/BasicIssuanceModuleV2.sol index a281cc087..0efa412ff 100644 --- a/contracts/protocol/modules/BasicIssuanceModuleV2.sol +++ b/contracts/protocol/modules/BasicIssuanceModuleV2.sol @@ -1,14 +1,18 @@ /* 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 */ From f17370c9ba92731613ee3ada597a9b746643a64e Mon Sep 17 00:00:00 2001 From: Noah Citron Date: Wed, 15 Dec 2021 15:40:55 -0500 Subject: [PATCH 3/6] update DIM and DIM V2 --- .../modules/BasicIssuanceModuleV2.sol | 7 ++ .../protocol/modules/DebtIssuanceModule.sol | 44 +++++++++++ .../protocol/modules/DebtIssuanceModuleV2.sol | 5 +- .../modules/SlippageIssuanceModule.sol | 11 +++ .../modules/basicIssuanceModule.spec.ts | 6 +- .../modules/basicIssuanceModuleV2.spec.ts | 2 +- .../customOracleNAVIssuanceModule.spec.ts | 10 +-- .../modules/debtIssuanceModule.spec.ts | 76 ++++++++++++++++++- .../modules/debtIssuanceModuleV2.spec.ts | 23 +++++- test/protocol/modules/issuanceModule.spec.ts | 6 +- .../modules/navIssuanceModule.spec.ts | 10 +-- .../modules/slippageIssuanceModule.spec.ts | 2 +- utils/deploys/deployModules.ts | 3 +- 13 files changed, 181 insertions(+), 24 deletions(-) diff --git a/contracts/protocol/modules/BasicIssuanceModuleV2.sol b/contracts/protocol/modules/BasicIssuanceModuleV2.sol index 0efa412ff..9a5f11fab 100644 --- a/contracts/protocol/modules/BasicIssuanceModuleV2.sol +++ b/contracts/protocol/modules/BasicIssuanceModuleV2.sol @@ -189,6 +189,13 @@ contract BasicIssuanceModuleV2 is ModuleBase, ReentrancyGuard { _setToken.initializeModule(); } + /** + * MANAGER ONLY: Updates the address of the manager issuance hook. To remove the hook + * set the new hook address to address(0) + * + * @param _setToken Instance of the SetToken to update manager hook + * @param _newHook New manager hook contract address + */ function updateManagerIssuanceHook( ISetToken _setToken, IManagerIssuanceHook _newHook diff --git a/contracts/protocol/modules/DebtIssuanceModule.sol b/contracts/protocol/modules/DebtIssuanceModule.sol index 8cf1814b2..3b8c5e7f6 100644 --- a/contracts/protocol/modules/DebtIssuanceModule.sol +++ b/contracts/protocol/modules/DebtIssuanceModule.sol @@ -75,6 +75,7 @@ contract DebtIssuanceModule is ModuleBase, ReentrancyGuard { ISetToken indexed _setToken, address indexed _redeemer, address indexed _to, + address _hookContract, uint256 _quantity, uint256 _managerFee, uint256 _protocolFee @@ -173,6 +174,8 @@ contract DebtIssuanceModule is ModuleBase, ReentrancyGuard { { require(_quantity > 0, "Redeem quantity must be > 0"); + address hookContract = _callManagerPreRedeemHooks(_setToken, _quantity, msg.sender, _to); + _callModulePreRedeemHooks(_setToken, _quantity); // Place burn after pre-redeem hooks because burning tokens may lead to false accounting of synced positions @@ -198,12 +201,31 @@ contract DebtIssuanceModule is ModuleBase, ReentrancyGuard { _setToken, msg.sender, _to, + hookContract, _quantity, managerFee, protocolFee ); } + /** + * MANAGER ONLY: Updates the address of the manager issuance hook. To remove the hook + * set the new hook address to address(0) + * + * @param _setToken Instance of the SetToken to update manager hook + * @param _newHook New manager hook contract address + */ + function updateManagerIssuanceHook( + ISetToken _setToken, + IManagerIssuanceHook _newHook + ) + external + onlySetManager(_setToken, msg.sender) + onlyValidAndInitializedSet(_setToken) + { + issuanceSettings[_setToken].managerIssuanceHook = _newHook; + } + /** * MANAGER ONLY: Updates address receiving issue/redeem fees for a given SetToken. * @@ -647,6 +669,28 @@ contract DebtIssuanceModule is ModuleBase, ReentrancyGuard { return address(0); } + + /** + * If a pre-issue hook has been configured, call the external-protocol contract. Pre-issue hook logic + * can contain arbitrary logic including validations, external function calls, etc. + */ + function _callManagerPreRedeemHooks( + ISetToken _setToken, + uint256 _quantity, + address _caller, + address _to + ) + internal + returns(address) + { + IManagerIssuanceHook preIssueHook = issuanceSettings[_setToken].managerIssuanceHook; + if (address(preIssueHook) != address(0)) { + preIssueHook.invokePreRedeemHook(_setToken, _quantity, _caller, _to); + return address(preIssueHook); + } + + return address(0); + } /** * Calls all modules that have registered with the DebtIssuanceModule that have a moduleIssueHook. diff --git a/contracts/protocol/modules/DebtIssuanceModuleV2.sol b/contracts/protocol/modules/DebtIssuanceModuleV2.sol index c857d4d52..d722ac6d2 100644 --- a/contracts/protocol/modules/DebtIssuanceModuleV2.sol +++ b/contracts/protocol/modules/DebtIssuanceModuleV2.sol @@ -52,7 +52,7 @@ import { Position } from "../lib/Position.sol"; */ contract DebtIssuanceModuleV2 is DebtIssuanceModule { using Position for uint256; - + /* ============ Constructor ============ */ constructor(IController _controller) public DebtIssuanceModule(_controller) {} @@ -151,6 +151,8 @@ contract DebtIssuanceModuleV2 is DebtIssuanceModule { { require(_quantity > 0, "Redeem quantity must be > 0"); + address hookContract = _callManagerPreRedeemHooks(_setToken, _quantity, msg.sender, _to); + _callModulePreRedeemHooks(_setToken, _quantity); uint256 initialSetSupply = _setToken.totalSupply(); @@ -183,6 +185,7 @@ contract DebtIssuanceModuleV2 is DebtIssuanceModule { _setToken, msg.sender, _to, + hookContract, _quantity, managerFee, protocolFee diff --git a/contracts/protocol/modules/SlippageIssuanceModule.sol b/contracts/protocol/modules/SlippageIssuanceModule.sol index 70666799f..853c4a8c0 100644 --- a/contracts/protocol/modules/SlippageIssuanceModule.sol +++ b/contracts/protocol/modules/SlippageIssuanceModule.sol @@ -38,6 +38,17 @@ import { ISetToken } from "../../interfaces/ISetToken.sol"; */ contract SlippageIssuanceModule is DebtIssuanceModule { + /* ============ Events ============ */ + + event SetTokenRedeemed( + ISetToken indexed _setToken, + address indexed _redeemer, + address indexed _to, + uint256 _quantity, + uint256 _managerFee, + uint256 _protocolFee + ); + constructor(IController _controller) public DebtIssuanceModule(_controller) {} /* ============ External Functions ============ */ diff --git a/test/protocol/modules/basicIssuanceModule.spec.ts b/test/protocol/modules/basicIssuanceModule.spec.ts index 48c338851..0fefb184f 100644 --- a/test/protocol/modules/basicIssuanceModule.spec.ts +++ b/test/protocol/modules/basicIssuanceModule.spec.ts @@ -311,10 +311,10 @@ describe("BasicIssuanceModule", () => { it("should properly call the pre-issue hooks", async () => { await subject(); - const retrievedSetToken = await issuanceHookContract.retrievedSetToken(); + const retrievedSetToken = await issuanceHookContract.retrievedIssueSetToken(); const retrievedIssueQuantity = await issuanceHookContract.retrievedIssueQuantity(); - const retrievedSender = await issuanceHookContract.retrievedSender(); - const retrievedTo = await issuanceHookContract.retrievedTo(); + const retrievedSender = await issuanceHookContract.retrievedIssueSender(); + const retrievedTo = await issuanceHookContract.retrievedIssueTo(); expect(retrievedSetToken).to.eq(subjectSetToken); expect(retrievedIssueQuantity).to.eq(subjectIssueQuantity); diff --git a/test/protocol/modules/basicIssuanceModuleV2.spec.ts b/test/protocol/modules/basicIssuanceModuleV2.spec.ts index 96dcd8520..819ff418f 100644 --- a/test/protocol/modules/basicIssuanceModuleV2.spec.ts +++ b/test/protocol/modules/basicIssuanceModuleV2.spec.ts @@ -600,7 +600,7 @@ describe("BasicIssuanceModuleV2", () => { return issuanceModule.redeem(subjectSetToken, subjectRedeemQuantity, subjectTo.address); } - it("should properly call the pre-issue hooks", async () => { + it("should properly call the pre-redeem hooks", async () => { await subject(); const retrievedSetToken = await issuanceHookContract.retrievedRedeemSetToken(); const retrievedQuantity = await issuanceHookContract.retrievedRedeemQuantity(); diff --git a/test/protocol/modules/customOracleNAVIssuanceModule.spec.ts b/test/protocol/modules/customOracleNAVIssuanceModule.spec.ts index 09aa812d6..2670823ba 100644 --- a/test/protocol/modules/customOracleNAVIssuanceModule.spec.ts +++ b/test/protocol/modules/customOracleNAVIssuanceModule.spec.ts @@ -2812,13 +2812,13 @@ describe("CustomOracleNavIssuanceModule", () => { it("should properly call the pre-issue hooks", async () => { await subject(); - const retrievedSetToken = await issuanceHookContract.retrievedSetToken(); - const retrievedIssueQuantity = await issuanceHookContract.retrievedIssueQuantity(); - const retrievedSender = await issuanceHookContract.retrievedSender(); - const retrievedTo = await issuanceHookContract.retrievedTo(); + const retrievedSetToken = await issuanceHookContract.retrievedRedeemSetToken(); + const retrievedQuantity = await issuanceHookContract.retrievedRedeemQuantity(); + const retrievedSender = await issuanceHookContract.retrievedRedeemSender(); + const retrievedTo = await issuanceHookContract.retrievedRedeemTo(); expect(retrievedSetToken).to.eq(subjectSetToken); - expect(retrievedIssueQuantity).to.eq(subjectSetTokenQuantity); + expect(retrievedQuantity).to.eq(subjectSetTokenQuantity); expect(retrievedSender).to.eq(owner.address); expect(retrievedTo).to.eq(subjectTo.address); }); diff --git a/test/protocol/modules/debtIssuanceModule.spec.ts b/test/protocol/modules/debtIssuanceModule.spec.ts index f8ca6ff21..7b31d2050 100644 --- a/test/protocol/modules/debtIssuanceModule.spec.ts +++ b/test/protocol/modules/debtIssuanceModule.spec.ts @@ -755,10 +755,10 @@ describe("DebtIssuanceModule", () => { preIssueHook = ADDRESS_ZERO; }); - it("should call the issuance hook", async () => { + it("should call the pre-issue hook", async () => { await subject(); - const setToken = await issuanceHook.retrievedSetToken(); + const setToken = await issuanceHook.retrievedIssueSetToken(); expect(setToken).to.eq(subjectSetToken); }); @@ -879,6 +879,7 @@ describe("DebtIssuanceModule", () => { setToken.address, subjectCaller.address, subjectTo, + preIssueHook, subjectQuantity, feeQuantity, ZERO @@ -998,6 +999,24 @@ describe("DebtIssuanceModule", () => { }); }); + describe("when manager issuance hook is defined", async () => { + before(async () => { + preIssueHook = issuanceHook.address; + }); + + after(async () => { + preIssueHook = ADDRESS_ZERO; + }); + + it("should call the pre-redeem hook", async () => { + await subject(); + + const setToken = await issuanceHook.retrievedRedeemSetToken(); + + expect(setToken).to.eq(subjectSetToken); + }); + }); + describe("when the issue quantity is 0", async () => { beforeEach(async () => { subjectQuantity = ZERO; @@ -1025,6 +1044,59 @@ describe("DebtIssuanceModule", () => { }); }); + describe("#updateManagerIssuanceHook", async () => { + let subjectSetToken: SetToken; + let subjectNewManagerIssuanceHook: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectCaller = manager; + subjectSetToken = setToken; + subjectNewManagerIssuanceHook = await getRandomAddress(); + }); + + async function subject(): Promise { + return await debtIssuance.connect(subjectCaller.wallet).updateManagerIssuanceHook( + subjectSetToken.address, + subjectNewManagerIssuanceHook + ); + } + + it("should update the manager issuance hook", async () => { + await subject(); + + const issuanceSettings = await debtIssuance.issuanceSettings(subjectSetToken.address); + expect(issuanceSettings.managerIssuanceHook).to.eq(subjectNewManagerIssuanceHook); + }); + + context("when caller is not the manager", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be the SetToken manager"); + }); + }); + + context("when module is not valid", async () => { + beforeEach(async () => { + const nonEnabledSetToken = await setup.createNonControllerEnabledSetToken( + [setup.weth.address], + [ether(1)], + [debtIssuance.address], + manager.address + ); + + subjectSetToken = nonEnabledSetToken; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be a valid and initialized SetToken"); + }); + }); + }); + describe("#updateFeeRecipient", async () => { let subjectSetToken: Address; let subjectNewFeeRecipient: Address; diff --git a/test/protocol/modules/debtIssuanceModuleV2.spec.ts b/test/protocol/modules/debtIssuanceModuleV2.spec.ts index 39bb6bb61..9151f9d32 100644 --- a/test/protocol/modules/debtIssuanceModuleV2.spec.ts +++ b/test/protocol/modules/debtIssuanceModuleV2.spec.ts @@ -325,10 +325,10 @@ describe("DebtIssuanceModuleV2", () => { preIssueHook = ADDRESS_ZERO; }); - it("should call the issuance hook", async () => { + it("should call the pre-issue hook", async () => { await subject(); - const setToken = await issuanceHook.retrievedSetToken(); + const setToken = await issuanceHook.retrievedIssueSetToken(); expect(setToken).to.eq(subjectSetToken); }); @@ -449,6 +449,7 @@ describe("DebtIssuanceModuleV2", () => { setToken.address, subjectCaller.address, subjectTo, + preIssueHook, subjectQuantity, feeQuantity, ZERO @@ -568,6 +569,24 @@ describe("DebtIssuanceModuleV2", () => { }); }); + describe("when manager issuance hook is defined", async () => { + before(async () => { + preIssueHook = issuanceHook.address; + }); + + after(async () => { + preIssueHook = ADDRESS_ZERO; + }); + + it("should call the pre-redeem hook", async () => { + await subject(); + + const setToken = await issuanceHook.retrievedRedeemSetToken(); + + expect(setToken).to.eq(subjectSetToken); + }); + }); + describe("when the issue quantity is 0", async () => { beforeEach(async () => { subjectQuantity = ZERO; diff --git a/test/protocol/modules/issuanceModule.spec.ts b/test/protocol/modules/issuanceModule.spec.ts index 2a52f54ed..da9467b8a 100644 --- a/test/protocol/modules/issuanceModule.spec.ts +++ b/test/protocol/modules/issuanceModule.spec.ts @@ -302,10 +302,10 @@ describe("IssuanceModule", () => { it("should properly call the pre-issue hooks", async () => { await subject(); - const retrievedSetToken = await issuanceHookContract.retrievedSetToken(); + const retrievedSetToken = await issuanceHookContract.retrievedIssueSetToken(); const retrievedIssueQuantity = await issuanceHookContract.retrievedIssueQuantity(); - const retrievedSender = await issuanceHookContract.retrievedSender(); - const retrievedTo = await issuanceHookContract.retrievedTo(); + const retrievedSender = await issuanceHookContract.retrievedIssueSender(); + const retrievedTo = await issuanceHookContract.retrievedIssueTo(); expect(retrievedSetToken).to.eq(subjectSetToken); expect(retrievedIssueQuantity).to.eq(subjectIssueQuantity); diff --git a/test/protocol/modules/navIssuanceModule.spec.ts b/test/protocol/modules/navIssuanceModule.spec.ts index 629d30cb4..824c0a150 100644 --- a/test/protocol/modules/navIssuanceModule.spec.ts +++ b/test/protocol/modules/navIssuanceModule.spec.ts @@ -2634,13 +2634,13 @@ describe("NavIssuanceModule", () => { it("should properly call the pre-issue hooks", async () => { await subject(); - const retrievedSetToken = await issuanceHookContract.retrievedSetToken(); - const retrievedIssueQuantity = await issuanceHookContract.retrievedIssueQuantity(); - const retrievedSender = await issuanceHookContract.retrievedSender(); - const retrievedTo = await issuanceHookContract.retrievedTo(); + const retrievedSetToken = await issuanceHookContract.retrievedRedeemSetToken(); + const retrievedQuantity = await issuanceHookContract.retrievedRedeemQuantity(); + const retrievedSender = await issuanceHookContract.retrievedRedeemSender(); + const retrievedTo = await issuanceHookContract.retrievedRedeemTo(); expect(retrievedSetToken).to.eq(subjectSetToken); - expect(retrievedIssueQuantity).to.eq(subjectSetTokenQuantity); + expect(retrievedQuantity).to.eq(subjectSetTokenQuantity); expect(retrievedSender).to.eq(owner.address); expect(retrievedTo).to.eq(subjectTo.address); }); diff --git a/test/protocol/modules/slippageIssuanceModule.spec.ts b/test/protocol/modules/slippageIssuanceModule.spec.ts index f81f6b634..cf43f510a 100644 --- a/test/protocol/modules/slippageIssuanceModule.spec.ts +++ b/test/protocol/modules/slippageIssuanceModule.spec.ts @@ -675,7 +675,7 @@ describe("SlippageIssuanceModule", () => { it("should call the issuance hook", async () => { await subject(); - const setToken = await issuanceHook.retrievedSetToken(); + const setToken = await issuanceHook.retrievedIssueSetToken(); expect(setToken).to.eq(subjectSetToken); }); diff --git a/utils/deploys/deployModules.ts b/utils/deploys/deployModules.ts index a91ad168b..6982f9c90 100644 --- a/utils/deploys/deployModules.ts +++ b/utils/deploys/deployModules.ts @@ -10,6 +10,7 @@ import { CompoundLeverageModule, CustomOracleNavIssuanceModule, DebtIssuanceModule, + DebtIssuanceModuleV2, GeneralIndexModule, GovernanceModule, IssuanceModule, @@ -71,7 +72,7 @@ export default class DeployModules { return await new DebtIssuanceModule__factory(this._deployerSigner).deploy(controller); } - public async deployDebtIssuanceModuleV2(controller: Address): Promise { + public async deployDebtIssuanceModuleV2(controller: Address): Promise { return await new DebtIssuanceModuleV2__factory(this._deployerSigner).deploy(controller); } From e14b40270473628e02662b4713b3b949283ad26e Mon Sep 17 00:00:00 2001 From: Noah Citron Date: Wed, 15 Dec 2021 16:39:45 -0500 Subject: [PATCH 4/6] mark issuance hook as address(0) for slippage issuance redeem --- .../modules/SlippageIssuanceModule.sol | 34 +++++++------------ .../modules/slippageIssuanceModule.spec.ts | 1 + 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/contracts/protocol/modules/SlippageIssuanceModule.sol b/contracts/protocol/modules/SlippageIssuanceModule.sol index 853c4a8c0..d54dc502d 100644 --- a/contracts/protocol/modules/SlippageIssuanceModule.sol +++ b/contracts/protocol/modules/SlippageIssuanceModule.sol @@ -38,17 +38,6 @@ import { ISetToken } from "../../interfaces/ISetToken.sol"; */ contract SlippageIssuanceModule is DebtIssuanceModule { - /* ============ Events ============ */ - - event SetTokenRedeemed( - ISetToken indexed _setToken, - address indexed _redeemer, - address indexed _to, - uint256 _quantity, - uint256 _managerFee, - uint256 _protocolFee - ); - constructor(IController _controller) public DebtIssuanceModule(_controller) {} /* ============ External Functions ============ */ @@ -172,23 +161,26 @@ contract SlippageIssuanceModule is DebtIssuanceModule { uint256 protocolFee ) = calculateTotalFees(_setToken, _setQuantity, isIssue); - ( - address[] memory components, - uint256[] memory equityUnits, - uint256[] memory debtUnits - ) = _calculateRequiredComponentIssuanceUnits(_setToken, quantityNetFees, isIssue); + { + ( + address[] memory components, + uint256[] memory equityUnits, + uint256[] memory debtUnits + ) = _calculateRequiredComponentIssuanceUnits(_setToken, quantityNetFees, isIssue); - // Validate the required token amounts don't exceed those passed by redeemer - _validateTokenTransferLimits(_checkedComponents, _minTokenAmountsOut, components, equityUnits, isIssue); + // Validate the required token amounts don't exceed those passed by redeemer + _validateTokenTransferLimits(_checkedComponents, _minTokenAmountsOut, components, equityUnits, isIssue); - _resolveDebtPositions(_setToken, quantityNetFees, isIssue, components, debtUnits); - _resolveEquityPositions(_setToken, quantityNetFees, _to, isIssue, components, equityUnits); - _resolveFees(_setToken, managerFee, protocolFee); + _resolveDebtPositions(_setToken, quantityNetFees, isIssue, components, debtUnits); + _resolveEquityPositions(_setToken, quantityNetFees, _to, isIssue, components, equityUnits); + _resolveFees(_setToken, managerFee, protocolFee); + } emit SetTokenRedeemed( _setToken, msg.sender, _to, + address(0), _setQuantity, managerFee, protocolFee diff --git a/test/protocol/modules/slippageIssuanceModule.spec.ts b/test/protocol/modules/slippageIssuanceModule.spec.ts index cf43f510a..7101d06e0 100644 --- a/test/protocol/modules/slippageIssuanceModule.spec.ts +++ b/test/protocol/modules/slippageIssuanceModule.spec.ts @@ -896,6 +896,7 @@ describe("SlippageIssuanceModule", () => { setToken.address, subjectCaller.address, subjectTo, + ADDRESS_ZERO, subjectQuantity, feeQuantity, ZERO From 3272121a6208c85bb5ddc2ac9537c373774ca2d5 Mon Sep 17 00:00:00 2001 From: Noah Citron Date: Wed, 15 Dec 2021 17:35:49 -0500 Subject: [PATCH 5/6] use inheritance for BasicIssuanceModuleV2 --- .../protocol/modules/BasicIssuanceModule.sol | 11 +- .../modules/BasicIssuanceModuleV2.sol | 151 +----------------- .../protocol/modules/DebtIssuanceModule.sol | 5 + .../protocol/modules/DebtIssuanceModuleV2.sol | 3 + .../modules/basicIssuanceModule.spec.ts | 1 + 5 files changed, 25 insertions(+), 146 deletions(-) diff --git a/contracts/protocol/modules/BasicIssuanceModule.sol b/contracts/protocol/modules/BasicIssuanceModule.sol index 5d1a0c43a..99fc5e565 100644 --- a/contracts/protocol/modules/BasicIssuanceModule.sol +++ b/contracts/protocol/modules/BasicIssuanceModule.sol @@ -38,6 +38,11 @@ import { PreciseUnitMath } from "../../lib/PreciseUnitMath.sol"; * * Module that enables issuance and redemption functionality on a SetToken. This is a module that is * required to bring the totalSupply of a Set above 0. + * + * CHANGELOG 12/15/2021: + * - update removeModule and redeem to be virtual + * - add _hookContract parameter to SetTokenRedeemed + * - always set _hookContract to address(0) when emitting SetTokenRedeemed */ contract BasicIssuanceModule is ModuleBase, ReentrancyGuard { using Invoke for ISetToken; @@ -60,6 +65,7 @@ contract BasicIssuanceModule is ModuleBase, ReentrancyGuard { address indexed _setToken, address indexed _redeemer, address indexed _to, + address _hookContract, uint256 _quantity ); @@ -136,6 +142,7 @@ contract BasicIssuanceModule is ModuleBase, ReentrancyGuard { address _to ) external + virtual nonReentrant onlyValidAndInitializedSet(_setToken) { @@ -163,7 +170,7 @@ contract BasicIssuanceModule is ModuleBase, ReentrancyGuard { ); } - emit SetTokenRedeemed(address(_setToken), msg.sender, _to, _quantity); + emit SetTokenRedeemed(address(_setToken), msg.sender, _to, address(0), _quantity); } /** @@ -190,7 +197,7 @@ contract BasicIssuanceModule is ModuleBase, ReentrancyGuard { * Reverts as this module should not be removable after added. Users should always * have a way to redeem their Sets */ - function removeModule() external override { + function removeModule() external virtual override { revert("The BasicIssuanceModule module cannot be removed"); } diff --git a/contracts/protocol/modules/BasicIssuanceModuleV2.sol b/contracts/protocol/modules/BasicIssuanceModuleV2.sol index 9a5f11fab..91a2f1fc0 100644 --- a/contracts/protocol/modules/BasicIssuanceModuleV2.sol +++ b/contracts/protocol/modules/BasicIssuanceModuleV2.sol @@ -24,6 +24,7 @@ import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.s import { SafeCast } from "@openzeppelin/contracts/utils/SafeCast.sol"; import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; +import { BasicIssuanceModule } from "../../protocol/modules/BasicIssuanceModule.sol"; import { IController } from "../../interfaces/IController.sol"; import { IManagerIssuanceHook } from "../../interfaces/IManagerIssuanceHook.sol"; import { Invoke } from "../lib/Invoke.sol"; @@ -39,7 +40,7 @@ import { PreciseUnitMath } from "../../lib/PreciseUnitMath.sol"; * Module that enables issuance and redemption functionality on a SetToken. This is a module that is * required to bring the totalSupply of a Set above 0. */ -contract BasicIssuanceModuleV2 is ModuleBase, ReentrancyGuard { +contract BasicIssuanceModuleV2 is BasicIssuanceModule { using Invoke for ISetToken; using Position for ISetToken.Position; using Position for ISetToken; @@ -47,28 +48,6 @@ contract BasicIssuanceModuleV2 is ModuleBase, ReentrancyGuard { using SafeMath for uint256; using SafeCast for int256; - /* ============ Events ============ */ - - event SetTokenIssued( - address indexed _setToken, - address indexed _issuer, - address indexed _to, - address _hookContract, - uint256 _quantity - ); - event SetTokenRedeemed( - address indexed _setToken, - address indexed _redeemer, - address indexed _to, - address _hookContract, - uint256 _quantity - ); - - /* ============ State Variables ============ */ - - // Mapping of SetToken to Issuance hook configurations - mapping(ISetToken => IManagerIssuanceHook) public managerIssuanceHook; - /* ============ Constructor ============ */ /** @@ -76,53 +55,10 @@ contract BasicIssuanceModuleV2 is ModuleBase, ReentrancyGuard { * * @param _controller Address of controller contract */ - constructor(IController _controller) public ModuleBase(_controller) {} + constructor(IController _controller) public BasicIssuanceModule(_controller) {} /* ============ External Functions ============ */ - /** - * Deposits the SetToken's position components into the SetToken and mints the SetToken of the given quantity - * to the specified _to address. This function only handles Default Positions (positionState = 0). - * - * @param _setToken Instance of the SetToken contract - * @param _quantity Quantity of the SetToken to mint - * @param _to Address to mint SetToken to - */ - function issue( - ISetToken _setToken, - uint256 _quantity, - address _to - ) - external - nonReentrant - onlyValidAndInitializedSet(_setToken) - { - require(_quantity > 0, "Issue quantity must be > 0"); - - address hookContract = _callPreIssueHooks(_setToken, _quantity, msg.sender, _to); - - ( - address[] memory components, - uint256[] memory componentQuantities - ) = getRequiredComponentUnitsForIssue(_setToken, _quantity); - - // For each position, transfer the required underlying to the SetToken - for (uint256 i = 0; i < components.length; i++) { - // Transfer the component to the SetToken - transferFrom( - IERC20(components[i]), - msg.sender, - address(_setToken), - componentQuantities[i] - ); - } - - // Mint the SetToken - _setToken.mint(_to, _quantity); - - emit SetTokenIssued(address(_setToken), msg.sender, _to, hookContract, _quantity); - } - /** * Redeems the SetToken's positions and sends the components of the given * quantity to the caller. This function only handles Default Positions (positionState = 0). @@ -137,6 +73,7 @@ contract BasicIssuanceModuleV2 is ModuleBase, ReentrancyGuard { address _to ) external + override nonReentrant onlyValidAndInitializedSet(_setToken) { @@ -170,23 +107,10 @@ contract BasicIssuanceModuleV2 is ModuleBase, ReentrancyGuard { } /** - * Initializes this module to the SetToken with issuance-related hooks. Only callable by the SetToken's manager. - * Hook addresses are optional. Address(0) means that no hook will be called - * - * @param _setToken Instance of the SetToken to issue - * @param _preIssueHook Instance of the Manager Contract with the Pre-Issuance Hook function + * SET TOKEN ONLY: Allows removal (and deletion of state) of BasicIssuanceModuleV2 */ - function initialize( - ISetToken _setToken, - IManagerIssuanceHook _preIssueHook - ) - external - onlySetManager(_setToken, msg.sender) - onlyValidAndPendingSet(_setToken) - { - managerIssuanceHook[_setToken] = _preIssueHook; - - _setToken.initializeModule(); + function removeModule() external override { + delete managerIssuanceHook[ISetToken(msg.sender)]; } /** @@ -207,69 +131,8 @@ contract BasicIssuanceModuleV2 is ModuleBase, ReentrancyGuard { managerIssuanceHook[_setToken] = _newHook; } - /** - * SET TOKEN ONLY: Allows removal (and deletion of state) of BasicIssuanceModuleV2 - */ - function removeModule() external override { - delete managerIssuanceHook[ISetToken(msg.sender)]; - } - - /* ============ External Getter Functions ============ */ - - /** - * Retrieves the addresses and units required to mint a particular quantity of SetToken. - * - * @param _setToken Instance of the SetToken to issue - * @param _quantity Quantity of SetToken to issue - * @return address[] List of component addresses - * @return uint256[] List of component units required to issue the quantity of SetTokens - */ - function getRequiredComponentUnitsForIssue( - ISetToken _setToken, - uint256 _quantity - ) - public - view - onlyValidAndInitializedSet(_setToken) - returns (address[] memory, uint256[] memory) - { - address[] memory components = _setToken.getComponents(); - - uint256[] memory notionalUnits = new uint256[](components.length); - - for (uint256 i = 0; i < components.length; i++) { - require(!_setToken.hasExternalPosition(components[i]), "Only default positions are supported"); - - notionalUnits[i] = _setToken.getDefaultPositionRealUnit(components[i]).toUint256().preciseMulCeil(_quantity); - } - - return (components, notionalUnits); - } - /* ============ Internal Functions ============ */ - /** - * If a pre-issue hook has been configured, call the external-protocol contract's pre-issue function. - * Pre-issue hook logic can contain arbitrary logic including validations, external function calls, etc. - */ - function _callPreIssueHooks( - ISetToken _setToken, - uint256 _quantity, - address _caller, - address _to - ) - internal - returns(address) - { - IManagerIssuanceHook preIssueHook = managerIssuanceHook[_setToken]; - if (address(preIssueHook) != address(0)) { - preIssueHook.invokePreIssueHook(_setToken, _quantity, _caller, _to); - return address(preIssueHook); - } - - return address(0); - } - /** * If a pre-issue hook has been configured, call the external-protocol contract's pre-redeem function. * Pre-issue hook logic can contain arbitrary logic including validations, external function calls, etc. diff --git a/contracts/protocol/modules/DebtIssuanceModule.sol b/contracts/protocol/modules/DebtIssuanceModule.sol index 3b8c5e7f6..dec753684 100644 --- a/contracts/protocol/modules/DebtIssuanceModule.sol +++ b/contracts/protocol/modules/DebtIssuanceModule.sol @@ -44,6 +44,11 @@ import { PreciseUnitMath } from "../../lib/PreciseUnitMath.sol"; * 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. + * + * CHANGELOG 12/15/2021: + * - add _hookContract parameter to SetTokenRedeemed + * - call manager pre-redeem hooks on redemption + * - add function for updating the manager hooks */ contract DebtIssuanceModule is ModuleBase, ReentrancyGuard { diff --git a/contracts/protocol/modules/DebtIssuanceModuleV2.sol b/contracts/protocol/modules/DebtIssuanceModuleV2.sol index d722ac6d2..99a99005a 100644 --- a/contracts/protocol/modules/DebtIssuanceModuleV2.sol +++ b/contracts/protocol/modules/DebtIssuanceModuleV2.sol @@ -49,6 +49,9 @@ import { Position } from "../lib/Position.sol"; * * The getRequiredComponentIssuanceUnits function on this module assumes that Default token balances will be synced on every issuance * and redemption. If token balances are not being synced it will over-estimate the amount of tokens required to issue a Set. + * + * CHANGELOG 12/15/2021: + * - call manager pre-redeem hooks on redemption */ contract DebtIssuanceModuleV2 is DebtIssuanceModule { using Position for uint256; diff --git a/test/protocol/modules/basicIssuanceModule.spec.ts b/test/protocol/modules/basicIssuanceModule.spec.ts index 0fefb184f..bd32bdd93 100644 --- a/test/protocol/modules/basicIssuanceModule.spec.ts +++ b/test/protocol/modules/basicIssuanceModule.spec.ts @@ -412,6 +412,7 @@ describe("BasicIssuanceModule", () => { subjectSetToken, subjectCaller.address, subjectTo, + ADDRESS_ZERO, subjectRedeemQuantity ); }); From 64713f5aee93fe2c67aa1c5cba100566925642ad Mon Sep 17 00:00:00 2001 From: Noah Citron Date: Wed, 15 Dec 2021 17:58:28 -0500 Subject: [PATCH 6/6] remove unused imports --- contracts/protocol/modules/BasicIssuanceModuleV2.sol | 2 -- 1 file changed, 2 deletions(-) diff --git a/contracts/protocol/modules/BasicIssuanceModuleV2.sol b/contracts/protocol/modules/BasicIssuanceModuleV2.sol index 91a2f1fc0..9f402a830 100644 --- a/contracts/protocol/modules/BasicIssuanceModuleV2.sol +++ b/contracts/protocol/modules/BasicIssuanceModuleV2.sol @@ -19,7 +19,6 @@ pragma solidity 0.6.10; pragma experimental "ABIEncoderV2"; -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import { SafeCast } from "@openzeppelin/contracts/utils/SafeCast.sol"; import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; @@ -29,7 +28,6 @@ import { IController } from "../../interfaces/IController.sol"; import { IManagerIssuanceHook } from "../../interfaces/IManagerIssuanceHook.sol"; import { Invoke } from "../lib/Invoke.sol"; import { ISetToken } from "../../interfaces/ISetToken.sol"; -import { ModuleBase } from "../lib/ModuleBase.sol"; import { Position } from "../lib/Position.sol"; import { PreciseUnitMath } from "../../lib/PreciseUnitMath.sol";