diff --git a/spot-contracts/contracts/_interfaces/IVault.sol b/spot-contracts/contracts/_interfaces/IVault.sol index 22339e40..b12289c9 100644 --- a/spot-contracts/contracts/_interfaces/IVault.sol +++ b/spot-contracts/contracts/_interfaces/IVault.sol @@ -49,6 +49,9 @@ interface IVault { /// @notice Recovers deployed funds. function recover() external; + /// @notice Recovers a given deployed asset. + function recover(IERC20Upgradeable token) external; + /// @notice Deposits the underlying asset from {msg.sender} into the vault and mints notes. /// @param amount The amount tokens to be deposited into the vault. /// @return The amount of notes. diff --git a/spot-contracts/contracts/vaults/RolloverVault.sol b/spot-contracts/contracts/vaults/RolloverVault.sol index 19bf7eff..77ca95d1 100644 --- a/spot-contracts/contracts/vaults/RolloverVault.sol +++ b/spot-contracts/contracts/vaults/RolloverVault.sol @@ -164,7 +164,7 @@ contract RolloverVault is /// @inheritdoc IVault /// @dev Its safer to call `recover` before `deploy` so the full available balance can be deployed. /// Reverts if there are no funds to deploy. - function deploy() public nonReentrant whenNotPaused { + function deploy() public override nonReentrant whenNotPaused { TrancheData memory td = _tranche(perp.getDepositBond()); if (_rollover(perp, td) == 0) { revert NoDeployment(); @@ -172,10 +172,20 @@ contract RolloverVault is } /// @inheritdoc IVault - function recover() public nonReentrant whenNotPaused { + function recover() public override nonReentrant whenNotPaused { _redeemTranches(); } + /// @inheritdoc IVault + /// @dev Reverts when attempting to recover a tranche which is not part of the deployed list. + /// In the case of immature redemption, this method will recover other sibling tranches as well. + function recover(IERC20Upgradeable token) external override nonReentrant whenNotPaused { + if (!_deployed.contains(address(token))) { + revert UnexpectedAsset(token); + } + _redeemTranche(ITranche(address(token))); + } + /// @inheritdoc IVault function deposit(uint256 amount) external override nonReentrant whenNotPaused returns (uint256) { uint256 totalSupply_ = totalSupply(); @@ -283,6 +293,7 @@ contract RolloverVault is // Private write methods /// @dev Deposits underlying balance into the provided bond and receives tranche tokens in return. + /// And performs some book-keeping to keep track of the vault's assets. function _tranche(IBondController bond) private returns (TrancheData memory) { // Get bond's tranche data TrancheData memory td = bond.getTrancheData(); @@ -309,6 +320,7 @@ contract RolloverVault is } /// @dev Rolls over freshly tranched tokens from the given bond for older tranches (close to maturity) from perp. + /// And performs some book-keeping to keep track of the vault's assets. /// @return The amount of perps rolled over. function _rollover(IPerpetualTranche perp_, TrancheData memory td) private returns (uint256) { // NOTE: The first element of the list is the mature tranche, @@ -385,37 +397,12 @@ contract RolloverVault is return totalPerpRolledOver; } - /// @notice Redeems the deployed tranche tokens for the underlying asset. + /// @dev Redeems all deployed tranches for the underlying asset and + /// performs internal book-keeping to keep track of the vault assets. function _redeemTranches() private { uint256 deployedCount_ = _deployed.length(); - if (deployedCount_ <= 0) { - return; - } - for (uint256 i = 0; i < deployedCount_; i++) { - ITranche tranche = ITranche(_deployed.at(i)); - IBondController bond = IBondController(tranche.bond()); - - // if bond has matured, redeem the tranche token - if (bond.timeToMaturity() <= 0) { - if (!bond.isMature()) { - bond.mature(); - } - bond.redeemMature(address(tranche), tranche.balanceOf(address(this))); - } - // else redeem using proportional balances, redeems all tranches part of the bond - else { - TrancheData memory td; - uint256[] memory trancheAmts; - (td, trancheAmts) = bond.computeRedeemableTrancheAmounts(address(this)); - - // NOTE: It is guaranteed that if one tranche amount is zero, all amounts are zeros. - if (trancheAmts[0] == 0) { - continue; - } - - bond.redeem(trancheAmts); - } + _execTrancheRedemption(ITranche(_deployed.at(i))); } // sync holdings @@ -428,6 +415,49 @@ contract RolloverVault is _syncAsset(underlying); } + /// @dev Redeems the given tranche for the underlying asset and + /// performs internal book-keeping to keep track of the vault assets. + function _redeemTranche(ITranche tranche) private { + _execTrancheRedemption(tranche); + + // sync holdings + // We traverse in reverse order here as well, as immature redemptions + // might remove many assets from the deployed list. + for (uint256 i = _deployed.length(); i > 0; i--) { + _syncDeployedAsset(IERC20Upgradeable(_deployed.at(i - 1))); + } + _syncAsset(underlying); + } + + /// @dev Low level method that redeems the given deployed tranche tokens for the underlying asset. + /// When the tranche is not up for redemption, its a no-op. + /// This function should NOT be called directly, use `_redeemTranches` or `_redeemTranche` + /// which wrap this function with the internal book-keeping necessary to keep track of the vault's assets. + function _execTrancheRedemption(ITranche tranche) private { + IBondController bond = IBondController(tranche.bond()); + + // if bond has matured, redeem the tranche token + if (bond.timeToMaturity() <= 0) { + if (!bond.isMature()) { + bond.mature(); + } + bond.redeemMature(address(tranche), tranche.balanceOf(address(this))); + } + // else redeem using proportional balances, redeems all tranches part of the bond + else { + TrancheData memory td; + uint256[] memory trancheAmts; + (td, trancheAmts) = bond.computeRedeemableTrancheAmounts(address(this)); + + // NOTE: It is guaranteed that if one tranche amount is zero, all amounts are zeros. + if (trancheAmts[0] == 0) { + return; + } + + bond.redeem(trancheAmts); + } + } + /// @dev Logs the token balance held by the vault. /// @return The Vault's token balance. function _syncAsset(IERC20Upgradeable token) private returns (uint256) { diff --git a/spot-contracts/test/vaults/RolloverVault_recover.ts b/spot-contracts/test/vaults/RolloverVault_recover.ts index c306a640..98976ba3 100644 --- a/spot-contracts/test/vaults/RolloverVault_recover.ts +++ b/spot-contracts/test/vaults/RolloverVault_recover.ts @@ -6,6 +6,7 @@ import { smock } from "@defi-wonderland/smock"; import { setupCollateralToken, mintCollteralToken, + createBondWithFactory, setupBondFactory, depositIntoBond, bondAt, @@ -162,11 +163,11 @@ describe("RolloverVault", function () { await network.provider.send("hardhat_reset"); }); - describe("#recover", function () { + describe("#recover()", function () { describe("when no asset is deployed", function () { it("should be a no-op", async function () { - await vault.recover(); - await expect(vault.recover()).not.to.be.reverted; + await vault["recover()"](); + await expect(vault["recover()"]()).not.to.be.reverted; expect(await vault.deployedCount()).to.eq(0); }); }); @@ -205,7 +206,7 @@ describe("RolloverVault", function () { }); describe("when its not mature", function () { it("should be a no-op", async function () { - await expect(vault.recover()).not.to.be.reverted; + await expect(vault["recover()"]()).not.to.be.reverted; await checkVaultAssetComposition( vault, [collateralToken, currentTranchesIn[2], perp], @@ -220,12 +221,12 @@ describe("RolloverVault", function () { await advancePerpQueueToBondMaturity(perp, currentBondIn); }); it("should recover", async function () { - await expect(vault.recover()).not.to.be.reverted; + await expect(vault["recover()"]()).not.to.be.reverted; expect(await vault.deployedCount()).to.eq(0); await checkVaultAssetComposition(vault, [collateralToken, perp], [toFixedPtAmt("10"), toFixedPtAmt("0")]); }); it("should sync assets", async function () { - const tx = vault.recover(); + const tx = vault["recover()"](); await expect(tx).to.emit(vault, "AssetSynced").withArgs(collateralToken.address, toFixedPtAmt("10")); await expect(tx).to.emit(vault, "AssetSynced").withArgs(currentTranchesIn[2].address, toFixedPtAmt("0")); }); @@ -261,7 +262,7 @@ describe("RolloverVault", function () { describe("when no redemption", function () { it("should be a no-op", async function () { - await expect(vault.recover()).not.to.be.reverted; + await expect(vault["recover()"]()).not.to.be.reverted; await checkVaultAssetComposition( vault, [collateralToken, currentTranchesIn[1], currentTranchesIn[2], perp], @@ -278,12 +279,12 @@ describe("RolloverVault", function () { await advancePerpQueueToBondMaturity(perp, currentBondIn); }); it("should recover", async function () { - await expect(vault.recover()).not.to.be.reverted; + await expect(vault["recover()"]()).not.to.be.reverted; expect(await vault.deployedCount()).to.eq(0); await checkVaultAssetComposition(vault, [collateralToken, perp], [toFixedPtAmt("10"), toFixedPtAmt("0")]); }); it("should sync assets", async function () { - const tx = vault.recover(); + const tx = vault["recover()"](); await expect(tx).to.emit(vault, "AssetSynced").withArgs(collateralToken.address, toFixedPtAmt("10")); await expect(tx).to.emit(vault, "AssetSynced").withArgs(currentTranchesIn[1].address, toFixedPtAmt("0")); await expect(tx).to.emit(vault, "AssetSynced").withArgs(currentTranchesIn[2].address, toFixedPtAmt("0")); @@ -333,7 +334,7 @@ describe("RolloverVault", function () { describe("without reminder", function () { it("should recover", async function () { - await expect(vault.recover()).not.to.be.reverted; + await expect(vault["recover()"]()).not.to.be.reverted; expect(await vault.deployedCount()).to.eq(2); await checkVaultAssetComposition( vault, @@ -342,7 +343,7 @@ describe("RolloverVault", function () { ); }); it("should sync assets", async function () { - const tx = vault.recover(); + const tx = vault["recover()"](); await expect(tx).to.emit(vault, "AssetSynced").withArgs(collateralToken.address, toFixedPtAmt("2008")); await expect(tx).to.emit(vault, "AssetSynced").withArgs(currentTranchesIn[0].address, toFixedPtAmt("0")); await expect(tx).to.emit(vault, "AssetSynced").withArgs(currentTranchesIn[1].address, toFixedPtAmt("0")); @@ -380,7 +381,7 @@ describe("RolloverVault", function () { ); }); it("should recover", async function () { - await expect(vault.recover()).not.to.be.reverted; + await expect(vault["recover()"]()).not.to.be.reverted; expect(await vault.deployedCount()).to.eq(3); await checkVaultAssetComposition( vault, @@ -389,7 +390,270 @@ describe("RolloverVault", function () { ); }); it("should sync assets", async function () { - const tx = vault.recover(); + const tx = vault["recover()"](); + await expect(tx).to.emit(vault, "AssetSynced").withArgs(collateralToken.address, toFixedPtAmt("2008")); + await expect(tx).to.emit(vault, "AssetSynced").withArgs(currentTranchesIn[0].address, toFixedPtAmt("0")); + await expect(tx).to.emit(vault, "AssetSynced").withArgs(currentTranchesIn[1].address, toFixedPtAmt("0")); + await expect(tx).to.emit(vault, "AssetSynced").withArgs(currentTranchesIn[2].address, toFixedPtAmt("1")); + await expect(tx).to.emit(vault, "AssetSynced").withArgs(newTranchesIn[1].address, toFixedPtAmt("3000")); + await expect(tx).to.emit(vault, "AssetSynced").withArgs(newTranchesIn[2].address, toFixedPtAmt("5000")); + }); + }); + }); + }); + }); + + describe("#recover(address)", function () { + describe("when no asset is deployed", function () { + it("should revert", async function () { + await vault["recover()"](); + await expect(vault["recover(address)"](collateralToken.address)).to.be.revertedWith("UnexpectedAsset"); + expect(await vault.deployedCount()).to.eq(0); + }); + }); + + describe("when one asset deployed", function () { + let currentBondIn: Contract, currentTranchesIn: Contract[]; + beforeEach(async function () { + await advancePerpQueueToBondMaturity(perp, rolloverInBond); + currentBondIn = await bondAt(await perp.callStatic.getDepositBond()); + currentTranchesIn = await getTranches(currentBondIn); + + await pricingStrategy.computeTranchePrice + .whenCalledWith(currentTranchesIn[0].address) + .returns(toPriceFixedPtAmt("1")); + await discountStrategy.computeTrancheDiscount + .whenCalledWith(currentTranchesIn[0].address) + .returns(toDiscountFixedPtAmt("1")); + + await pricingStrategy.computeTranchePrice + .whenCalledWith(currentTranchesIn[1].address) + .returns(toPriceFixedPtAmt("1")); + await discountStrategy.computeTrancheDiscount + .whenCalledWith(currentTranchesIn[1].address) + .returns(toDiscountFixedPtAmt("1")); + + await collateralToken.transfer(vault.address, toFixedPtAmt("10")); + + await vault.deploy(); + await checkVaultAssetComposition( + vault, + [collateralToken, currentTranchesIn[2], perp], + [toFixedPtAmt("5"), toFixedPtAmt("5"), toFixedPtAmt("0")], + ); + expect(await vault.deployedCount()).to.eq(1); + expect(await vault.deployedAt(0)).to.eq(currentTranchesIn[2].address); + }); + describe("when address is not valid", function () { + it("should be reverted", async function () { + await expect(vault["recover(address)"](collateralToken.address)).to.be.revertedWith("UnexpectedAsset"); + }); + }); + describe("when belongs to a malicious tranche", function () { + it("should be reverted", async function () { + const maliciousBond = await createBondWithFactory(bondFactory, collateralToken, [1, 999], 100000000000); + await collateralToken.approve(maliciousBond.address, toFixedPtAmt("1")); + await maliciousBond.deposit(toFixedPtAmt("1")); + const maliciousTranches = await getTranches(maliciousBond); + await maliciousTranches[1].transfer( + vault.address, + maliciousTranches[1].balanceOf(await deployer.getAddress()), + ); + await expect(vault["recover(address)"](maliciousTranches[1].address)).to.be.revertedWith("UnexpectedAsset"); + }); + }); + describe("when its not mature", function () { + it("should be a no-op", async function () { + await expect(vault["recover(address)"](currentTranchesIn[2].address)).not.to.be.reverted; + await checkVaultAssetComposition( + vault, + [collateralToken, currentTranchesIn[2], perp], + [toFixedPtAmt("5"), toFixedPtAmt("5"), toFixedPtAmt("0")], + ); + expect(await vault.deployedCount()).to.eq(1); + expect(await vault.deployedAt(0)).to.eq(currentTranchesIn[2].address); + }); + }); + describe("when its mature", function () { + beforeEach(async function () { + await advancePerpQueueToBondMaturity(perp, currentBondIn); + }); + it("should recover", async function () { + await expect(vault["recover(address)"](currentTranchesIn[2].address)).not.to.be.reverted; + expect(await vault.deployedCount()).to.eq(0); + await checkVaultAssetComposition(vault, [collateralToken, perp], [toFixedPtAmt("10"), toFixedPtAmt("0")]); + }); + it("should sync assets", async function () { + const tx = vault["recover()"](); + await expect(tx).to.emit(vault, "AssetSynced").withArgs(collateralToken.address, toFixedPtAmt("10")); + await expect(tx).to.emit(vault, "AssetSynced").withArgs(currentTranchesIn[2].address, toFixedPtAmt("0")); + }); + }); + }); + + describe("when many assets are deployed", function () { + let currentBondIn: Contract, currentTranchesIn: Contract[], newBondIn: Contract, newTranchesIn: Contract[]; + beforeEach(async function () { + await advancePerpQueueToBondMaturity(perp, rolloverInBond); + currentBondIn = await bondAt(await perp.callStatic.getDepositBond()); + currentTranchesIn = await getTranches(currentBondIn); + + await pricingStrategy.computeTranchePrice + .whenCalledWith(currentTranchesIn[0].address) + .returns(toPriceFixedPtAmt("1")); + await discountStrategy.computeTrancheDiscount + .whenCalledWith(currentTranchesIn[0].address) + .returns(toDiscountFixedPtAmt("1")); + + await collateralToken.transfer(vault.address, toFixedPtAmt("10")); + await vault.deploy(); + + await checkVaultAssetComposition( + vault, + [collateralToken, currentTranchesIn[1], currentTranchesIn[2], perp], + [toFixedPtAmt("2"), toFixedPtAmt("3"), toFixedPtAmt("5"), toFixedPtAmt("0")], + ); + expect(await vault.deployedCount()).to.eq(2); + expect(await vault.deployedAt(0)).to.eq(currentTranchesIn[2].address); + expect(await vault.deployedAt(1)).to.eq(currentTranchesIn[1].address); + }); + + describe("when no redemption", function () { + it("should be a no-op", async function () { + await expect(vault["recover(address)"](currentTranchesIn[1].address)).not.to.be.reverted; + await checkVaultAssetComposition( + vault, + [collateralToken, currentTranchesIn[1], currentTranchesIn[2], perp], + [toFixedPtAmt("2"), toFixedPtAmt("3"), toFixedPtAmt("5"), toFixedPtAmt("0")], + ); + expect(await vault.deployedCount()).to.eq(2); + expect(await vault.deployedAt(0)).to.eq(currentTranchesIn[2].address); + expect(await vault.deployedAt(1)).to.eq(currentTranchesIn[1].address); + }); + }); + + describe("when mature redemption", function () { + beforeEach(async function () { + await advancePerpQueueToBondMaturity(perp, currentBondIn); + }); + it("should recover", async function () { + await expect(vault["recover(address)"](currentTranchesIn[1].address)).not.to.be.reverted; + await checkVaultAssetComposition( + vault, + [collateralToken, currentTranchesIn[2], perp], + [toFixedPtAmt("5"), toFixedPtAmt("5"), toFixedPtAmt("0")], + ); + expect(await vault.deployedCount()).to.eq(1); + expect(await vault.deployedAt(0)).to.eq(currentTranchesIn[2].address); + }); + it("should sync assets", async function () { + const tx = vault["recover(address)"](currentTranchesIn[1].address); + await expect(tx).to.emit(vault, "AssetSynced").withArgs(collateralToken.address, toFixedPtAmt("5")); + await expect(tx).to.emit(vault, "AssetSynced").withArgs(currentTranchesIn[1].address, toFixedPtAmt("0")); + await expect(tx).to.emit(vault, "AssetSynced").withArgs(currentTranchesIn[2].address, toFixedPtAmt("5")); + }); + }); + + describe("when immature redemption", function () { + beforeEach(async function () { + await advancePerpQueueToRollover(perp, currentBondIn); + + newBondIn = await bondAt(await perp.callStatic.getDepositBond()); + newTranchesIn = await getTranches(newBondIn); + + await pricingStrategy.computeTranchePrice + .whenCalledWith(newTranchesIn[0].address) + .returns(toPriceFixedPtAmt("1")); + await discountStrategy.computeTrancheDiscount + .whenCalledWith(newTranchesIn[0].address) + .returns(toDiscountFixedPtAmt("1")); + + await collateralToken.transfer(vault.address, toFixedPtAmt("9998")); + await vault.deploy(); + + expect(await vault.deployedCount()).to.eq(5); + await checkVaultAssetComposition( + vault, + [ + collateralToken, + currentTranchesIn[0], + currentTranchesIn[1], + currentTranchesIn[2], + newTranchesIn[1], + newTranchesIn[2], + perp, + ], + [ + toFixedPtAmt("1998"), + toFixedPtAmt("2"), + toFixedPtAmt("3"), + toFixedPtAmt("5"), + toFixedPtAmt("3000"), + toFixedPtAmt("5000"), + toFixedPtAmt("0"), + ], + ); + }); + + describe("without reminder", function () { + it("should recover", async function () { + await expect(vault["recover(address)"](currentTranchesIn[1].address)).not.to.be.reverted; + expect(await vault.deployedCount()).to.eq(2); + await checkVaultAssetComposition( + vault, + [collateralToken, newTranchesIn[1], newTranchesIn[2], perp], + [toFixedPtAmt("2008"), toFixedPtAmt("3000"), toFixedPtAmt("5000"), toFixedPtAmt("0")], + ); + }); + it("should sync assets", async function () { + const tx = vault["recover()"](); + await expect(tx).to.emit(vault, "AssetSynced").withArgs(collateralToken.address, toFixedPtAmt("2008")); + await expect(tx).to.emit(vault, "AssetSynced").withArgs(currentTranchesIn[0].address, toFixedPtAmt("0")); + await expect(tx).to.emit(vault, "AssetSynced").withArgs(currentTranchesIn[1].address, toFixedPtAmt("0")); + await expect(tx).to.emit(vault, "AssetSynced").withArgs(currentTranchesIn[2].address, toFixedPtAmt("0")); + await expect(tx).to.emit(vault, "AssetSynced").withArgs(newTranchesIn[1].address, toFixedPtAmt("3000")); + await expect(tx).to.emit(vault, "AssetSynced").withArgs(newTranchesIn[2].address, toFixedPtAmt("5000")); + }); + }); + + describe("with reminder", function () { + beforeEach(async function () { + await depositIntoBond(currentBondIn, toFixedPtAmt("1000"), deployer); + await currentTranchesIn[2].transfer(vault.address, toFixedPtAmt("1")); + expect(await vault.deployedCount()).to.eq(5); + await checkVaultAssetComposition( + vault, + [ + collateralToken, + currentTranchesIn[0], + currentTranchesIn[1], + currentTranchesIn[2], + newTranchesIn[1], + newTranchesIn[2], + perp, + ], + [ + toFixedPtAmt("1998"), + toFixedPtAmt("2"), + toFixedPtAmt("3"), + toFixedPtAmt("6"), + toFixedPtAmt("3000"), + toFixedPtAmt("5000"), + toFixedPtAmt("0"), + ], + ); + }); + it("should recover", async function () { + await expect(vault["recover(address)"](currentTranchesIn[0].address)).not.to.be.reverted; + expect(await vault.deployedCount()).to.eq(3); + await checkVaultAssetComposition( + vault, + [collateralToken, currentTranchesIn[2], newTranchesIn[1], newTranchesIn[2], perp], + [toFixedPtAmt("2008"), toFixedPtAmt("1"), toFixedPtAmt("3000"), toFixedPtAmt("5000"), toFixedPtAmt("0")], + ); + }); + it("should sync assets", async function () { + const tx = vault["recover()"](); await expect(tx).to.emit(vault, "AssetSynced").withArgs(collateralToken.address, toFixedPtAmt("2008")); await expect(tx).to.emit(vault, "AssetSynced").withArgs(currentTranchesIn[0].address, toFixedPtAmt("0")); await expect(tx).to.emit(vault, "AssetSynced").withArgs(currentTranchesIn[1].address, toFixedPtAmt("0"));