diff --git a/README.md b/README.md index ba4b49f8..f65a6fad 100644 --- a/README.md +++ b/README.md @@ -8,34 +8,21 @@ On-chain shorting via Aave and Uniswap. ## Principles -1. Immutable -2. No Governance -3. No Admin Keys +The following outlines principles for core protocol funcitonality. + +1. Immutable. +2. No Governance. +3. No Admin Keys. ## To-Do Logic: -- [x] Make `FEE_COLLECTOR` an immutable variable in Position.sol and PositionFactory.sol -- [x] Add `owner()` and `clientRate()` to IFeeCollector +- All caught up!🙂 Tests: - [ ] Separate integration tests from unit tests (separate PR) -- [x] testFuzz_CollectFeesWithClient -- [x] testFuzz_CollectFeesNoClient -- [x] testFuzz_CollectFeesWithClientIntegrated -- [x] testFuzz_CollectFeesNoClientIntegrated -- [x] testFuzz_ClientWithdraw -- [x] testFuzz_SetClientRate -- [x] testFuzz_CannotSetClientRateOutOfRange -- [x] testFuzz_CannotSetClientRateUnauthorized -- [x] testFuzz_ExtractNative -- [x] testFuzz_CannotExtractNative -- [x] testFuzz_ExtractERC20 -- [x] testFuzz_CannotExtractERC20 -- [x] testFuzz_Receive -- [x] testFuzz_Fallback Considerations: diff --git a/src/Position.sol b/src/Position.sol index 44010af2..cb93cfad 100644 --- a/src/Position.sol +++ b/src/Position.sol @@ -6,6 +6,7 @@ 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 { IERC20Permit } from "src/interfaces/token/IERC20Permit.sol"; import { IFeeCollector } from "src/interfaces/IFeeCollector.sol"; /// @title Position @@ -62,6 +63,36 @@ contract Position is DebtService, SwapService { emit Short(cAmtNet, dAmt, bAmt); } + /** + * @notice Adds to this contract's short position with permit, obviating the need for a separate approve tx. + * @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). + * @param _deadline The deadline timestamp that the permit is valid. + * @param _v The V parameter of ERC712 permit signature. + * @param _r The R parameter of ERC712 permit signature. + * @param _s The S parameter of ERC712 permit signature. + */ + function shortWithPermit( + uint256 _cAmt, + uint256 _ltv, + uint256 _swapAmtOutMin, + uint24 _poolFee, + address _client, + uint256 _deadline, + uint8 _v, + bytes32 _r, + bytes32 _s + ) public payable onlyOwner { + // 1. Approve with permit + IERC20Permit(C_TOKEN).permit(msg.sender, address(this), _cAmt, _deadline, _v, _r, _s); + + // 2. Short + short(_cAmt, _ltv, _swapAmtOutMin, _poolFee, _client); + } + /** * @notice Fully closes the short position. * @param _poolFee The fee of the Uniswap pool. diff --git a/src/interfaces/IPosition.sol b/src/interfaces/IPosition.sol index e88114d9..d37eed73 100644 --- a/src/interfaces/IPosition.sol +++ b/src/interfaces/IPosition.sol @@ -42,6 +42,30 @@ interface IPosition { external payable; + /** + * @notice Adds to this contract's short position with permit, obviating the need for a separate approve tx. + * @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). + * @param _deadline The deadline timestamp that the permit is valid. + * @param _v The V parameter of ERC712 permit signature. + * @param _r The R parameter of ERC712 permit signature. + * @param _s The S parameter of ERC712 permit signature. + */ + function shortWithPermit( + uint256 _cAmt, + uint256 _ltv, + uint256 _swapAmtOutMin, + uint24 _poolFee, + address _client, + uint256 _deadline, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external payable; + /** * @notice Fully closes the short position. * @param _poolFee The fee of the Uniswap pool. @@ -59,6 +83,18 @@ interface IPosition { */ function addCollateral(uint256 _cAmt) external payable; + /** + * @notice Increases the collateral amount for this contract's loan with permit, obviating the need for a separate approve tx. + * @param _cAmt The amount of collateral to be supplied (units: C_DECIMALS). + * @param _deadline The deadline timestamp that the permit is valid. + * @param _v The V parameter of ERC712 permit signature. + * @param _r The R parameter of ERC712 permit signature. + * @param _s The S parameter of ERC712 permit signature. + */ + function addCollateralWithPermit(uint256 _cAmt, uint256 _deadline, uint8 _v, bytes32 _r, bytes32 _s) + 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). @@ -67,6 +103,26 @@ interface IPosition { */ function repayAfterClose(uint256 _dAmt, uint256 _withdrawBuffer) external payable; + /** + * @notice Repays any outstanding debt to Aave and transfers remaining collateral from Aave to owner, + * with permit, obviating the need for a separate approve tx. + * @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). + * @param _deadline The deadline timestamp that the permit is valid. + * @param _v The V parameter of ERC712 permit signature. + * @param _r The R parameter of ERC712 permit signature. + * @param _s The S parameter of ERC712 permit signature. + */ + function repayAfterCloseWithPermit( + uint256 _dAmt, + uint256 _withdrawBuffer, + uint256 _deadline, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external payable; + /* **************************************************************************** ** ** ADMIN FUNCTIONS diff --git a/src/interfaces/aave/IPool.sol b/src/interfaces/aave/IPool.sol index 2c513840..5116053f 100644 --- a/src/interfaces/aave/IPool.sol +++ b/src/interfaces/aave/IPool.sol @@ -254,12 +254,12 @@ interface IPool { * @param onBehalfOf The address that will receive the aTokens, same as msg.sender if the user * wants to receive them on his own wallet, or a different address if the beneficiary of aTokens * is a different wallet - * @param deadline The deadline timestamp that the permit is valid + * @param deadline The deadline timestamp that the permit is valid. * @param referralCode Code used to register the integrator originating the operation, for potential rewards. * 0 if the action is executed directly by the user, without any middle-man - * @param permitV The V parameter of ERC712 permit sig - * @param permitR The R parameter of ERC712 permit sig - * @param permitS The S parameter of ERC712 permit sig + * @param permitV The V parameter of ERC712 permit signature. + * @param permitR The R parameter of ERC712 permit signature. + * @param permitS The S parameter of ERC712 permit signature. * */ function supplyWithPermit( @@ -333,10 +333,10 @@ interface IPool { * @param onBehalfOf Address of the user who will get his debt reduced/removed. Should be the address of the * user calling the function if he wants to reduce/remove his own debt, or the address of any other * other borrower whose debt should be removed - * @param deadline The deadline timestamp that the permit is valid - * @param permitV The V parameter of ERC712 permit sig - * @param permitR The R parameter of ERC712 permit sig - * @param permitS The S parameter of ERC712 permit sig + * @param deadline The deadline timestamp that the permit is valid. + * @param permitV The V parameter of ERC712 permit signature. + * @param permitR The R parameter of ERC712 permit signature. + * @param permitS The S parameter of ERC712 permit signature. * @return The final amount repaid * */ diff --git a/src/interfaces/token/IERC20Permit.sol b/src/interfaces/token/IERC20Permit.sol new file mode 100644 index 00000000..5d4fb8cb --- /dev/null +++ b/src/interfaces/token/IERC20Permit.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.0.0) (token/ERC20/extensions/IERC20Permit.sol) + +pragma solidity ^0.8.21; + +/** + * @dev Interface of the ERC-20 Permit extension allowing approvals to be made via signatures, as defined in + * https://eips.ethereum.org/EIPS/eip-2612[ERC-2612]. + * + * Adds the {permit} method, which can be used to change an account's ERC-20 allowance (see {IERC20-allowance}) by + * presenting a message signed by the account. By not relying on {IERC20-approve}, the token holder account doesn't + * need to send a transaction, and thus is not required to hold Ether at all. + * + * ==== Security Considerations + * + * There are two important considerations concerning the use of `permit`. The first is that a valid permit signature + * expresses an allowance, and it should not be assumed to convey additional meaning. In particular, it should not be + * considered as an intention to spend the allowance in any specific way. The second is that because permits have + * built-in replay protection and can be submitted by anyone, they can be frontrun. A protocol that uses permits should + * take this into consideration and allow a `permit` call to fail. Combining these two aspects, a pattern that may be + * generally recommended is: + * + * ```solidity + * function doThingWithPermit(..., uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) public { + * try token.permit(msg.sender, address(this), value, deadline, v, r, s) {} catch {} + * doThing(..., value); + * } + * + * function doThing(..., uint256 value) public { + * token.safeTransferFrom(msg.sender, address(this), value); + * ... + * } + * ``` + * + * Observe that: 1) `msg.sender` is used as the owner, leaving no ambiguity as to the signer intent, and 2) the use of + * `try/catch` allows the permit to fail and makes the code tolerant to frontrunning. (See also + * {SafeERC20-safeTransferFrom}). + * + * Additionally, note that smart contract wallets (such as Argent or Safe) are not able to produce permit signatures, so + * contracts should have entry points that don't rely on permit. + */ +interface IERC20Permit { + /** + * @dev Sets `value` as the allowance of `spender` over ``owner``'s tokens, + * given ``owner``'s signed approval. + * + * IMPORTANT: The same issues {IERC20-approve} has related to transaction + * ordering also apply here. + * + * Emits an {Approval} event. + * + * Requirements: + * + * - `spender` cannot be the zero address. + * - `deadline` must be a timestamp in the future. + * - `v`, `r` and `s` must be a valid `secp256k1` signature from `owner` + * over the EIP712-formatted function arguments. + * - the signature must use ``owner``'s current nonce (see {nonces}). + * + * For more information on the signature format, see the + * https://eips.ethereum.org/EIPS/eip-2612#specification[relevant EIP + * section]. + * + * CAUTION: See Security Considerations above. + */ + function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + external; + + /** + * @dev Returns the current nonce for `owner`. This value must be + * included whenever a signature is generated for {permit}. + * + * Every successful call to {permit} increases ``owner``'s nonce by one. This + * prevents a signature from being used multiple times. + */ + function nonces(address owner) external view returns (uint256); + + /** + * @dev Returns the domain separator used in the encoding of the signature for {permit}, as defined by {EIP712}. + */ + // solhint-disable-next-line func-name-mixedcase + function DOMAIN_SEPARATOR() external view returns (bytes32); +} diff --git a/src/services/DebtService.sol b/src/services/DebtService.sol index c6102d0c..2408af4c 100644 --- a/src/services/DebtService.sol +++ b/src/services/DebtService.sol @@ -6,6 +6,7 @@ import { PositionAdmin } from "src/PositionAdmin.sol"; import { SafeTransferLib, ERC20 } from "solmate/utils/SafeTransferLib.sol"; import { IPool } from "src/interfaces/aave/IPool.sol"; import { IERC20 } from "src/interfaces/token/IERC20.sol"; +import { IERC20Permit } from "src/interfaces/token/IERC20Permit.sol"; import { IAaveOracle } from "src/interfaces/aave/IAaveOracle.sol"; import { IERC20Metadata } from "src/interfaces/token/IERC20Metadata.sol"; @@ -125,6 +126,26 @@ contract DebtService is PositionAdmin { IPool(AAVE_POOL).supply(C_TOKEN, _cAmt, address(this), 0); } + /** + * @notice Increases the collateral amount for this contract's loan with permit, obviating the need for a separate approve tx. + * @param _cAmt The amount of collateral to be supplied (units: C_DECIMALS). + * @param _deadline The deadline timestamp that the permit is valid. + * @param _v The V parameter of ERC712 permit signature. + * @param _r The R parameter of ERC712 permit signature. + * @param _s The S parameter of ERC712 permit signature. + */ + function addCollateralWithPermit(uint256 _cAmt, uint256 _deadline, uint8 _v, bytes32 _r, bytes32 _s) + public + payable + onlyOwner + { + // 1. Approve with permit + IERC20Permit(C_TOKEN).permit(msg.sender, address(this), _cAmt, _deadline, _v, _r, _s); + + // 2. Add Collateral + addCollateral(_cAmt); + } + /** * @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). @@ -138,4 +159,30 @@ contract DebtService is PositionAdmin { _withdraw(OWNER, _withdrawBuffer); } + + /** + * @notice Repays any outstanding debt to Aave and transfers remaining collateral from Aave to owner, + * with permit, obviating the need for a separate approve tx. + * @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). + * @param _deadline The deadline timestamp that the permit is valid. + * @param _v The V parameter of ERC712 permit signature. + * @param _r The R parameter of ERC712 permit signature. + * @param _s The S parameter of ERC712 permit signature. + */ + function repayAfterCloseWithPermit( + uint256 _dAmt, + uint256 _withdrawBuffer, + uint256 _deadline, + uint8 _v, + bytes32 _r, + bytes32 _s + ) public payable onlyOwner { + // 1. Approve with permit + IERC20Permit(D_TOKEN).permit(msg.sender, address(this), _dAmt, _deadline, _v, _r, _s); + + // 2. Repay + repayAfterClose(_dAmt, _withdrawBuffer); + } } diff --git a/test/FeeCollector.t.sol b/test/FeeCollector.t.sol index e0980b4c..7b893d0c 100644 --- a/test/FeeCollector.t.sol +++ b/test/FeeCollector.t.sol @@ -500,7 +500,6 @@ contract FeeCollectorTest is Test, TokenUtils { vm.deal(_sender, _amount + gasMoney); // Pre-Act Data - uint256 preSenderBalance = _sender.balance; uint256 preContractBalance = feeCollectorAddr.balance; // Act @@ -508,12 +507,10 @@ contract FeeCollectorTest is Test, TokenUtils { (bool success,) = feeCollectorAddr.call{ value: _amount }(abi.encodeWithSignature("nonExistentFn()")); // Post-Act Data - uint256 postSenderBalance = _sender.balance; uint256 postContractBalance = feeCollectorAddr.balance; // Assertions assertTrue(success); - assertEq(postSenderBalance, preSenderBalance - _amount); assertEq(postContractBalance, preContractBalance + _amount); } } diff --git a/test/Position.t.sol b/test/Position.t.sol index f855ad19..f0dd2f2c 100644 --- a/test/Position.t.sol +++ b/test/Position.t.sol @@ -152,7 +152,7 @@ contract PositionTest is Test, TokenUtils, DebtUtils { // Pre-act balances uint256 cTokenPreBal = IERC20(cToken).balanceOf(owner); - uint256 bTokenPreBal = IERC20(cToken).balanceOf(addr); + uint256 bTokenPreBal = IERC20(bToken).balanceOf(addr); // Act vm.recordLogs(); @@ -529,3 +529,192 @@ contract PositionTest is Test, TokenUtils, DebtUtils { } } } + +contract PositionPermitTest is Test, TokenUtils, DebtUtils { + /* solhint-disable func-name-mixedcase */ + + struct TestPosition { + address addr; + address cToken; + address dToken; + address bToken; + } + + struct ContractBalances { + uint256 preBToken; + uint256 postBToken; + uint256 preVDToken; + uint256 postVDToken; + uint256 preAToken; + uint256 postAToken; + uint256 preDToken; + uint256 postDToken; + } + + struct OwnerBalances { + uint256 preBToken; + uint256 postBToken; + uint256 preCToken; + uint256 postCToken; + } + + // Test contracts + PositionFactory public positionFactory; + FeeCollector public feeCollector; + Assets public assets; + TestPosition[] public positions; + + // Test Storage + VmSafe.Wallet public wallet; + address public positionAddr; + uint256 public mainnetFork; + address public owner; + + // Events + event Short(uint256 cAmt, uint256 dAmt, uint256 bAmt); + + function setUp() public { + // Setup: use mainnet fork + mainnetFork = vm.createFork(vm.envString("RPC_URL")); + vm.selectFork(mainnetFork); + + // Deploy assets + assets = new Assets(); + address[4] memory supportedAssets = assets.getSupported(); + + // Deploy FeeCollector + vm.prank(CONTRACT_DEPLOYER); + feeCollector = new FeeCollector(CONTRACT_DEPLOYER); + + // Deploy PositionFactory + vm.prank(CONTRACT_DEPLOYER); + positionFactory = new PositionFactory(CONTRACT_DEPLOYER, address(feeCollector)); + + // Set contract owner + wallet = vm.createWallet(uint256(keccak256(abi.encodePacked(uint256(1))))); + owner = wallet.addr; + + // Deploy and store all possible positions + for (uint256 i; i < supportedAssets.length; i++) { + address cToken = supportedAssets[i]; + for (uint256 j; j < supportedAssets.length; j++) { + if (j != i) { + address dToken = supportedAssets[j]; + for (uint256 k; k < supportedAssets.length; k++) { + address bToken = supportedAssets[k]; + // Exclude positions with no pool + bool poolExists = !((dToken == USDC && bToken == DAI) || (dToken == DAI && bToken == USDC)); + if (k != j && poolExists) { + vm.prank(owner); + positionAddr = positionFactory.createPosition(cToken, dToken, bToken); + TestPosition memory newPosition = + TestPosition({ addr: positionAddr, cToken: cToken, dToken: dToken, bToken: bToken }); + positions.push(newPosition); + } + } + } + } + } + + // Mock AaveOracle + for (uint256 i; i < supportedAssets.length; i++) { + vm.mockCall( + AAVE_ORACLE, + abi.encodeWithSelector(IAaveOracle(AAVE_ORACLE).getAssetPrice.selector, supportedAssets[i]), + abi.encode(assets.prices(supportedAssets[i])) + ); + } + } + + /// @dev + // - Owner's cToken balance should decrease by collateral amount supplied. + // - Position's bToken balance should increase by amount receieved from swap. + // - The above should be true for a wide range of LTVs. + // - The above should be true for a wide range of collateral amounts. + // - The above should be true for all supported tokens. + // - The act should be accomplished without a separate approve tx. + function testFuzz_ShortWithPermit(uint256 _ltv, uint256 _cAmt) public { + ContractBalances memory contractBalances; + OwnerBalances memory ownerBalances; + + // Take snapshot + uint256 id = vm.snapshot(); + + for (uint256 i; i < positions.length; i++) { + // Test variables + address cToken = positions[i].cToken; + address bToken = positions[i].bToken; + + // Bound fuzzed variables + _ltv = bound(_ltv, 1, 60); + _cAmt = bound(_cAmt, assets.minCAmts(cToken), assets.maxCAmts(cToken)); + + // Fund owner with collateral + _fund(owner, cToken, _cAmt); + + // Get permit + uint256 permitTimestamp = block.timestamp + 1000; + (uint8 v, bytes32 r, bytes32 s) = _getPermit(cToken, wallet, positions[i].addr, _cAmt, permitTimestamp); + + // Pre-act balances + contractBalances.preBToken = IERC20(bToken).balanceOf(positions[i].addr); + ownerBalances.preCToken = IERC20(cToken).balanceOf(owner); + + // Act + vm.recordLogs(); + vm.prank(owner); + IPosition(positions[i].addr).shortWithPermit(_cAmt, _ltv, 0, 3000, TEST_CLIENT, permitTimestamp, v, r, s); + VmSafe.Log[] memory entries = vm.getRecordedLogs(); + + // Post-act balances + contractBalances.postBToken = IERC20(bToken).balanceOf(positions[i].addr); + ownerBalances.postCToken = IERC20(cToken).balanceOf(owner); + bytes memory shortEvent = entries[entries.length - 1].data; + uint256 bAmt; + + assembly { + let startPos := sub(mload(shortEvent), 32) + bAmt := mload(add(shortEvent, add(0x20, startPos))) + } + + // Assertions + assertEq(ownerBalances.postCToken, ownerBalances.preCToken - _cAmt); + assertEq(contractBalances.postBToken, contractBalances.preBToken + bAmt); + + // Revert to snapshot + vm.revertTo(id); + } + } + + /// @dev + // - It should revert with Unauthorized() error when called by an unauthorized sender. + function testFuzz_CannotShortWithPermit(address _sender) public { + // Take snapshot + uint256 id = vm.snapshot(); + + for (uint256 i; i < positions.length; i++) { + // Test variables + address cToken = positions[i].cToken; + uint256 cAmt = assets.maxCAmts(cToken); + uint256 ltv = 60; + + // Assumptions + vm.assume(_sender != owner); + + // Fund owner with collateral + _fund(owner, cToken, cAmt); + + // Get permit + uint256 permitTimestamp = block.timestamp + 1000; + (uint8 v, bytes32 r, bytes32 s) = _getPermit(cToken, wallet, positions[i].addr, cAmt, permitTimestamp); + + // Act + vm.prank(_sender); + vm.expectRevert(PositionAdmin.Unauthorized.selector); + IPosition(positions[i].addr).shortWithPermit(cAmt, ltv, 0, 3000, TEST_CLIENT, permitTimestamp, v, r, s); + + // Revert to snapshot + vm.revertTo(id); + } + } +} diff --git a/test/PositionAdmin.t.sol b/test/PositionAdmin.t.sol index 35f6c817..8d64d342 100644 --- a/test/PositionAdmin.t.sol +++ b/test/PositionAdmin.t.sol @@ -187,7 +187,6 @@ contract PositionAdminTest is Test, TokenUtils { vm.deal(_sender, _amount + gasMoney); // Pre-Act Data - uint256 preSenderBalance = _sender.balance; uint256 preContractBalance = positionAddr.balance; // Act @@ -195,12 +194,10 @@ contract PositionAdminTest is Test, TokenUtils { (bool success,) = positionAddr.call{ value: _amount }(abi.encodeWithSignature("nonExistentFn()")); // Post-Act Data - uint256 postSenderBalance = _sender.balance; uint256 postContractBalance = positionAddr.balance; // Assertions assertTrue(success); - assertEq(postSenderBalance, preSenderBalance - _amount); assertEq(postContractBalance, preContractBalance + _amount); } diff --git a/test/PositionFactory.t.sol b/test/PositionFactory.t.sol index 079dd048..e5fc99ea 100644 --- a/test/PositionFactory.t.sol +++ b/test/PositionFactory.t.sol @@ -308,7 +308,6 @@ contract PositionFactoryTest is Test, TokenUtils { vm.deal(_sender, _amount + gasMoney); // Pre-Act Data - uint256 preSenderBalance = _sender.balance; uint256 preContractBalance = address(positionFactory).balance; // Act @@ -316,12 +315,10 @@ contract PositionFactoryTest is Test, TokenUtils { (bool success,) = address(positionFactory).call{ value: _amount }(abi.encodeWithSignature("nonExistentFn()")); // Post-Act Data - uint256 postSenderBalance = _sender.balance; uint256 postContractBalance = address(positionFactory).balance; // Assertions assertTrue(success); - assertEq(postSenderBalance, preSenderBalance - _amount); assertEq(postContractBalance, preContractBalance + _amount); } } diff --git a/test/common/utils/TokenUtils.t.sol b/test/common/utils/TokenUtils.t.sol index bae23c16..5e7d967a 100644 --- a/test/common/utils/TokenUtils.t.sol +++ b/test/common/utils/TokenUtils.t.sol @@ -3,10 +3,12 @@ pragma solidity ^0.8.21; // External Imports import { Test } from "forge-std/Test.sol"; +import { VmSafe } from "forge-std/Vm.sol"; // Local Imports import { Assets, USDC, USDC_HOLDER } from "test/common/Constants.t.sol"; import { IERC20 } from "src/interfaces/token/IERC20.sol"; +import { IERC20Permit } from "src/interfaces/token/IERC20Permit.sol"; contract TokenUtils is Test { function _fund(address _account, address _token, uint256 _amount) internal { @@ -19,4 +21,38 @@ contract TokenUtils is Test { vm.stopPrank(); } } + + function _getPermit( + address _token, + VmSafe.Wallet memory _wallet, + address _spender, + uint256 _value, + uint256 _deadline + ) internal view returns (uint8 v, bytes32 r, bytes32 s) { + // 1. Get domain separator + bytes32 domainSeparator = IERC20Permit(_token).DOMAIN_SEPARATOR(); + + // 2. Get owner's nonce + uint256 nonce = IERC20Permit(_token).nonces(_wallet.addr); + + // 3. Construct permit hash + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + domainSeparator, + keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + _wallet.addr, + _spender, + _value, + nonce++, + _deadline + ) + ) + ) + ); + // 4. Sign permit hash + (v, r, s) = vm.sign(_wallet.privateKey, hash); + } } diff --git a/test/services/DebtService.t.sol b/test/services/DebtService.t.sol index 9ea31fa7..3156c470 100644 --- a/test/services/DebtService.t.sol +++ b/test/services/DebtService.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.21; // External Imports import { Test } from "forge-std/Test.sol"; +import { VmSafe } from "forge-std/Vm.sol"; // Local Imports import { PositionAdmin } from "src/PositionAdmin.sol"; @@ -432,3 +433,186 @@ contract DebtServiceTest is Test, DebtUtils, TokenUtils { } } } + +contract DebtServicePermitTest is Test, DebtUtils, TokenUtils { + /* solhint-disable func-name-mixedcase */ + + // Test Contracts + Assets public assets; + + // Test Storage + DebtServiceHarness[] public debtServices; + VmSafe.Wallet public wallet; + address[4] public supportedAssets; + uint256 public mainnetFork; + address public owner; + + function setUp() public { + // Setup: use mainnet fork + mainnetFork = vm.createFork(vm.envString("RPC_URL")); + vm.selectFork(mainnetFork); + + // Deploy Assets contract + assets = new Assets(); + supportedAssets = assets.getSupported(); + + // Set contract owner + wallet = vm.createWallet(uint256(keccak256(abi.encodePacked(uint256(1))))); + owner = wallet.addr; + + // Construct list of all possible debt services + for (uint256 i; i < supportedAssets.length; i++) { + for (uint256 j; j < supportedAssets.length; j++) { + if (i != j) { + // Create DebtService + address cToken = supportedAssets[i]; + address dToken = supportedAssets[j]; + DebtServiceHarness debtService = new DebtServiceHarness(owner, cToken, dToken); + debtServices.push(debtService); + + // Fund DebtService with collateral + _fund(address(debtService), cToken, assets.maxCAmts(cToken)); + } + } + } + } + + /// @dev + // - The contract's aToken balance should increase by amount of collateral supplied. + // - The owner's cToken balance should decrease by the amount of collateral supplied. + // - The act should be accomplished without a separate approve tx. + function testFuzz_AddCollateralWithPermit(uint256 _cAmt) public { + for (uint256 i; i < debtServices.length; i++) { + // Setup + address debtService = address(debtServices[i]); + address cToken = debtServices[i].C_TOKEN(); + debtServices[i].exposed_borrow(assets.maxCAmts(cToken), 50); + + // Assumptions + _cAmt = bound(_cAmt, assets.minCAmts(cToken), assets.maxCAmts(cToken)); + + // Fund owner and approve debtService + _fund(owner, cToken, _cAmt); + + // Get permit + uint256 permitTimestamp = block.timestamp + 1000; + (uint8 v, bytes32 r, bytes32 s) = _getPermit(cToken, wallet, debtService, _cAmt, permitTimestamp); + + // Pre-Act Assertions + uint256 preAtokenBalance = _getATokenBalance(debtService, cToken); + uint256 preOwnerCtokenBalance = IERC20(cToken).balanceOf(owner); + assertEq(preOwnerCtokenBalance, _cAmt); + + // Act + vm.prank(owner); + debtServices[i].addCollateralWithPermit(_cAmt, permitTimestamp, v, r, s); + + // Post-Act Assertions + uint256 postAtokenBalance = _getATokenBalance(debtService, cToken); + uint256 postOwnerCtokenBalance = IERC20(cToken).balanceOf(owner); + assertEq(postOwnerCtokenBalance, preOwnerCtokenBalance - _cAmt); + assertApproxEqAbs(postAtokenBalance, preAtokenBalance + _cAmt, 1); + } + } + + /// @dev + // - It should revert with Unauthorized() error when called by an unauthorized sender. + function testFuzz_CannotAddCollateralWithPermit(uint256 _cAmt, address _sender) public { + for (uint256 i; i < debtServices.length; i++) { + // Setup + address debtService = address(debtServices[i]); + address cToken = debtServices[i].C_TOKEN(); + debtServices[i].exposed_borrow(assets.maxCAmts(cToken), 50); + + // Assumptions + _cAmt = bound(_cAmt, assets.minCAmts(cToken), assets.maxCAmts(cToken)); + vm.assume(_sender != owner); + + // Fund owner and approve debtService + _fund(owner, cToken, _cAmt); + + // Get permit + uint256 permitTimestamp = block.timestamp + 1000; + (uint8 v, bytes32 r, bytes32 s) = _getPermit(cToken, wallet, debtService, _cAmt, permitTimestamp); + + // Act + vm.prank(_sender); + vm.expectRevert(PositionAdmin.Unauthorized.selector); + debtServices[i].addCollateralWithPermit(_cAmt, permitTimestamp, v, r, s); + } + } + + /// @dev + // - The contract's debt amount should decrease by amount repaid. + // - The owner's D_TOKEN balance should decrease by the amount repaid. + // - The act should be accomplished without a separate approve tx. + function testFuzz_RepayAfterCloseWithPermit(uint256 _payment) public { + // Setup + DebtServiceHarness[4] memory filteredDebtServices = _getFilteredDebtServicesByDToken(debtServices); + + for (uint256 i; i < filteredDebtServices.length; i++) { + // Borrow + address cToken = debtServices[i].C_TOKEN(); + address dToken = debtServices[i].D_TOKEN(); + uint256 dAmt = debtServices[i].exposed_borrow(assets.maxCAmts(cToken), 50); + + // Fund owner with dAmt of D_TOKEN + _fund(owner, dToken, dAmt); + + // Bound + _payment = bound(_payment, 1, dAmt); + + // Get permit + uint256 permitTimestamp = block.timestamp + 1000; + (uint8 v, bytes32 r, bytes32 s) = + _getPermit(dToken, wallet, address(debtServices[i]), _payment, permitTimestamp); + + // Pre-act data + uint256 preDebtAmt = debtServices[i].exposed_getDebtAmt(); + uint256 preOwnerDtokenBalance = IERC20(debtServices[i].D_TOKEN()).balanceOf(owner); + + // Act + vm.prank(owner); + debtServices[i].repayAfterCloseWithPermit(_payment, WITHDRAW_BUFFER, permitTimestamp, v, r, s); + + // Post-act data + uint256 postDebtAmt = debtServices[i].exposed_getDebtAmt(); + uint256 postOwnerDtokenBalance = IERC20(debtServices[i].D_TOKEN()).balanceOf(owner); + + // Assert + assertApproxEqAbs(postDebtAmt, preDebtAmt - _payment, 1); + assertEq(postOwnerDtokenBalance, preOwnerDtokenBalance - _payment); + } + } + + /// @dev + // - It should revert with Unauthorized() error when called by an unauthorized sender. + function testFuzz_CannotRepayAfterCloseWithPermit(uint256 _payment, address _sender) public { + // Setup + DebtServiceHarness[4] memory filteredDebtServices = _getFilteredDebtServicesByDToken(debtServices); + + for (uint256 i; i < filteredDebtServices.length; i++) { + // Borrow + address cToken = debtServices[i].C_TOKEN(); + address dToken = debtServices[i].D_TOKEN(); + uint256 dAmt = debtServices[i].exposed_borrow(assets.maxCAmts(cToken), 50); + + // Fund owner with dAmt of D_TOKEN + _fund(owner, dToken, dAmt); + + // Assumptions + _payment = bound(_payment, 1, dAmt); + vm.assume(_sender != owner); + + // Get permit + uint256 permitTimestamp = block.timestamp + 1000; + (uint8 v, bytes32 r, bytes32 s) = + _getPermit(dToken, wallet, address(debtServices[i]), _payment, permitTimestamp); + + // Act + vm.prank(_sender); + vm.expectRevert(PositionAdmin.Unauthorized.selector); + debtServices[i].repayAfterCloseWithPermit(_payment, WITHDRAW_BUFFER, permitTimestamp, v, r, s); + } + } +}