diff --git a/contracts/src/Interfaces/IRedemptionHelper.sol b/contracts/src/Interfaces/IRedemptionHelper.sol new file mode 100644 index 000000000..c5d70131e --- /dev/null +++ b/contracts/src/Interfaces/IRedemptionHelper.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IRedemptionHelper { + struct SimulationContext { + address troveManager; + address sortedTroves; + bool redeemable; + uint256 price; + uint256 proportion; + uint256 attemptedBold; + uint256 redeemedBold; + uint256 iterations; + } + + struct Redeemed { + uint256 bold; + uint256 coll; + } + + function simulateRedemption(uint256 _bold, uint256 _maxIterationsPerCollateral) + external + returns (SimulationContext[] memory branch, uint256 totalProportions); + + // Find the maximal amount of BOLD that can be redeemed proportionally within + // a given iteration limit. This helps prevent the redeemer from overpaying on + // the redemption fee. + // + // Also returns the expected fee that will be paid (as a percentage), and the + // expected collateral amounts that will be paid out in exchange for the + // redeemed BOLD. The latter may be used to calculate the _minCollRedeemed + // parameter passed to redeemCollateral(). + function truncateRedemption(uint256 _bold, uint256 _maxIterationsPerCollateral) + external + returns (uint256 truncatedBold, uint256 feePct, Redeemed[] memory redeemed); + + // Wrapper around CollateralRegistry's redeemCollateral() that adds slippage + // protection in the form of a minimum acceptable collateral amounts parameter. + function redeemCollateral( + uint256 _bold, + uint256 _maxIterationsPerCollateral, + uint256 _maxFeePct, + uint256[] memory _minCollRedeemed + ) external; +} diff --git a/contracts/src/RedemptionHelper.sol b/contracts/src/RedemptionHelper.sol new file mode 100644 index 000000000..a6018e920 --- /dev/null +++ b/contracts/src/RedemptionHelper.sol @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.24; + +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "openzeppelin-contracts/contracts/utils/math/Math.sol"; + +import {_100pct, DECIMAL_PRECISION} from "./Dependencies/Constants.sol"; +import {IAddressesRegistry} from "./Interfaces/IAddressesRegistry.sol"; +import {IBoldToken} from "./Interfaces/IBoldToken.sol"; +import {ICollateralRegistry} from "./Interfaces/ICollateralRegistry.sol"; +import {IRedemptionHelper} from "./Interfaces/IRedemptionHelper.sol"; +import {ISortedTroves} from "./Interfaces/ISortedTroves.sol"; +import {ITroveManager} from "./Interfaces/ITroveManager.sol"; +import {LatestTroveData} from "./Types/LatestTroveData.sol"; + +contract RedemptionHelper is IRedemptionHelper { + using SafeERC20 for IERC20; + + struct RedemptionContext { + IERC20 collToken; + uint256 collBalanceBefore; + } + + uint256 public immutable numBranches; + ICollateralRegistry public immutable collateralRegistry; + IBoldToken public immutable boldToken; + IAddressesRegistry[] public addresses; // only used off-chain, so we don't care about storage cost + + constructor(ICollateralRegistry _collateralRegistry, IAddressesRegistry[] memory _addresses) { + require(_addresses.length == _collateralRegistry.totalCollaterals(), "Wrong number of registries"); + numBranches = _addresses.length; + collateralRegistry = _collateralRegistry; + boldToken = _collateralRegistry.boldToken(); + + for (uint256 i = 0; i < _addresses.length; ++i) { + require(_collateralRegistry.getTroveManager(i) == _addresses[i].troveManager(), "TroveManager mismatch"); + addresses.push(_addresses[i]); + } + + boldToken.approve(address(_collateralRegistry), type(uint256).max); + } + + // Meant to be called off-chain + // Not a view because price fetching has side-effects + function simulateRedemption(uint256 _bold, uint256 _maxIterationsPerCollateral) + public + returns (SimulationContext[] memory branch, uint256 totalProportions) + { + branch = new SimulationContext[](numBranches); + + // First priority: proportional to unbacked debt + for (uint256 i = 0; i < numBranches; ++i) { + branch[i].troveManager = address(addresses[i].troveManager()); + branch[i].sortedTroves = address(addresses[i].sortedTroves()); + (branch[i].proportion, branch[i].price, branch[i].redeemable) = + ITroveManager(branch[i].troveManager).getUnbackedPortionPriceAndRedeemability(); + if (branch[i].redeemable) totalProportions += branch[i].proportion; + } + + // CS-BOLD-013: truncate redemption if it would exceed total unbacked debt + if (0 < totalProportions && totalProportions < _bold) _bold = totalProportions; + + // Fallback: proportional to total debt + if (totalProportions == 0) { + for (uint256 i = 0; i < numBranches; ++i) { + branch[i].proportion = ITroveManager(branch[i].troveManager).getEntireBranchDebt(); + if (branch[i].redeemable) totalProportions += branch[i].proportion; + } + } + + if (totalProportions == 0) return (branch, totalProportions); + + for (uint256 i = 0; i < numBranches; ++i) { + if (!branch[i].redeemable) continue; + + branch[i].attemptedBold = _bold * branch[i].proportion / totalProportions; + if (branch[i].attemptedBold == 0) continue; + + uint256 lastZombieTroveId = ITroveManager(branch[i].troveManager).lastZombieTroveId(); + uint256 lastTroveId = ISortedTroves(branch[i].sortedTroves).getLast(); + + (uint256 troveId, uint256 nextTroveId) = lastZombieTroveId != 0 + ? (lastZombieTroveId, lastTroveId) + : (lastTroveId, ISortedTroves(branch[i].sortedTroves).getPrev(lastTroveId)); + + for ( + branch[i].iterations = 0; + branch[i].iterations < _maxIterationsPerCollateral || _maxIterationsPerCollateral == 0; + ++branch[i].iterations + ) { + if (branch[i].redeemedBold == branch[i].attemptedBold || troveId == 0) break; + + LatestTroveData memory trove = ITroveManager(branch[i].troveManager).getLatestTroveData(troveId); + if (trove.entireColl * branch[i].price / trove.entireDebt >= _100pct) { + branch[i].redeemedBold += + Math.min(branch[i].attemptedBold - branch[i].redeemedBold, trove.entireDebt); + } + + troveId = nextTroveId; + nextTroveId = ISortedTroves(branch[i].sortedTroves).getPrev(nextTroveId); + } + } + } + + // Meant to be called off-chain + // Not a view because price fetching has side-effects + function truncateRedemption(uint256 _bold, uint256 _maxIterationsPerCollateral) + external + returns (uint256 truncatedBold, uint256 feePct, Redeemed[] memory redeemed) + { + (SimulationContext[] memory branch, uint256 totalProportions) = + simulateRedemption(_bold, _maxIterationsPerCollateral); + + if (totalProportions == 0) return (0, 0, redeemed); + + truncatedBold = _bold; + for (uint256 i = 0; i < numBranches; ++i) { + if (branch[i].redeemable && branch[i].proportion > 0) { + // Extrapolate how much the entire redeemed BOLD would + // have been if this branch was redeemed proportionally. + uint256 extrapolatedBold = branch[i].redeemedBold * totalProportions / branch[i].proportion; + + // Normally this is no different from `_bold`, but can be less if the redemption on this branch + // terminated due to hitting the iteration limit. We're looking for the smallest extrapolated value, + // since that is the maximum amount of BOLD that can be redeemed proportionally within the given + // iteration limit. Any attempt to redeem more than this would result in a partial redemption, thus + // paying a higher redemption fee than necessary — since the fee is based on the attempted amount. + if (extrapolatedBold < truncatedBold) truncatedBold = extrapolatedBold; + } + } + + feePct = collateralRegistry.getRedemptionRateForRedeemedAmount(truncatedBold); + redeemed = new Redeemed[](numBranches); + + for (uint256 i = 0; i < numBranches; ++i) { + if (branch[i].redeemable && branch[i].proportion > 0) { + (uint256 redemptionPrice,) = addresses[i].priceFeed().fetchRedemptionPrice(); + redeemed[i].bold = truncatedBold * branch[i].proportion / totalProportions; + redeemed[i].coll = redeemed[i].bold * (DECIMAL_PRECISION - feePct) / redemptionPrice; + } + } + } + + function redeemCollateral( + uint256 _bold, + uint256 _maxIterationsPerCollateral, + uint256 _maxFeePct, + uint256[] memory _minCollRedeemed + ) external { + require(_minCollRedeemed.length == numBranches, "Wrong _minCollRedeemed length"); + + RedemptionContext[] memory branch = new RedemptionContext[](numBranches); + + for (uint256 i = 0; i < numBranches; ++i) { + branch[i].collToken = collateralRegistry.getToken(i); + branch[i].collBalanceBefore = branch[i].collToken.balanceOf(address(this)); + } + + uint256 boldBalanceBefore = boldToken.balanceOf(address(this)); + + boldToken.transferFrom(msg.sender, address(this), _bold); + collateralRegistry.redeemCollateral(_bold, _maxIterationsPerCollateral, _maxFeePct); + + for (uint256 i = 0; i < numBranches; ++i) { + uint256 collRedeemed = branch[i].collToken.balanceOf(address(this)) - branch[i].collBalanceBefore; + require(collRedeemed >= _minCollRedeemed[i], "Insufficient collateral redeemed"); + if (collRedeemed > 0) branch[i].collToken.safeTransfer(msg.sender, collRedeemed); + } + + uint256 boldRemaining = boldToken.balanceOf(address(this)) - boldBalanceBefore; + if (boldRemaining > 0) boldToken.transfer(msg.sender, boldRemaining); + } +} diff --git a/contracts/test/RedemptionHelper.t.sol b/contracts/test/RedemptionHelper.t.sol new file mode 100644 index 000000000..ad2f4c05f --- /dev/null +++ b/contracts/test/RedemptionHelper.t.sol @@ -0,0 +1,366 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Math} from "openzeppelin-contracts/contracts/utils/math/Math.sol"; +import {Strings} from "openzeppelin-contracts/contracts/utils/Strings.sol"; +import {MIN_DEBT} from "../src/Dependencies/Constants.sol"; +import {IAddressesRegistry} from "../src/Interfaces/IAddressesRegistry.sol"; +import {IRedemptionHelper} from "../src/Interfaces/IRedemptionHelper.sol"; +import {TroveChange} from "../src/Types/TroveChange.sol"; +import {RedemptionHelper} from "../src/RedemptionHelper.sol"; +import {Accounts} from "./TestContracts/Accounts.sol"; +import {TestDeployer} from "./TestContracts/Deployment.t.sol"; +import {DevTestSetup} from "./TestContracts/DevTestSetup.sol"; + +uint256 constant NUM_BRANCHES = 3; +uint256 constant NUM_TROVES = 20; + +contract RedemptionHelperTest is DevTestSetup { + using Strings for *; + + struct TroveParams { + uint256 branchIdx; + uint256 collRatio; + uint256 debt; + } + + TestDeployer.TroveManagerParams[] params; + TestDeployer.LiquityContractsDev[] branch; + IRedemptionHelper redemptionHelper; + + function setUp() public override { + // Start tests at a non-zero timestamp + vm.warp(block.timestamp + 600); + + accounts = new Accounts(); + createAccounts(); + + (A, B, C, D, E, F, G) = ( + accountsList[0], + accountsList[1], + accountsList[2], + accountsList[3], + accountsList[4], + accountsList[5], + accountsList[6] + ); + + params.push(TestDeployer.TroveManagerParams(1.5 ether, 1.1 ether, 0.1 ether, 1.1 ether, 0.05 ether, 0.1 ether)); + params.push(TestDeployer.TroveManagerParams(1.6 ether, 1.2 ether, 0.1 ether, 1.2 ether, 0.05 ether, 0.2 ether)); + params.push(TestDeployer.TroveManagerParams(1.6 ether, 1.2 ether, 0.1 ether, 1.2 ether, 0.05 ether, 0.2 ether)); + assertEq(NUM_BRANCHES, 3, "Must update params"); + + TestDeployer.LiquityContractsDev[] memory tmpBranch; + TestDeployer deployer = new TestDeployer(); + (tmpBranch, collateralRegistry, boldToken, hintHelpers,, WETH,) = + deployer.deployAndConnectContractsMultiColl(params); + + for (uint256 i = 0; i < tmpBranch.length; ++i) { + branch.push(tmpBranch[i]); + } + + branch[0].priceFeed.setPrice(2000e18); + branch[1].priceFeed.setPrice(3000e18); + branch[2].priceFeed.setPrice(4000e18); + assertEq(NUM_BRANCHES, 3, "Must update initial prices"); + + for (uint256 i = 0; i < branch.length; ++i) { + for (uint256 j = 0; j < accountsList.length; ++j) { + // Give some Collateral to test accounts, and approve it to BorrowerOperations + giveAndApproveCollateral( + branch[i].collToken, accountsList[j], 10_000 ether, address(branch[i].borrowerOperations) + ); + + // Approve WETH for gas compensation in all branches + vm.prank(accountsList[j]); + WETH.approve(address(branch[i].borrowerOperations), type(uint256).max); + } + } + + IAddressesRegistry[] memory addresses = new IAddressesRegistry[](branch.length); + for (uint256 i = 0; i < branch.length; ++i) { + addresses[i] = branch[i].addressesRegistry; + } + + redemptionHelper = new RedemptionHelper(collateralRegistry, addresses); + } + + function findAmountToBorrow(uint256 branchIdx, uint256 targetDebt, uint256 interestRate) + internal + view + returns (uint256 borrow, uint256 upfrontFee) + { + uint256 borrowRight = targetDebt; + upfrontFee = hintHelpers.predictOpenTroveUpfrontFee(branchIdx, borrowRight, interestRate); + uint256 borrowLeft = borrowRight - upfrontFee; + + for (uint256 i = 0; i < 256; ++i) { + borrow = (borrowLeft + borrowRight) / 2; + upfrontFee = hintHelpers.predictOpenTroveUpfrontFee(branchIdx, borrow, interestRate); + uint256 actualDebt = borrow + upfrontFee; + + if (actualDebt == targetDebt) { + break; + } else if (actualDebt < targetDebt) { + borrowLeft = borrow; + } else { + borrowRight = borrow; + } + } + } + + function openTrove( + uint256 branchIdx, + address owner, + uint256 ownerIdx, + uint256 collRatio, + uint256 debt, + uint256 interestRate + ) internal { + (uint256 borrow, uint256 upfrontFee) = findAmountToBorrow(branchIdx, debt, interestRate); + uint256 coll = Math.ceilDiv(debt * collRatio, branch[branchIdx].priceFeed.getPrice()); + + vm.prank(owner); + branch[branchIdx].borrowerOperations.openTrove({ + _owner: owner, + _ownerIndex: ownerIdx, + _ETHAmount: coll, + _boldAmount: borrow, + _upperHint: 0, + _lowerHint: 0, + _annualInterestRate: interestRate, + _maxUpfrontFee: upfrontFee, + _addManager: address(0), + _removeManager: address(0), + _receiver: address(0) + }); + } + + function openTroves(address owner, TroveParams[NUM_TROVES] memory trove) internal { + for (uint256 i = 0; i < trove.length; ++i) { + trove[i].branchIdx = _bound(trove[i].branchIdx, 0, branch.length - 1); + trove[i].collRatio = _bound(trove[i].collRatio, params[trove[i].branchIdx].CCR, 3 ether); + trove[i].debt = _bound(trove[i].debt, MIN_DEBT, 100 * MIN_DEBT); + openTrove(trove[i].branchIdx, owner, i, trove[i].collRatio, trove[i].debt, 0.05 ether); + } + } + + function provideToSP(uint256 branchIdx, address account, uint256 bold) public { + vm.prank(account); + branch[branchIdx].stabilityPool.provideToSP(bold, true); + } + + function provideToSPs(address account, uint256[NUM_BRANCHES] memory bold) public { + for (uint256 i = 0; i < bold.length; ++i) { + bold[i] = _bound(bold[i], 0, boldToken.balanceOf(account) - 1); + if (bold[i] > 0) provideToSP(i, account, bold[i]); + } + } + + function setTotalCollRatio(uint256[NUM_BRANCHES] memory totalCollRatio) internal { + for (uint256 i = 0; i < totalCollRatio.length; ++i) { + totalCollRatio[i] = _bound(totalCollRatio[i], 0.9 ether, 3 ether); + uint256 totalColl = branch[i].troveManager.getEntireBranchColl(); + uint256 totalDebt = branch[i].troveManager.getEntireBranchDebt(); + if (totalColl > 0) branch[i].priceFeed.setPrice(totalCollRatio[i] * totalDebt / totalColl); + } + } + + function test_SimulateRedemption( + uint256 delay, + TroveParams[NUM_TROVES] memory troves, + uint256[NUM_BRANCHES] memory spBold, + uint256[NUM_BRANCHES] memory totalCollRatio, + uint256 attemptedRedeemedBold, + uint256 maxIterations + ) external { + skip(_bound(delay, 0, 30 days)); // decay the baserate + + openTroves(A, troves); + provideToSPs(A, spBold); + setTotalCollRatio(totalCollRatio); + + attemptedRedeemedBold = _bound(attemptedRedeemedBold, 1, boldToken.balanceOf(A)); + maxIterations = _bound(maxIterations, 0, NUM_TROVES); + + (IRedemptionHelper.SimulationContext[] memory sim,) = + redemptionHelper.simulateRedemption(attemptedRedeemedBold, maxIterations); + + uint256 expectedRedeemedBold = 0; + uint256 expectedMaxIterations = 0; + + for (uint256 i = 0; i < sim.length; ++i) { + expectedRedeemedBold += sim[i].redeemedBold; + expectedMaxIterations = Math.max(expectedMaxIterations, sim[i].iterations); + } + + assertLeDecimal(expectedRedeemedBold, attemptedRedeemedBold, 18, "expectedRedeemedBold > attemptedRedeemedBold"); + if (maxIterations != 0) assertLe(expectedMaxIterations, maxIterations, "expectedMaxIterations > maxIterations"); + + uint256 boldBalanceBefore = boldToken.balanceOf(A); + vm.prank(A); + collateralRegistry.redeemCollateral(attemptedRedeemedBold, expectedMaxIterations, 1 ether); + uint256 actualRedeemedBold = boldBalanceBefore - boldToken.balanceOf(A); + + // There can be a tiny difference between the simulated and actually redeemed BOLD amounts, + // since RedemptionHelper doesn't implement error feedback similar to what CollateralRegistry + // does when proportionally splitting the redeemed amount. + // + // We deem this acceptable, since the frontend will eventually apply significantly larger + // slippage tolerance margins to the corresponding min collateral amounts anyway. + assertApproxEqAbsDecimal( + actualRedeemedBold, expectedRedeemedBold, 2, 18, "actualRedeemedBold != expectedRedeemedBold" + ); + } + + function test_TruncateRedemption( + uint256 delay, + TroveParams[NUM_TROVES] memory troves, + uint256[NUM_BRANCHES] memory spBold, + uint256[NUM_BRANCHES] memory totalCollRatio, + uint256 attemptedRedeemedBold, + uint256 maxIterations + ) external { + skip(_bound(delay, 0, 30 days)); // decay the baserate + + openTroves(A, troves); + provideToSPs(A, spBold); + setTotalCollRatio(totalCollRatio); + + attemptedRedeemedBold = _bound(attemptedRedeemedBold, 1, boldToken.balanceOf(A)); + maxIterations = _bound(maxIterations, 0, NUM_TROVES); + + (uint256 truncatedRedeemedBold, uint256 feePct, IRedemptionHelper.Redeemed[] memory expectedRedeemed) = + redemptionHelper.truncateRedemption(attemptedRedeemedBold, maxIterations); + vm.assume(truncatedRedeemedBold > 0); + + assertLeDecimal( + truncatedRedeemedBold, attemptedRedeemedBold, 18, "truncatedRedeemedBold > attemptedRedeemedBold" + ); + + uint256 boldBalanceBefore = boldToken.balanceOf(A); + uint256[] memory collBalanceBefore = new uint256[](branch.length); + for (uint256 i = 0; i < branch.length; ++i) { + collBalanceBefore[i] = branch[i].collToken.balanceOf(A); + } + + vm.prank(A); + collateralRegistry.redeemCollateral(truncatedRedeemedBold, maxIterations, feePct); + + uint256 actualRedeemedBold = boldBalanceBefore - boldToken.balanceOf(A); + assertApproxEqAbsDecimal( + actualRedeemedBold, truncatedRedeemedBold, 1, 18, "actualRedeemedBold != truncatedRedeemedBold" + ); + + for (uint256 i = 0; i < branch.length; ++i) { + uint256 actualRedeemedColl = branch[i].collToken.balanceOf(A) - collBalanceBefore[i]; + + assertApproxEqAbsDecimal( + actualRedeemedColl, + expectedRedeemed[i].coll, + 10, + 18, + string.concat("actualRedeemedColl != expectedRedeemed[", i.toString(), "].coll") + ); + } + } + + function test_RedeemCollateral_RefundsRemainingBold() external { + skip(100 days); + + for (uint256 i = 0; i < branch.length - 1; ++i) { + openTrove(i, A, 0, 2 ether, 10_000 ether, 0.05 ether); + } + + // All branches have the same unbacked portions, + // but on the last branch only 2K can be redeemed in 1 iteration + openTrove(branch.length - 1, A, 0, 2 ether, 2_000 ether, 0.05 ether); + openTrove(branch.length - 1, A, 1, 2 ether, 8_000 ether, 0.06 ether); + + uint256 boldBalanceBefore = boldToken.balanceOf(A); + uint256 attemptedRedeemedBold = branch.length * 3_000 ether; + + vm.startPrank(A); + boldToken.approve(address(redemptionHelper), attemptedRedeemedBold); + redemptionHelper.redeemCollateral(attemptedRedeemedBold, 1, 1 ether, new uint256[](branch.length)); + vm.stopPrank(); + + uint256 actualRedeemedBold = boldBalanceBefore - boldToken.balanceOf(A); + assertEqDecimal(actualRedeemedBold, attemptedRedeemedBold - 1_000 ether, 18, "actualRedeemedBold"); + assertEqDecimal(boldToken.balanceOf(address(redemptionHelper)), 0, 18, "boldToken.balanceOf(redemptionHelper)"); + } + + function test_RedeemCollateral_ForwardsRedeemedColl() external { + skip(100 days); + + for (uint256 i = 0; i < branch.length; ++i) { + openTrove(i, A, 0, 2 ether, 10_000 ether, 0.05 ether); + } + + uint256[] memory collBalanceBefore = new uint256[](branch.length); + for (uint256 i = 0; i < branch.length; ++i) { + collBalanceBefore[i] = branch[i].collToken.balanceOf(A); + } + + uint256 redeemedBold = branch.length * 1_000 ether; // a tenth of the supply + + vm.startPrank(A); + boldToken.approve(address(redemptionHelper), redeemedBold); + redemptionHelper.redeemCollateral(redeemedBold, 1, 1 ether, new uint256[](branch.length)); + vm.stopPrank(); + + for (uint256 i = 0; i < branch.length; ++i) { + uint256 actualRedeemedColl = branch[i].collToken.balanceOf(A) - collBalanceBefore[i]; + + assertApproxEqAbsDecimal( + actualRedeemedColl, + 1_000 ether * 0.895 ether / branch[i].priceFeed.getPrice(), + 1, + 18, + "actualRedeemedColl" + ); + + assertEqDecimal( + branch[i].collToken.balanceOf(address(redemptionHelper)), 0, 18, "collToken.balanceOf(redemptionHelper)" + ); + } + } + + function test_RedeemCollateral_RevertsWhenRedeemedCollLtMin() external { + skip(100 days); + + for (uint256 i = 0; i < branch.length; ++i) { + // Use the same price on each branch for simplicity + branch[i].priceFeed.setPrice(1_000 ether); + openTrove(i, A, 0, 2 ether, 10_000 ether, 0.05 ether); + } + + uint256 redeemedBold = branch.length * 1_000 ether; // a tenth of the supply + + uint256[] memory minCollRedeemed = new uint256[](branch.length); + for (uint256 i = 0; i < branch.length; ++i) { + minCollRedeemed[i] = 0.895 ether; + } + + vm.startPrank(A); + { + boldToken.approve(address(redemptionHelper), redeemedBold); + + for (uint256 i = 0; i < branch.length; ++i) { + // Make one of the parameters too high + ++minCollRedeemed[i]; + + // This should cause the redemption to revert + vm.expectRevert("Insufficient collateral redeemed"); + redemptionHelper.redeemCollateral(redeemedBold, 1, 1 ether, minCollRedeemed); + + // Fix the parameter that was made too high + --minCollRedeemed[i]; + } + + // Should succeed now that all parameters are fixed + redemptionHelper.redeemCollateral(redeemedBold, 1, 1 ether, minCollRedeemed); + } + vm.stopPrank(); + } +}