diff --git a/package.json b/package.json index 7c66d4c35..67b5e765b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "compile:typechain": "forge build && typechain --target ethers-v5 --out-dir ./typechain-types './out/!(test*|Test*|*.t.sol|*.s.sol)/*.json'", "compile:client-dest": "yarn compile:typechain && tsc --project tsconfig.client-dest.json", "build": "forge build && yarn compile:client-dest", - "test:pinned:14000000": "forge test --fork-block-number 14000000 --match-contract 'Element|OracleHelper' --fork-url https://mainnet.infura.io/v3/9928b52099854248b3a096be07a6b23c", + "test:pinned:14000000": "forge test --fork-block-number 14000000 --match-contract 'Element' --fork-url https://mainnet.infura.io/v3/9928b52099854248b3a096be07a6b23c", "test:pinned:14970000": "forge test --fork-block-number 14970000 -m 'testRedistributionSuccessfulSwap|testRedistributionExitWhenICREqualsMCR' --fork-url https://mainnet.infura.io/v3/9928b52099854248b3a096be07a6b23c", "test:pinned:14972000": "forge test --fork-block-number 14972000 -m 'testRedistributionFailingSwap' --fork-url https://mainnet.infura.io/v3/9928b52099854248b3a096be07a6b23c", "test:pinned": "yarn test:pinned:14000000 && yarn test:pinned:14970000 && yarn test:pinned:14972000", diff --git a/src/bridges/liquity/TroveBridge.sol b/src/bridges/liquity/TroveBridge.sol index 2c84405ec..d95f047d2 100644 --- a/src/bridges/liquity/TroveBridge.sol +++ b/src/bridges/liquity/TroveBridge.sol @@ -26,12 +26,13 @@ import {IUniswapV3PoolActions} from "../../interfaces/uniswapv3/pool/IUniswapV3P * the token symbol is TB-[initial ICR] (ICR is an acronym for individual collateral ratio). 1 TB token represents * 1 LUSD worth of debt if no redistribution took place (Liquity whitepaper section 4.2). If redistribution took place * 1 TB corresponds to more than 1 LUSD. In case the trove is not closed by redemption or liquidation, users can - * withdraw their collateral by supplying TB and an equal amount of LUSD to the bridge. If 1 TB corresponds to more than - * 1 LUSD part of the ETH collateral withdrawn is swapped to LUSD and the output amount is repaid. This swap is - * necessary because it's impossible to deploy different amounts of _inputAssetA and _inputAssetB. 1 deployment - * of the bridge contract controls 1 trove. The bridge keeps precise accounting of debt by making sure that no user - * can change the trove's ICR. This means that when a price goes down the only way how a user can avoid liquidation - * penalty is to repay their debt. + * withdraw their collateral by supplying TB and an equal amount of LUSD to the bridge. Alternatively, they supply only + * TB on input in which case their debt will be repaid with a part their collateral. In case a user supplies both TB and + * LUSD on input and 1 TB corresponds to more than 1 LUSD part of the ETH collateral withdrawn is swapped to LUSD and + * the output amount is repaid. This swap is necessary because it's impossible to provide different amounts of + * _inputAssetA and _inputAssetB. 1 deployment of the bridge contract controls 1 trove. The bridge keeps precise + * accounting of debt by making sure that no user can change the trove's ICR. This means that when a price goes down + * the only way how a user can avoid liquidation penalty is to repay their debt. * * In case the trove gets liquidated, the bridge no longer controls any ETH and all the TB balances are irrelevant. * At this point the bridge is defunct (unless owner is the only one who borrowed). If owner is the only who borrowed @@ -48,9 +49,11 @@ contract TroveBridge is BridgeBase, ERC20, Ownable, IUniswapV3SwapCallback { using Strings for uint256; error NonZeroTotalSupply(); - error InvalidStatus(Status acceptableStatus1, Status acceptableStatus2, Status received); + error InvalidStatus(Status status); error InvalidDeltaAmounts(); error OwnerNotLast(); + error MaxCostExceeded(); + error SwapFailed(); // Trove status taken from TroveManager.sol enum Status { @@ -85,6 +88,9 @@ contract TroveBridge is BridgeBase, ERC20, Ownable, IUniswapV3SwapCallback { uint256 public immutable INITIAL_ICR; + // Price precision + uint256 public constant PRECISION = 1e18; + // We are not setting price impact protection and in both swaps zeroForOne is false so sqrtPriceLimitX96 // is set to TickMath.MAX_SQRT_RATIO - 1 = 1461446703485210103287273052203988822378723970341 // See https://github.com/Uniswap/v3-periphery/blob/22a7ead071fff53f00d9ddc13434f285f4ed5c7d/contracts/SwapRouter.sol#L187 @@ -168,17 +174,17 @@ contract TroveBridge is BridgeBase, ERC20, Ownable, IUniswapV3SwapCallback { * executed. If TB, repaying. RollupProcessor.sol has to transfer the tokens to the bridge before calling * the method. If this is not the case, the function will revert. * - * Borrowing Repaying Repaying (redis.) Redeeming - * @param _inputAssetA - ETH TB TB TB - * @param _inputAssetB - None LUSD LUSD None - * @param _outputAssetA - TB ETH ETH ETH - * @param _outputAssetB - LUSD LUSD TB None - * @param _totalInputValue - ETH amount TB and LUSD amt. TB and LUSD amt. TB amount - * @param _interactionNonce - nonce nonce nonce nonce - * @param _auxData - max borrower fee 0 0 0 + * Borrowing | Repaying | Repaying (redis.)| Repay. (coll.)| Redeeming + * @param _inputAssetA - ETH | TB | TB | TB | TB + * @param _inputAssetB - None | LUSD | LUSD | None | None + * @param _outputAssetA - TB | ETH | ETH | ETH | ETH + * @param _outputAssetB - LUSD | LUSD | TB | None | None + * @param _totalInputValue - ETH amount | TB and LUSD amt.| TB and LUSD amt. | TB amount | TB amount + * @param _interactionNonce - nonce | nonce | nonce | nonce | nonce + * @param _auxData - max borrower fee | 0 | max ETH price | max ETH price | 0 * @param _rollupBeneficiary - Address which receives subsidy if the call is eligible for it - * @return outputValueA - TB amount ETH amount ETH amount ETH amount - * @return outputValueB - LUSD amount LUSD amount TB amount 0 + * @return outputValueA - TB amount | ETH amount | ETH amount | ETH amount | ETH amount + * @return outputValueB - LUSD amount | LUSD amount | TB amount | 0 | 0 * @dev The amount of LUSD returned (outputValueB) during repayment will be non-zero only when the trove was * partially redeemed. */ @@ -212,7 +218,7 @@ contract TroveBridge is BridgeBase, ERC20, Ownable, IUniswapV3SwapCallback { _outputAssetB.erc20Address == LUSD ) { // Borrowing - if (troveStatus != Status.active) revert InvalidStatus(Status.active, Status.active, troveStatus); + if (troveStatus != Status.active) revert InvalidStatus(troveStatus); (outputValueA, outputValueB) = _borrow(_totalInputValue, _auxData); subsidyCriteria = 0; } else if ( @@ -221,14 +227,22 @@ contract TroveBridge is BridgeBase, ERC20, Ownable, IUniswapV3SwapCallback { _outputAssetA.assetType == AztecTypes.AztecAssetType.ETH ) { // Repaying - if (troveStatus != Status.active) revert InvalidStatus(Status.active, Status.active, troveStatus); + if (troveStatus != Status.active) revert InvalidStatus(troveStatus); if (_outputAssetB.erc20Address == LUSD) { // A case when the trove was partially redeemed (1 TB corresponding to less than 1 LUSD of debt) or not // redeemed and not touched by redistribution (1 TB corresponding to exactly 1 LUSD of debt) (outputValueA, outputValueB) = _repay(_totalInputValue, _interactionNonce); } else if (_outputAssetB.erc20Address == address(this)) { - // A case when the trove was touched by redistribution (1 TB corresponding to more than 1 LUSD of debt) - (outputValueA, outputValueB) = _repayAfterRedistribution(_totalInputValue, _interactionNonce); + // A case when the trove was touched by redistribution (1 TB corresponding to more than 1 LUSD of + // debt). For this reason it was impossible to provide enough LUSD on input since it's not currently + // allowed to have different input token amounts. Swap part of the collateral to be able to repay + // the debt in full. + (outputValueA, outputValueB) = _repayWithCollateral( + _totalInputValue, + _auxData, + _interactionNonce, + true + ); } else { revert ErrorLib.InvalidOutputB(); } @@ -236,12 +250,15 @@ contract TroveBridge is BridgeBase, ERC20, Ownable, IUniswapV3SwapCallback { } else if ( _inputAssetA.erc20Address == address(this) && _outputAssetA.assetType == AztecTypes.AztecAssetType.ETH ) { - // Redeeming remaining collateral after the Trove is closed - if (troveStatus != Status.closedByRedemption && troveStatus != Status.closedByLiquidation) { - revert InvalidStatus(Status.closedByRedemption, Status.closedByLiquidation, troveStatus); + if (troveStatus == Status.active) { + // Repaying debt with collateral (using flash swaps) + (outputValueA, ) = _repayWithCollateral(_totalInputValue, _auxData, _interactionNonce, false); + } else if (troveStatus == Status.closedByRedemption || troveStatus == Status.closedByLiquidation) { + // Redeeming remaining collateral after the Trove is closed + outputValueA = _redeem(_totalInputValue, _interactionNonce); + } else { + revert InvalidStatus(troveStatus); } - outputValueA = _redeem(_totalInputValue, _interactionNonce); - // Repaying and redeeming has the same subsidy criteria subsidyCriteria = 1; } else { revert ErrorLib.InvalidInput(); @@ -281,13 +298,13 @@ contract TroveBridge is BridgeBase, ERC20, Ownable, IUniswapV3SwapCallback { } // @inheritdoc IUniswapV3SwapCallback - // @dev See _repay(...) method for more information about how this callback is entered. + // @dev See _repayWithCollateral(...) method for more information about how this callback is entered. function uniswapV3SwapCallback( int256 _amount0Delta, int256 _amount1Delta, bytes calldata _data ) external override(IUniswapV3SwapCallback) { - // swaps entirely within 0-liquidity regions are not supported + // Swaps entirely within 0-liquidity regions are not supported if (_amount0Delta <= 0 && _amount1Delta <= 0) revert InvalidDeltaAmounts(); // Uniswap pools always call callback on msg.sender so this check is enough to prevent malicious behavior if (msg.sender == LUSD_USDC_POOL) { @@ -414,14 +431,16 @@ contract TroveBridge is BridgeBase, ERC20, Ownable, IUniswapV3SwapCallback { } /** - * @notice Repay debt. - * @param _tbAmount Amount of TB to burn. + * @notice Repay debt by selling part of the collateral for LUSD. + * @param _totalInputValue Amount of TB to burn (and input LUSD to use for repayment if `_lusdInput` param is set + * to true). + * @param _maxPrice Maximum acceptable price of LUSD denominated in ETH. * @param _interactionNonce Same as in convert(...) method. - * @return collateral Amount of collateral withdrawn. + * @param _lusdInput If true the debt will be covered by both the LUSD on input and by selling part of the + * collateral. If false the debt will be covered only by selling the collateral. + * @return collateralReturned Amount of collateral withdrawn. * @return tbReturned Amount of TB returned (non-zero only when the flash swap fails) - * @dev Collateral and debt was redistributed to bridge's trove (1 TB corresponds to more than 1 LUSD worth - * of debt). For this reason the bridge doesn't currently have enough LUSD to repay the debt in full. - * It's important that CR never drops because if the trove was near minimum CR (MCR) the tx would revert. + * @dev It's important that CR never drops because if the trove was near minimum CR (MCR) the tx would revert. * This would effectively stop users from being able to exit. Unfortunately users are also not able * to exit when Liquity is in recovery mode (total collateral ratio < 150%) because in such a case * only pure collateral top-up or debt repayment is allowed. @@ -435,43 +454,74 @@ contract TroveBridge is BridgeBase, ERC20, Ownable, IUniswapV3SwapCallback { * Note: Since owner is not able to exit until all the TB of everyone else gets burned his funds will be * stuck forever unless the Uniswap pools recover. */ - function _repayAfterRedistribution(uint256 _tbAmount, uint256 _interactionNonce) - private - returns (uint256 collateral, uint256 tbReturned) - { + function _repayWithCollateral( + uint256 _totalInputValue, + uint256 _maxPrice, + uint256 _interactionNonce, + bool _lusdInput + ) private returns (uint256 collateralReturned, uint256 tbReturned) { (uint256 debtBefore, uint256 collBefore, , ) = TROVE_MANAGER.getEntireDebtAndColl(address(this)); // Compute how much debt to be repay uint256 tbTotalSupply = totalSupply(); // SLOAD optimization - uint256 debtToRepay = (_tbAmount * debtBefore) / tbTotalSupply; - if (debtToRepay <= _tbAmount) revert ErrorLib.InvalidOutputB(); - // Compute how much collateral to withdraw - uint256 collToWithdraw = (_tbAmount * collBefore) / tbTotalSupply; + uint256 debtToRepay = (_totalInputValue * debtBefore) / tbTotalSupply; + uint256 collToWithdraw = (_totalInputValue * collBefore) / tbTotalSupply; + + uint256 lusdToBuy; + if (_lusdInput) { + // Reverting here because an incorrect flow has been chosen --> there is no reason to be using flash swaps + // when the amount of LUSD on input is enough to cover the debt + if (debtToRepay <= _totalInputValue) revert ErrorLib.InvalidOutputB(); + uint256 lusdToBuy = debtToRepay - _totalInputValue; + } else { + lusdToBuy = debtToRepay; + } - try - IUniswapV3PoolActions(LUSD_USDC_POOL).swap( + (bool success, ) = LUSD_USDC_POOL.call( + abi.encodeWithSignature( + "swap(address,bool,int256,uint160,bytes)", address(this), // recipient false, // zeroForOne - -int256(debtToRepay - _tbAmount), // amount of LUSD to receive + -int256(lusdToBuy), SQRT_PRICE_LIMIT_X96, abi.encode(SwapCallbackData({debtToRepay: debtToRepay, collToWithdraw: collToWithdraw})) ) - { - // Flash swap was executed without error/revert - burn all input TB - _burn(address(this), _tbAmount); - } catch (bytes memory) { - // Flash swap failed - repay as much debt as you can with current LUSD balance and return the remaining TB - debtToRepay = _tbAmount; + ); + + if (success) { + // Note: Debt repayment took place in the `uniswapV3SwapCallback(...)` function + collateralReturned = address(this).balance; + + { + // Check that at most `maxCost` of ETH collateral was sold for `debtToRepay` worth of LUSD + uint256 maxCost = (lusdToBuy * _maxPrice) / PRECISION; + uint256 collateralSold = collToWithdraw - collateralReturned; + if (collateralSold > maxCost) revert MaxCostExceeded(); + } + + // Burn all input TB + _burn(address(this), _totalInputValue); + } else if (_lusdInput) { + // Flash swap failed and some LUSD was provided on input --> repay as much debt as you can with current + // LUSD balance and return the remaining TB + debtToRepay = _totalInputValue; uint256 tbToBurn = (debtToRepay * tbTotalSupply) / debtBefore; collToWithdraw = (tbToBurn * collBefore) / tbTotalSupply; - // Repay _totalInputValue of LUSD and withdraw collateral - (address upperHint, address lowerHint) = _getHints(); - BORROWER_OPERATIONS.adjustTrove(0, collToWithdraw, debtToRepay, false, upperHint, lowerHint); - tbReturned = _tbAmount - tbToBurn; + + { + // Repay _totalInputValue of LUSD and withdraw collateral + (address upperHint, address lowerHint) = _getHints(); + BORROWER_OPERATIONS.adjustTrove(0, collToWithdraw, debtToRepay, false, upperHint, lowerHint); + } + + tbReturned = _totalInputValue - tbToBurn; _burn(address(this), tbToBurn); + collateralReturned = address(this).balance; + } else { + revert SwapFailed(); } + // Return ETH to rollup processor - collateral = address(this).balance; - IRollupProcessor(ROLLUP_PROCESSOR).receiveEthFromBridge{value: collateral}(_interactionNonce); + IRollupProcessor(ROLLUP_PROCESSOR).receiveEthFromBridge{value: collateralReturned}(_interactionNonce); } /** diff --git a/src/test/bridges/liquity/TroveBridgeE2E.t.sol b/src/test/bridges/liquity/TroveBridgeE2E.t.sol index df37c8006..c0e220994 100644 --- a/src/test/bridges/liquity/TroveBridgeE2E.t.sol +++ b/src/test/bridges/liquity/TroveBridgeE2E.t.sol @@ -16,6 +16,10 @@ contract TroveBridgeE2ETest is BridgeTestBase, TroveBridgeTestBase { // To store the id of the trove bridge after being added uint256 private id; + AztecTypes.AztecAsset private ethAsset; + AztecTypes.AztecAsset private tbAsset; + AztecTypes.AztecAsset private lusdAsset; + function setUp() public { // Deploy a new trove bridge vm.prank(OWNER); @@ -38,27 +42,38 @@ contract TroveBridgeE2ETest is BridgeTestBase, TroveBridgeTestBase { vm.stopPrank(); + // Setup assets + ethAsset = ROLLUP_ENCODER.getRealAztecAsset(address(0)); + tbAsset = ROLLUP_ENCODER.getRealAztecAsset(address(bridge)); + lusdAsset = ROLLUP_ENCODER.getRealAztecAsset(tokens["LUSD"].addr); + // Fetch the id of the trove bridge id = ROLLUP_PROCESSOR.getSupportedBridgesLength(); _openTrove(); } - // @dev In order to avoid overflows we set _collateral to be uint96 instead of uint256. function testFullFlow(uint96 _collateral) public { uint256 collateral = bound(_collateral, 1e17, 1e21); - // Use the helper function to fetch Aztec assets - AztecTypes.AztecAsset memory ethAsset = ROLLUP_ENCODER.getRealAztecAsset(address(0)); - AztecTypes.AztecAsset memory tbAsset = ROLLUP_ENCODER.getRealAztecAsset(address(bridge)); - AztecTypes.AztecAsset memory lusdAsset = ROLLUP_ENCODER.getRealAztecAsset(tokens["LUSD"].addr); + _borrow(collateral); + _repay(collateral); + } - // BORROW + function testFullFlowRepayingWithColl(uint96 _collateral) public { + // Setting maximum only to 100 ETH because liquidity in the UNI pools is not sufficient for higher amounts + uint256 collateral = bound(_collateral, 1e17, 1e20); + + _borrow(collateral); + _repayWithCollateral(collateral); + } + + function _borrow(uint256 _collateral) private { // Mint the collateral amount of ETH to rollupProcessor - vm.deal(address(ROLLUP_PROCESSOR), collateral); + vm.deal(address(ROLLUP_PROCESSOR), _collateral); // Compute borrow calldata - ROLLUP_ENCODER.defiInteractionL2(id, ethAsset, emptyAsset, tbAsset, lusdAsset, MAX_FEE, collateral); + ROLLUP_ENCODER.defiInteractionL2(id, ethAsset, emptyAsset, tbAsset, lusdAsset, MAX_FEE, _collateral); (uint256 debtBeforeBorrowing, uint256 collBeforeBorrowing, , ) = TROVE_MANAGER.getEntireDebtAndColl( address(bridge) @@ -71,7 +86,7 @@ contract TroveBridgeE2ETest is BridgeTestBase, TroveBridgeTestBase { ); assertEq( collAfterBorrowing - collBeforeBorrowing, - collateral, + _collateral, "Collateral increase differs from deposited collateral" ); uint256 tbBalanceAfterBorrowing = bridge.balanceOf(address(ROLLUP_PROCESSOR)); @@ -83,21 +98,45 @@ contract TroveBridgeE2ETest is BridgeTestBase, TroveBridgeTestBase { assertEq(outputValueA, tbBalanceAfterBorrowing, "Debt amount doesn't equal outputValueA"); assertEq( outputValueB, - bridge.computeAmtToBorrow(collateral), + bridge.computeAmtToBorrow(_collateral), "Borrowed amount doesn't equal expected borrow amount" ); + } - // REPAY - // Mint the amount to repay to rollup processor - sufficient amount is not there since borrowing because there - // we need to pay for the borrowing fee - deal(lusdAsset.erc20Address, address(ROLLUP_PROCESSOR), tbBalanceAfterBorrowing + bridge.DUST()); + function _repay(uint256 _collateral) private { + uint256 tbBalance = bridge.balanceOf(address(ROLLUP_PROCESSOR)); + + // Mint the amount to repay to rollup processor - sufficient amount is not there since borrowing because we + // need to pay for the borrowing fee + deal(lusdAsset.erc20Address, address(ROLLUP_PROCESSOR), tbBalance + bridge.DUST()); // Compute repay calldata - ROLLUP_ENCODER.defiInteractionL2(id, tbAsset, lusdAsset, ethAsset, lusdAsset, MAX_FEE, tbBalanceAfterBorrowing); + ROLLUP_ENCODER.defiInteractionL2(id, tbAsset, lusdAsset, ethAsset, lusdAsset, 0, tbBalance); - (outputValueA, outputValueB, ) = ROLLUP_ENCODER.processRollupAndGetBridgeResult(); + (uint256 outputValueA, uint256 outputValueB, ) = ROLLUP_ENCODER.processRollupAndGetBridgeResult(); - assertApproxEqAbs(outputValueA, collateral, 1, "output value differs from colalteral by more than 1 wei"); + assertApproxEqAbs(outputValueA, _collateral, 1, "output value differs from collateral by more than 1 wei"); assertEq(outputValueB, 0, "Non-zero LUSD amount returned"); } + + function _repayWithCollateral(uint256 _collateral) private { + uint256 tbBalance = bridge.balanceOf(address(ROLLUP_PROCESSOR)); + + // Mint the amount to repay to rollup processor - sufficient amount is not there since borrowing because we + // need to pay for the borrowing fee + deal(lusdAsset.erc20Address, address(ROLLUP_PROCESSOR), tbBalance + bridge.DUST()); + + // Compute repay calldata + ROLLUP_ENCODER.defiInteractionL2(id, tbAsset, emptyAsset, ethAsset, emptyAsset, _getPrice(-1e20), tbBalance); + + (uint256 outputValueA, , ) = ROLLUP_ENCODER.processRollupAndGetBridgeResult(); + + // Given that ICR was set to 160% and the debt has been repaid with collateral, received collateral should be + // approx. equal to (deposit collateral amount) * (100/160). Given that borrowing fee and fee for the flash + // swap was paid the actual collateral received will be slightly less. + uint256 expectedEthReceived = _collateral - (_collateral * 100) / 160; + uint256 expectedEthReceivedWithTolerance = (expectedEthReceived * 90) / 100; + + assertGt(outputValueA, expectedEthReceivedWithTolerance, "Not enough collateral received"); + } } diff --git a/src/test/bridges/liquity/TroveBridgeTestBase.sol b/src/test/bridges/liquity/TroveBridgeTestBase.sol index ef2a4f208..9e97af611 100644 --- a/src/test/bridges/liquity/TroveBridgeTestBase.sol +++ b/src/test/bridges/liquity/TroveBridgeTestBase.sol @@ -185,4 +185,17 @@ contract TroveBridgeTestBase is TestUtil { vm.stopPrank(); } + + /** + * @param _ethPriceDiff LUSD denominated difference from the current ETH price + * @return Price of LUSD denominated in ETH corresponding to the current market price of ETH modified + * by `_ethPriceDiff` + */ + function _getPrice(int256 _ethPriceDiff) internal returns (uint64) { + // Set minPrice equal to that from Liquity's oracle increased by 100 LUSD + uint256 minEthPrice = uint256(int256(TROVE_MANAGER.priceFeed().fetchPrice()) + _ethPriceDiff); + // Invert the price so that it represent max price at which I am willing to buy LUSD and not min price at which + // I am willing to sell ETH (just for consistency sake) + return uint64((bridge.PRECISION() * bridge.PRECISION()) / minEthPrice); + } } diff --git a/src/test/bridges/liquity/TroveBridgeUnit.t.sol b/src/test/bridges/liquity/TroveBridgeUnit.t.sol index 1c1df858a..3310738af 100644 --- a/src/test/bridges/liquity/TroveBridgeUnit.t.sol +++ b/src/test/bridges/liquity/TroveBridgeUnit.t.sol @@ -35,7 +35,7 @@ contract TroveBridgeUnitTest is TroveBridgeTestBase { function testInvalidTroveStatus() public { // Attempt borrowing when trove was not opened - state 0 vm.deal(rollupProcessor, ROLLUP_PROCESSOR_ETH_BALANCE); - vm.expectRevert(abi.encodeWithSignature("InvalidStatus(uint8,uint8,uint8)", 1, 1, 0)); + vm.expectRevert(abi.encodeWithSignature("InvalidStatus(uint8)", 0)); bridge.convert( AztecTypes.AztecAsset(3, address(0), AztecTypes.AztecAssetType.ETH), emptyAsset, @@ -77,6 +77,42 @@ contract TroveBridgeUnitTest is TroveBridgeTestBase { _openTrove(); } + function testFullFlowRepayingWithColl() public { + _openTrove(); + _borrow(ROLLUP_PROCESSOR_ETH_BALANCE); + _repayWithCollateral(); + _closeTrove(); + + // Try reopening the trove + deal(tokens["LUSD"].addr, OWNER, 0); // delete user's LUSD balance to make accounting easier in _openTrove(...) + _openTrove(); + } + + function testRepayingWithCollateralRevertsWhenMaxCostExceeded() public { + _openTrove(); + _borrow(ROLLUP_PROCESSOR_ETH_BALANCE); + + // Try repaying debt while setting too high minPrice + AztecTypes.AztecAsset memory inputAssetA = AztecTypes.AztecAsset( + 2, + address(bridge), + AztecTypes.AztecAssetType.ERC20 + ); + AztecTypes.AztecAsset memory outputAssetA = AztecTypes.AztecAsset(3, address(0), AztecTypes.AztecAssetType.ETH); + + // inputValue is equal to rollupProcessor TB balance --> we want to repay the debt in full + uint256 inputValue = bridge.balanceOf(rollupProcessor); + // Transfer TB to the bridge + IERC20(inputAssetA.erc20Address).transfer(address(bridge), inputValue); + + // Get maximum LUSD price corresponding to min sell price of ETH which is 100 LUSD higher than the current + // oracle price + uint64 maxPrice = _getPrice(1e20); + + vm.expectRevert(TroveBridge.MaxCostExceeded.selector); + bridge.convert(inputAssetA, emptyAsset, outputAssetA, emptyAsset, inputValue, 1, maxPrice, address(0)); + } + function testLiquidationFlow() public { _openTrove(); _borrow(ROLLUP_PROCESSOR_ETH_BALANCE); @@ -263,7 +299,7 @@ contract TroveBridgeUnitTest is TroveBridgeTestBase { outputAssetB, inputValue, 1, - MAX_FEE, + 0, address(0) ); @@ -314,6 +350,42 @@ contract TroveBridgeUnitTest is TroveBridgeTestBase { _openTrove(); } + function testRepayingAfterRedistributionRevertsWhenMaxCostExceeded() public { + _setUpRedistribution(); + + AztecTypes.AztecAsset memory inputAssetA = AztecTypes.AztecAsset( + 2, + address(bridge), + AztecTypes.AztecAssetType.ERC20 + ); + AztecTypes.AztecAsset memory inputAssetB = AztecTypes.AztecAsset( + 1, + tokens["LUSD"].addr, + AztecTypes.AztecAssetType.ERC20 + ); + AztecTypes.AztecAsset memory outputAssetA = AztecTypes.AztecAsset(3, address(0), AztecTypes.AztecAssetType.ETH); + AztecTypes.AztecAsset memory outputAssetB = AztecTypes.AztecAsset( + 2, + address(bridge), + AztecTypes.AztecAssetType.ERC20 + ); + + // inputValue is equal to rollupProcessor TB balance --> we want to repay the debt in full + uint256 inputValue = bridge.balanceOf(rollupProcessor); + // Transfer TB to the bridge + IERC20(inputAssetA.erc20Address).transfer(address(bridge), inputValue); + + // Mint the amount to repay to the bridge + deal(inputAssetB.erc20Address, address(bridge), inputValue + bridge.DUST()); + + // Get maximum LUSD price corresponding to min sell price of ETH which is 100 LUSD higher than the current + // oracle price + uint64 maxPrice = _getPrice(1e20); + + vm.expectRevert(TroveBridge.MaxCostExceeded.selector); + bridge.convert(inputAssetA, inputAssetB, outputAssetA, outputAssetB, inputValue, 1, maxPrice, address(0)); + } + function testRedistributionExitWhenICREqualsMCR() public { vm.prank(OWNER); bridge = new TroveBridge(rollupProcessor, 500); @@ -523,7 +595,7 @@ contract TroveBridgeUnitTest is TroveBridgeTestBase { outputAssetB, inputValue, _interactionNonce, - MAX_FEE, + _getPrice(-1e20), address(0) ); @@ -545,6 +617,50 @@ contract TroveBridgeUnitTest is TroveBridgeTestBase { ); } + function _repayWithCollateral() private { + AztecTypes.AztecAsset memory inputAssetA = AztecTypes.AztecAsset( + 2, + address(bridge), + AztecTypes.AztecAssetType.ERC20 + ); + AztecTypes.AztecAsset memory outputAssetA = AztecTypes.AztecAsset(3, address(0), AztecTypes.AztecAssetType.ETH); + + // inputValue is equal to rollupProcessor TB balance --> we want to repay the debt in full + uint256 inputValue = bridge.balanceOf(rollupProcessor); + // Transfer TB to the bridge + IERC20(inputAssetA.erc20Address).transfer(address(bridge), inputValue); + + uint256 rollupProcessorEthBalanceBefore = rollupProcessor.balance; + + (uint256 outputValueA, uint256 outputValueB, ) = bridge.convert( + inputAssetA, + emptyAsset, + outputAssetA, + emptyAsset, + inputValue, + 1, + _getPrice(-1e20), + address(0) + ); + + uint256 rollupProcessorEthBalanceDiff = rollupProcessor.balance - rollupProcessorEthBalanceBefore; + assertEq(rollupProcessorEthBalanceDiff, outputValueA, "ETH received differs from outputValueA"); + + assertEq(outputValueB, 0, "Non-zero outputValueB"); + + // Check that the bridge doesn't hold any ETH or LUSD + assertEq(address(bridge).balance, 0, "Bridge holds ETH after interaction"); + assertEq(tokens["LUSD"].erc.balanceOf(address(bridge)), bridge.DUST(), "Bridge holds LUSD after interaction"); + + // Given that ICR was set to 160% and the debt has been repaid with collateral, received collateral should be + // approx. equal to (deposit collateral amount) * (100/160). Given that borrowing fee and fee for the flash + // swap was paid the actual collateral received will be slightly less. + uint256 expectedEthReceived = ROLLUP_PROCESSOR_ETH_BALANCE - (ROLLUP_PROCESSOR_ETH_BALANCE * 100) / 160; + + // Accepting to receive at most 0.05 ETH less after repaying + assertGt(rollupProcessorEthBalanceDiff, expectedEthReceived - 5e16, "Not enough collateral received"); + } + function _redeem() private { AztecTypes.AztecAsset memory inputAssetA = AztecTypes.AztecAsset( 2,