From ed5961b52403b5ef5c75313448edb19a97927745 Mon Sep 17 00:00:00 2001 From: Adam Cuculich <46691282+cucupac@users.noreply.github.com> Date: Tue, 23 Jan 2024 22:55:05 -0600 Subject: [PATCH] feat: add protocol fee (#7) * feat: add protocol fee * docs: update README with todo * chore: address requested changes --- README.md | 23 +++- src/FeeCollector.sol | 98 +++++++++++++++++ src/Position.sol | 28 +++-- src/interfaces/IFeeCollector.sol | 60 +++++++++++ src/interfaces/IPosition.sol | 70 +++++++++++- src/interfaces/IPositionFactory.sol | 58 ++++++++-- test/Position.t.sol | 158 ++++++++++++++++++++++++++-- test/common/Constants.t.sol | 2 + test/mocks/MockUniswap.t.sol | 10 ++ 9 files changed, 482 insertions(+), 25 deletions(-) create mode 100644 src/FeeCollector.sol create mode 100644 src/interfaces/IFeeCollector.sol diff --git a/README.md b/README.md index 61de0a8c..4acb20af 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,27 @@ On-chain shorting via Aave and Uniswap. ## To-Do -- [ ] Impelemnt protocol fee -- [ ] Implement frontend incentive +- [x] Impelemnt protocol fee +- [x] Implement frontend incentive +- [x] Account for case where there is no client (if no frontend, they would be able to pass their own address...) +- [x] Should not repay more to Aave than what is owed when closing a position + +Unit Tests: + +- [x] Mock feel collector in tests +- [x] Should not repay more to Aave than what is owed when closing a position +- [ ] Test that the client's collected fee balance increased by the correct amount +- [ ] Test that the totalClientBalance increased by the correct amount +- [ ] Test that an admin you can set a clientRate +- [ ] Test that a non-admin cannot set a clientRate +- [ ] Test that a client can withdraw their collected fees +- [ ] Test that extractERC20 works correctly on FeeCollector (it has different logic than the other contracts) +- [ ] Test that a non-admin cannot extractERC20 +- [ ] Test that an admin can withdraw native +- [ ] Test that a non-admin cannot withdraw native +- [ ] Test that FeeCollector can recieve native +- [ ] Test Fallback on FeeCollector +- [ ] Test that the correct protocol fee is collected during a short (proabably separate test from Position.t.sol) Considerations: diff --git a/src/FeeCollector.sol b/src/FeeCollector.sol new file mode 100644 index 00000000..d97cc8ec --- /dev/null +++ b/src/FeeCollector.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +// Local Imports +import { Ownable } from "src/dependencies/access/Ownable.sol"; +import { SafeTransferLib, ERC20 } from "solmate/utils/SafeTransferLib.sol"; +import { IERC20 } from "src/interfaces/token/IERC20.sol"; + +/// @title FeeCollector +/// @author Chain Rule, LLC +/// @notice Collects protocol fees +contract FeeCollector is Ownable { + // Constants: no SLOAD to save gas + address private constant CONTRACT_DEPLOYER = 0x0a5B347509621337cDDf44CBCf6B6E7C9C908CD2; + + // Storage + uint256 public clientRate; + mapping(address => uint256) public totalClientBalances; + mapping(address => mapping(address => uint256)) public balances; + + // Errors + error Unauthorized(); + error OutOfRange(); + + constructor(address _owner) Ownable(_owner) { + if (msg.sender != CONTRACT_DEPLOYER) revert Unauthorized(); + } + + /** + * @notice Collects fees from Position contracts when collateral is added. + * @param _client The address, controlled by client operators, for receiving protocol fees. + * @param _token The token to collect fees in (the collateral token of the calling Position contract). + * @param _amt The total amount of fees to collect. + */ + function collectFees(address _client, address _token, uint256 _amt) external payable { + // 1. Transfer tokens to this contract + SafeTransferLib.safeTransferFrom(ERC20(_token), msg.sender, address(this), _amt); + + // 2. Update client balances + if (_client != address(0)) { + uint256 clientFee = (_amt * clientRate) / 100; + balances[_client][_token] += clientFee; + totalClientBalances[_token] += clientFee; + } + } + + /** + * @notice Withdraw collected fees from this contract. + * @param _token The token address to withdraw. + */ + function clientWithdraw(address _token) public payable { + uint256 withdrawAmt = balances[msg.sender][_token]; + + // 1. Update accounting + balances[msg.sender][_token] -= withdrawAmt; + totalClientBalances[_token] -= withdrawAmt; + + // 2. Transfer tokens to msg.sender + SafeTransferLib.safeTransfer(ERC20(_token), msg.sender, withdrawAmt); + } + + /** + * @notice Allows owner to set client rate. + * @param _clientRate The percentage of total transaction-specific protocol fee, allocated to the utilized client. + */ + function setClientRate(uint256 _clientRate) public payable onlyOwner { + if (_clientRate < 30 || _clientRate > 100) revert OutOfRange(); + + clientRate = _clientRate; + } + + /** + * @notice Allows OWNER to withdraw all of this contract's native token balance. + */ + function extractNative() public payable onlyOwner { + payable(msg.sender).transfer(address(this).balance); + } + + /** + * @notice Allows owner to withdraw protocol fees from this contract. + * @param _token The address of token to remove. + */ + function extractERC20(address _token) public payable onlyOwner { + uint256 withdrawAmt = IERC20(_token).balanceOf(address(this)) - totalClientBalances[_token]; + + SafeTransferLib.safeTransfer(ERC20(_token), msg.sender, withdrawAmt); + } + + /** + * @notice Executes when native is sent to this contract through a non-existent function. + */ + fallback() external payable { } // solhint-disable-line no-empty-blocks + + /** + * @notice Executes when native is sent to this contract with a plain transaction. + */ + receive() external payable { } // solhint-disable-line no-empty-blocks +} diff --git a/src/Position.sol b/src/Position.sol index bc659504..f29fb2fa 100644 --- a/src/Position.sol +++ b/src/Position.sol @@ -6,11 +6,16 @@ import { DebtService } from "src/services/DebtService.sol"; import { SwapService } from "src/services/SwapService.sol"; import { SafeTransferLib, ERC20 } from "solmate/utils/SafeTransferLib.sol"; import { IERC20 } from "src/interfaces/token/IERC20.sol"; +import { IFeeCollector } from "src/interfaces/IFeeCollector.sol"; /// @title Position /// @author Chain Rule, LLC /// @notice Manages the owner's individual position contract Position is DebtService, SwapService { + // Constants: no SLOAD to save gas + uint256 public constant PROTOCOL_FEE = 3; + address private constant FEE_COLLECTOR = 0x2cD6D948263F20C3c27f181f14647840fC64b488; + // Immutables: no SLOAD to save gas address public immutable B_TOKEN; @@ -30,19 +35,30 @@ contract Position is DebtService, SwapService { * @param _ltv The desired loan-to-value ratio for this transaction-specific loan (ex: 75 is 75%). * @param _swapAmtOutMin The minimum amount of output tokens from swap for the tx to go through. * @param _poolFee The fee of the Uniswap pool. + * @param _client The address, controlled by client operators, for receiving protocol fees (use address(0) if no client). */ - function short(uint256 _cAmt, uint256 _ltv, uint256 _swapAmtOutMin, uint24 _poolFee) public payable onlyOwner { + function short(uint256 _cAmt, uint256 _ltv, uint256 _swapAmtOutMin, uint24 _poolFee, address _client) + public + payable + onlyOwner + { // 1. Transfer collateral to this contract SafeTransferLib.safeTransferFrom(ERC20(C_TOKEN), msg.sender, address(this), _cAmt); - // 2. Borrow debt token - uint256 dAmt = _borrow(_cAmt, _ltv); + // 2. Take protocol fee + uint256 protocolFee = (_cAmt * PROTOCOL_FEE) / 1000; + uint256 cAmtNet = _cAmt - protocolFee; + SafeTransferLib.safeApprove(ERC20(C_TOKEN), FEE_COLLECTOR, protocolFee); + IFeeCollector(FEE_COLLECTOR).collectFees(_client, C_TOKEN, protocolFee); + + // 3. Borrow debt token + uint256 dAmt = _borrow(cAmtNet, _ltv); - // 3. Swap debt token for base token + // 4. Swap debt token for base token (, uint256 bAmt) = _swapExactInput(D_TOKEN, B_TOKEN, dAmt, _swapAmtOutMin, _poolFee); - // 4. Emit event - emit Short(_cAmt, dAmt, bAmt); + // 5. Emit event + emit Short(cAmtNet, dAmt, bAmt); } /** diff --git a/src/interfaces/IFeeCollector.sol b/src/interfaces/IFeeCollector.sol new file mode 100644 index 00000000..a46bd82c --- /dev/null +++ b/src/interfaces/IFeeCollector.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +interface IFeeCollector { + /* solhint-disable func-name-mixedcase */ + /* **************************************************************************** + ** + ** CORE FUNCTIONS + ** + ******************************************************************************/ + /** + * @notice Returns the total balance for the specified token across all client operators. + * @param _token The token address to check. + * @return balance The total balance for the specified token across all client operators. + */ + function totalClientBalances(address _token) external view returns (uint256); + + /** + * @notice Returns the balance for the specified token for the specified client operator. + * @param _client A client operator address. + * @param _token The token address to check. + * @return balance The balance for the specified token for the specified client operator. + */ + function balances(address _client, address _token) external view returns (uint256); + + /** + * @notice Collects fees from Position contracts when collateral is added. + * @param _client The address, controlled by client operators, for receiving protocol fees. + * @param _token The token to collect fees in (the collateral token of the calling Position contract). + * @param _amt The total amount of fees to collect. + */ + function collectFees(address _client, address _token, uint256 _amt) external payable; + /** + * @notice Withdraw collected fees from this contract. + * @param _token The token address to withdraw. + */ + function clientWithdraw(address _token) external payable; + + /* **************************************************************************** + ** + ** ADMIN FUNCTIONS + ** + ******************************************************************************/ + /** + * @notice Allows owner to set client rate. + * @param _clientRate The percentage of total transaction-specific protocol fee, allocated to the utilized client. + */ + function setClientRate(uint256 _clientRate) external payable; + + /** + * @notice Allows owner to withdraw all of this contract's native token balance. + */ + function extractNative() external payable; + + /** + * @notice Allows owner to withdraw protocol fees from this contract. + * @param _token The address of token to remove. + */ + function extractERC20(address _token) external payable; +} diff --git a/src/interfaces/IPosition.sol b/src/interfaces/IPosition.sol index c617eb3e..e88114d9 100644 --- a/src/interfaces/IPosition.sol +++ b/src/interfaces/IPosition.sol @@ -3,21 +3,83 @@ pragma solidity ^0.8.21; interface IPosition { /* solhint-disable func-name-mixedcase */ - // Meta data + /* **************************************************************************** + ** + ** METADATA + ** + ******************************************************************************/ + /** + * @notice Returns the owner of this contract. + */ function OWNER() external returns (address); + /** + * @notice Returns the address of this position's collateral token. + */ function C_TOKEN() external returns (address); + /** + * @notice Returns the address of this position's debt token. + */ function D_TOKEN() external returns (address); + /** + * @notice Returns the address of this position's base token (the token that the debt token is swapped for when shorting). + */ function B_TOKEN() external returns (address); - // Core Functions - function short(uint256 _cAmt, uint256 _ltv, uint256 _swapAmtOutMin, uint24 _poolFee) external payable; + /* **************************************************************************** + ** + ** CORE FUNCTIONS + ** + ******************************************************************************/ + /** + * @notice Adds to this contract's short position. + * @param _cAmt The amount of collateral to be supplied for this transaction-specific loan (units: C_DECIMALS). + * @param _ltv The desired loan-to-value ratio for this transaction-specific loan (ex: 75 is 75%). + * @param _swapAmtOutMin The minimum amount of output tokens from swap for the tx to go through. + * @param _poolFee The fee of the Uniswap pool. + * @param _client The address, controlled by client operators, for receiving protocol fees (use address(0) if no client). + */ + function short(uint256 _cAmt, uint256 _ltv, uint256 _swapAmtOutMin, uint24 _poolFee, address _client) + external + payable; + + /** + * @notice Fully closes the short position. + * @param _poolFee The fee of the Uniswap pool. + * @param _exactOutput Whether to swap exact output or exact input (true for exact output, false for exact input). + * @param _swapAmtOutMin The minimum amount of output tokens from swap for the tx to go through (only used if _exactOutput is false, supply 0 if true). + * @param _withdrawBuffer The amount of collateral left as safety buffer for tx to go through (default = 100_000, units: 8 decimals). + */ function close(uint24 _poolFee, bool _exactOutput, uint256 _swapAmtOutMin, uint256 _withdrawBuffer) external payable; + + /** + * @notice Increases the collateral amount for this contract's loan. + * @param _cAmt The amount of collateral to be supplied (units: C_DECIMALS). + */ function addCollateral(uint256 _cAmt) external payable; + + /** + * @notice Repays any outstanding debt to Aave and transfers remaining collateral from Aave to owner. + * @param _dAmt The amount of debt token to repay to Aave (units: D_DECIMALS). + * To pay off entire debt, _dAmt = debtOwed + smallBuffer (to account for interest). + * @param _withdrawBuffer The amount of collateral left as safety buffer for tx to go through (default = 100_000, units: 8 decimals). + */ function repayAfterClose(uint256 _dAmt, uint256 _withdrawBuffer) external payable; - // Admin Functions + /* **************************************************************************** + ** + ** ADMIN FUNCTIONS + ** + ******************************************************************************/ + /** + * @notice Allows owner to withdraw all of this contract's native token balance. + */ function extractNative() external payable; + + /** + * @notice Allows owner to withdraw all of a specified ERC20 token's balance from this contract. + * @param _token The address of token to remove. + */ function extractERC20(address _token) external payable; } diff --git a/src/interfaces/IPositionFactory.sol b/src/interfaces/IPositionFactory.sol index b01f484c..8a974701 100644 --- a/src/interfaces/IPositionFactory.sol +++ b/src/interfaces/IPositionFactory.sol @@ -3,19 +3,65 @@ pragma solidity ^0.8.21; interface IPositionFactory { /* solhint-disable func-name-mixedcase */ - // Meta data + /* **************************************************************************** + ** + ** METADATA + ** + ******************************************************************************/ + + /** + * @notice Returns the owner of this contract. + */ function OWNER() external returns (address); - function positions(address, address, address, address) external view returns (address); - function positionsLookup(address) external view returns (address[] memory); - // Core Functions + /** + * @notice Returns the address of an owner's specified Position contract. + */ + function positions(address _owner, address _cToken, address _dToken, address _bToken) + external + view + returns (address); + + /** + * @notice Returns a list of contract addresses for the given owner. + */ + function positionsLookup(address _owner) external view returns (address[] memory); + + /* **************************************************************************** + ** + ** CORE FUNCTIONS + ** + ******************************************************************************/ + /** + * @notice Deploys a Position contract for msg.sender, given a _cToken, _dToken, and _bToken. + * @param _cToken The address of the token to be used as collateral. + * @param _dToken The address of the token to be borrowed. + * @param _bToken The address of the token to swap _dToken for. + */ function createPosition(address _cToken, address _dToken, address _bToken) external payable returns (address position); - function getPositions(address _owner) external view returns (address[] memory); - // Admin Functions + /** + * @notice Returns a list of contract addresses for the given _positionOwner. + * @param _positionOwner The owner of the Position contracts. + */ + function getPositions(address _positionOwner) external view returns (address[] memory); + + /* **************************************************************************** + ** + ** ADMIN FUNCTIONS + ** + ******************************************************************************/ + /** + * @notice Allows owner to withdraw all of this contract's native token balance. + */ function extractNative() external payable; + + /** + * @notice Allows owner to withdraw all of a specified ERC20 token's balance from this contract. + * @param _token The address of token to remove. + */ function extractERC20(address _token) external payable; } diff --git a/test/Position.t.sol b/test/Position.t.sol index ccfca183..7748a511 100644 --- a/test/Position.t.sol +++ b/test/Position.t.sol @@ -8,14 +8,18 @@ import { VmSafe } from "forge-std/Vm.sol"; // Local Imports import { PositionFactory } from "src/PositionFactory.sol"; import { PositionAdmin } from "src/PositionAdmin.sol"; +import { FeeCollector } from "src/FeeCollector.sol"; import { Assets, AAVE_ORACLE, CONTRACT_DEPLOYER, DAI, + FEE_COLLECTOR, PROFIT_PERCENT, + REPAY_BUFFER, REPAY_PERCENT, SWAP_ROUTER, + TEST_CLIENT, USDC, WBTC, WETH, @@ -45,6 +49,8 @@ contract PositionTest is Test, TokenUtils, DebtUtils { uint256 postVDToken; uint256 preAToken; uint256 postAToken; + uint256 preDToken; + uint256 postDToken; } struct OwnerBalances { @@ -56,6 +62,7 @@ contract PositionTest is Test, TokenUtils, DebtUtils { // Test contracts PositionFactory public positionFactory; + FeeCollector public feeCollector; Assets public assets; TestPosition[] public positions; @@ -80,6 +87,10 @@ contract PositionTest is Test, TokenUtils, DebtUtils { vm.prank(CONTRACT_DEPLOYER); positionFactory = new PositionFactory(CONTRACT_DEPLOYER); + // Deploy FeeCollector + vm.prank(CONTRACT_DEPLOYER); + feeCollector = new FeeCollector(CONTRACT_DEPLOYER); + // Deploy and store all possible positions for (uint256 i; i < supportedAssets.length; i++) { address cToken = supportedAssets[i]; @@ -109,6 +120,10 @@ contract PositionTest is Test, TokenUtils, DebtUtils { abi.encode(assets.prices(supportedAssets[i])) ); } + + // Mock FeeCollector + bytes memory code = address(feeCollector).code; + vm.etch(FEE_COLLECTOR, code); } /// @dev @@ -149,7 +164,7 @@ contract PositionTest is Test, TokenUtils, DebtUtils { // Act vm.recordLogs(); - IPosition(addr).short(_cAmt, _ltv, 0, 3000); + IPosition(addr).short(_cAmt, _ltv, 0, 3000, TEST_CLIENT); VmSafe.Log[] memory entries = vm.getRecordedLogs(); // Post-act balances @@ -199,7 +214,7 @@ contract PositionTest is Test, TokenUtils, DebtUtils { // Act vm.prank(_sender); vm.expectRevert(PositionAdmin.Unauthorized.selector); - IPosition(addr).short(cAmt, ltv, 0, 3000); + IPosition(addr).short(cAmt, ltv, 0, 3000, TEST_CLIENT); // Revert to snapshot vm.revertTo(id); @@ -212,7 +227,7 @@ contract PositionTest is Test, TokenUtils, DebtUtils { // - Owner's cToken balance should increase by the amount of collateral withdrawn. // - Owner's bToken balance should increase by the position's gains amount. // - The above should be true for all supported tokens. - function test_CloseWithGains() public { + function test_CloseWithGainsExactOutput() public { // Setup ContractBalances memory contractBalances; OwnerBalances memory ownerBalances; @@ -229,7 +244,7 @@ contract PositionTest is Test, TokenUtils, DebtUtils { uint256 ltv = 50; _fund(owner, positions[i].cToken, cAmt); IERC20(positions[i].cToken).approve(addr, cAmt); - IPosition(addr).short(cAmt, ltv, 0, 3000); + IPosition(addr).short(cAmt, ltv, 0, 3000, TEST_CLIENT); // Get pre-act balances contractBalances.preBToken = IERC20(positions[i].bToken).balanceOf(addr); @@ -288,13 +303,105 @@ contract PositionTest is Test, TokenUtils, DebtUtils { } } + /// @dev + // - Position contract's bToken balance should go to 0. + // - Position contract's debt on Aave should go to 0. + // - Position contract's dToken balance should be the amount received from swap minus the amount repaid to Aave. + // - Owner's cToken balance should increase by the amount of collateral withdrawn. + // - Owner's bToken balance should stay the same, as there are no gains. + // - The above should be true for all supported tokens. + function testFuzz_CloseWithGainsExactInput(uint256 _dAmtRemainder) public { + // Setup + ContractBalances memory contractBalances; + OwnerBalances memory ownerBalances; + + // Take snapshot + uint256 id = vm.snapshot(); + + for (uint256 i; i < positions.length; i++) { + // Test variables + address addr = positions[i].addr; + + // Setup: open short position + uint256 cAmt = assets.maxCAmts(positions[i].cToken); + uint256 ltv = 50; + _fund(owner, positions[i].cToken, cAmt); + IERC20(positions[i].cToken).approve(addr, cAmt); + IPosition(addr).short(cAmt, ltv, 0, 3000, TEST_CLIENT); + + // Get pre-act balances + contractBalances.preBToken = IERC20(positions[i].bToken).balanceOf(addr); + contractBalances.preVDToken = _getVariableDebtTokenBalance(addr, positions[i].dToken); + contractBalances.preAToken = _getATokenBalance(addr, positions[i].cToken); + contractBalances.preDToken = IERC20(positions[i].dToken).balanceOf(addr); + ownerBalances.preBToken = IERC20(positions[i].bToken).balanceOf(owner); + ownerBalances.preCToken = IERC20(positions[i].cToken).balanceOf(owner); + + // Assertions + assertEq(ownerBalances.preBToken, 0); + assertEq(contractBalances.preDToken, 0); + assertNotEq(contractBalances.preBToken, 0); + assertNotEq(contractBalances.preVDToken, 0); + + // Bound: upper bound is 150% of preVDToken + uint256 upperBound = contractBalances.preVDToken + (contractBalances.preVDToken * 50) / 100; + _dAmtRemainder = bound(_dAmtRemainder, 2, upperBound); + + // Mock Uniswap to ensure position gains + uint256 amountOut = contractBalances.preVDToken + _dAmtRemainder; + _fund(SWAP_ROUTER, positions[i].dToken, contractBalances.preVDToken + _dAmtRemainder); + bytes memory code = address(new MockUniswapGains()).code; + vm.etch(SWAP_ROUTER, code); + + // Act + /// @dev start event recorder + vm.recordLogs(); + IPosition(addr).close(3000, false, 0, WITHDRAW_BUFFER); + VmSafe.Log[] memory entries = vm.getRecordedLogs(); + + // Get post-act balances + contractBalances.postBToken = IERC20(positions[i].bToken).balanceOf(addr); + contractBalances.postVDToken = _getVariableDebtTokenBalance(addr, positions[i].dToken); + contractBalances.postAToken = _getATokenBalance(addr, positions[i].cToken); + contractBalances.postDToken = IERC20(positions[i].dToken).balanceOf(addr); + ownerBalances.postBToken = IERC20(positions[i].bToken).balanceOf(owner); + ownerBalances.postCToken = IERC20(positions[i].cToken).balanceOf(owner); + + bytes memory closeEvent = entries[entries.length - 1].data; + uint256 gains; + + assembly { + gains := mload(add(closeEvent, 0x20)) + } + + // Assertions: + assertApproxEqAbs(contractBalances.postDToken, amountOut - contractBalances.preVDToken, 1); + assertEq(contractBalances.postBToken, 0); + assertEq(contractBalances.postVDToken, 0); + assertEq(contractBalances.postAToken, 0); + assertEq(gains, 0); + + if (positions[i].bToken == positions[i].cToken) { + /// @dev In this case, bToken and cToken balances will increase by the same amount (collateral withdrawn) + assertEq(ownerBalances.postBToken, ownerBalances.preBToken + contractBalances.preAToken); + assertEq(ownerBalances.postCToken, ownerBalances.postBToken); + } else { + assertEq(ownerBalances.postBToken, ownerBalances.preBToken); + assertEq(ownerBalances.postCToken, ownerBalances.preCToken + contractBalances.preAToken); + } + + // Revert to snapshot + vm.revertTo(id); + } + } + /// @dev // - Position contract's bToken balance should go to 0. // - Position contract's debt on Aave should decrease by the amount received from the swap. // - Owner's cToken balance should increase by the amount of collateral withdrawn. // - If bToken != cToken, the owner's bToken balance should not increase. // - The above should be true for all supported tokens. - function test_CloseNoGains() public { + function test_CloseNoGainsExactInput() public { // Setup ContractBalances memory contractBalances; OwnerBalances memory ownerBalances; @@ -311,7 +418,7 @@ contract PositionTest is Test, TokenUtils, DebtUtils { uint256 ltv = 50; _fund(owner, positions[i].cToken, cAmt); IERC20(positions[i].cToken).approve(addr, cAmt); - IPosition(addr).short(cAmt, ltv, 0, 3000); + IPosition(addr).short(cAmt, ltv, 0, 3000, TEST_CLIENT); // Get pre-act balances contractBalances.preBToken = IERC20(positions[i].bToken).balanceOf(addr); @@ -361,6 +468,43 @@ contract PositionTest is Test, TokenUtils, DebtUtils { } } + /// @dev + // - It should revert because the position contract doesn't have enough bTokens to facilitate the exact output swap. + function testFail_CloseNoGainsExactOutput() public { + // Setup + ContractBalances memory contractBalances; + + // Take snapshot + uint256 id = vm.snapshot(); + + for (uint256 i; i < positions.length; i++) { + // Test variables + address addr = positions[i].addr; + + // Setup: open short position + uint256 cAmt = assets.maxCAmts(positions[i].cToken); + uint256 ltv = 50; + _fund(owner, positions[i].cToken, cAmt); + IERC20(positions[i].cToken).approve(addr, cAmt); + IPosition(addr).short(cAmt, ltv, 0, 3000, TEST_CLIENT); + + // Get pre-act balances + contractBalances.preVDToken = _getVariableDebtTokenBalance(addr, positions[i].dToken); + + // Mock Uniswap to ensure position gains + uint256 repayAmt = contractBalances.preVDToken * REPAY_PERCENT / 100; + _fund(SWAP_ROUTER, positions[i].dToken, repayAmt); + bytes memory code = address(new MockUniswapLosses()).code; + vm.etch(SWAP_ROUTER, code); + + // Act + IPosition(addr).close(3000, true, 0, WITHDRAW_BUFFER); + + // Revert to snapshot + vm.revertTo(id); + } + } + /// @dev // - It should revert with Unauthorized() error when called by an unauthorized sender. function testFuzz_CannotClose(address _sender) public { @@ -381,7 +525,7 @@ contract PositionTest is Test, TokenUtils, DebtUtils { uint256 cAmt = assets.maxCAmts(positions[i].cToken); _fund(owner, positions[i].cToken, cAmt); IERC20(positions[i].cToken).approve(addr, cAmt); - IPosition(addr).short(cAmt, ltv, 0, 3000); + IPosition(addr).short(cAmt, ltv, 0, 3000, TEST_CLIENT); // Act vm.prank(_sender); diff --git a/test/common/Constants.t.sol b/test/common/Constants.t.sol index 883a6bba..578c8a6e 100644 --- a/test/common/Constants.t.sol +++ b/test/common/Constants.t.sol @@ -5,6 +5,8 @@ address constant CONTRACT_DEPLOYER = 0x0a5B347509621337cDDf44CBCf6B6E7C9C908CD2; address constant AAVE_ORACLE = 0xb56c2F0B653B2e0b10C9b928C8580Ac5Df02C7C7; address constant AAVE_POOL = 0x794a61358D6845594F94dc1DB02A252b5b4814aD; address constant SWAP_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564; +address constant FEE_COLLECTOR = 0x2cD6D948263F20C3c27f181f14647840fC64b488; +address constant TEST_CLIENT = 0xd05E2C879821C31E5a8e3B5Da490e834211Ca443; // Supported Assets address constant USDC = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831; diff --git a/test/mocks/MockUniswap.t.sol b/test/mocks/MockUniswap.t.sol index 84421ab7..19ebd7c9 100644 --- a/test/mocks/MockUniswap.t.sol +++ b/test/mocks/MockUniswap.t.sol @@ -18,6 +18,16 @@ contract MockUniswapGains { TransferHelper.safeTransferFrom(params.tokenIn, msg.sender, address(this), amtIn); TransferHelper.safeTransfer(params.tokenOut, msg.sender, amtOut); } + + function exactInputSingle(ISwapRouter.ExactInputSingleParams calldata params) + public + payable + returns (uint256 amtOut) + { + amtOut = IERC20(params.tokenOut).balanceOf(address(this)); + TransferHelper.safeTransferFrom(params.tokenIn, msg.sender, address(this), params.amountIn); + TransferHelper.safeTransfer(params.tokenOut, msg.sender, amtOut); + } } contract MockUniswapLosses {