diff --git a/src/BaseTokenWrapper.sol b/src/BaseTokenWrapper.sol index 209a8c6..599fc1d 100644 --- a/src/BaseTokenWrapper.sol +++ b/src/BaseTokenWrapper.sol @@ -137,6 +137,35 @@ abstract contract BaseTokenWrapper is Ownable, IBaseTokenWrapper { _borrowToken(amount, msg.sender, referralCode); } + // @inheritdoc IBaseTokenWrapper + function repayToken( + uint256 amount, + address onBehalfOf + ) external virtual returns (uint256) { + return _repayToken(amount, onBehalfOf); + } + + // @inheritdoc IBaseTokenWrapper + function repayWithPermit( + uint256 amount, + address onBehalfOf, + PermitSignature calldata signature + ) external virtual returns (uint256) { + // explicitly left try-catch block blank to protect users from permit griefing + try + IERC20WithPermit(TOKEN_IN).permit( + msg.sender, + address(this), + amount, + signature.deadline, + signature.v, + signature.r, + signature.s + ) + {} catch {} + return _repayToken(amount, onBehalfOf); + } + /// @inheritdoc IBaseTokenWrapper function rescueTokens( IERC20 token, @@ -240,6 +269,23 @@ abstract contract BaseTokenWrapper is Ownable, IBaseTokenWrapper { IERC20(TOKEN_IN).transfer(onBehalfOf, amountIn); } + function _repayToken( + uint256 amount, + address onBehalfOf + ) internal returns (uint256) { + require(amount > 0, 'INSUFFICIENT_AMOUNT_TO_REPAY'); + + IERC20(TOKEN_IN).safeTransferFrom(msg.sender, address(this), amount); + uint256 amountWrapped = _wrapTokenIn(amount); + require(amountWrapped > 0, 'INSUFFICIENT_WRAPPED_TOKEN_RECEIVED'); + + SafeERC20.safeApprove(IERC20(TOKEN_OUT), address(POOL), amountWrapped); + POOL.repay(TOKEN_OUT, amountWrapped, 2, onBehalfOf); + + SafeERC20.safeApprove(IERC20(TOKEN_OUT), address(POOL), 0); + return amountWrapped; + } + /** * @notice Helper to wrap an amount of tokenIn, receiving tokenOut * @param amount The amount of tokenIn to wrap diff --git a/src/interfaces/IBaseTokenWrapper.sol b/src/interfaces/IBaseTokenWrapper.sol index 0d525ad..a8a9ade 100644 --- a/src/interfaces/IBaseTokenWrapper.sol +++ b/src/interfaces/IBaseTokenWrapper.sol @@ -80,6 +80,30 @@ interface IBaseTokenWrapper { PermitSignature calldata signature ) external; + /** + * @notice Repays token to the Pool, wraps it, and sends it to the pool for repayment + * @param amount The amount of token to repay + * @param onBehalfOf The address that will will repay the tokens + * @return The final amount repaied to the Pool, post-unwrapping + */ + function repayToken( + uint256 amount, + address onBehalfOf + ) external returns (uint256); + + /** + * @notice Repays token to the Pool, wraps it, and sends it to the pool for repayment + * @param amount The amount of token to repay + * @param onBehalfOf The address that will will repay the tokens + * @param signature The EIP-712 signature data used for permit + * @return The final amount repaied to the Pool, post-unwrapping + */ + function repayWithPermit( + uint256 amount, + address onBehalfOf, + PermitSignature calldata signature + ) external returns (uint256); + /** * @notice Provides way for the contract owner to rescue ERC-20 tokens * @param token The address of the token to withdraw from this contract diff --git a/test/BaseTokenWrapper.t.sol b/test/BaseTokenWrapper.t.sol index 94b1b3b..03431c4 100644 --- a/test/BaseTokenWrapper.t.sol +++ b/test/BaseTokenWrapper.t.sol @@ -878,11 +878,16 @@ abstract contract BaseTokenWrapperTest is Test { } } - function testFuzzBorrowToken(uint256 borrowAmount) public { + function testFuzzRepayToken( + uint256 borrowAmount, + uint256 repayAmount + ) public { borrowAmount = bound(borrowAmount, 1, MAX_DEAL_AMOUNT); borrowAmount *= 10 ** tokenInDecimals; uint256 collateralAmount = borrowAmount * 10; + repayAmount = bound(repayAmount, 1e6, borrowAmount); + address debtToken = IPool(pool) .getReserveData(tokenWrapper.TOKEN_OUT()) .variableDebtTokenAddress; @@ -905,7 +910,38 @@ abstract contract BaseTokenWrapperTest is Test { uint256 borrowedAmount = tokenWrapper.getTokenInForTokenOut(borrowAmount); assertEq( IERC20(tokenWrapper.TOKEN_IN()).balanceOf(address(alice)), - borrowedAmount + borrowedAmount, + 'Borrowed amount mismatch' + ); + + vm.warp(block.timestamp + 1 days); + + uint256 debtBefore = IERC20(debtToken).balanceOf(address(alice)); + deal(tokenWrapper.TOKEN_IN(), alice, repayAmount); + IERC20(tokenWrapper.TOKEN_IN()).approve( + address(tokenWrapper), + repayAmount + ); + + uint256 actualRepaidAmount = tokenWrapper.repayToken(repayAmount, alice); + + uint256 debtAfter = IERC20(debtToken).balanceOf(address(alice)); + assertLt(debtAfter, debtBefore, 'Debt should decrease after repayment'); + + uint256 expectedRepaidAmount = tokenWrapper.getTokenOutForTokenIn( + repayAmount + ); + assertApproxEqRel( + actualRepaidAmount, + expectedRepaidAmount, + 0.01e18, // 1% tolerance + 'Repaid amount should match expected amount' + ); + + assertLe( + IERC20(tokenWrapper.TOKEN_IN()).balanceOf(address(alice)), + borrowedAmount - repayAmount, + 'Token balance after repayment is incorrect' ); } else { vm.expectRevert(); @@ -914,6 +950,218 @@ abstract contract BaseTokenWrapperTest is Test { vm.stopPrank(); } + function testPartialRepayToken() public { + uint256 collateralAmount = 1000e18; + uint256 borrowAmount = 100e18; + uint256 partialRepayAmount = 60e18; // Repay 60% of the borrowed amount + deal(collateralAsset, ALICE, collateralAmount); + + address debtToken = IPool(pool) + .getReserveData(tokenWrapper.TOKEN_OUT()) + .variableDebtTokenAddress; + vm.startPrank(ALICE); + + ICreditDelegationToken(debtToken).approveDelegation( + address(tokenWrapper), + borrowAmount + ); + + IERC20(collateralAsset).approve(address(pool), collateralAmount); + IPool(pool).supply(collateralAsset, collateralAmount, ALICE, 0); + + if (borrowSupported) { + tokenWrapper.borrowToken(borrowAmount, 0); + uint256 borrowedAmount = tokenWrapper.getTokenInForTokenOut(borrowAmount); + assertEq( + IERC20(tokenWrapper.TOKEN_IN()).balanceOf(address(ALICE)), + borrowedAmount + ); + + vm.warp(block.timestamp + 1 days); + + uint256 underlyingBalanceBeforeRepayment = IERC20(tokenWrapper.TOKEN_IN()) + .balanceOf(address(ALICE)); + + uint256 debtBeforeRepayment = IERC20(debtToken).balanceOf(address(ALICE)); + + IERC20(tokenWrapper.TOKEN_IN()).approve( + address(tokenWrapper), + partialRepayAmount + ); + + uint256 amountRepaid = tokenWrapper.repayToken(partialRepayAmount, ALICE); + + uint256 underlyingBalanceAfterRepayment = IERC20(tokenWrapper.TOKEN_IN()) + .balanceOf(address(ALICE)); + uint256 debtAfterRepayment = IERC20(debtToken).balanceOf(address(ALICE)); + + assertApproxEqRel( + underlyingBalanceBeforeRepayment - underlyingBalanceAfterRepayment, + partialRepayAmount, + 0.001e18 // 0.1% tolerance + ); + + assertApproxEqRel( + debtBeforeRepayment - debtAfterRepayment, + amountRepaid, + 0.001e18 // 0.1% tolerance + ); + + assertTrue(debtAfterRepayment > 0, 'Debt should not be fully repaid'); + + assertApproxEqRel( + amountRepaid, + partialRepayAmount, + 0.001e18 // 0.1% tolerance + ); + } + vm.stopPrank(); + } + + function testRepayTokenWithPermit() public { + uint256 collateralAmount = 1000e18; + uint256 borrowAmount = 100e18; + deal(collateralAsset, ALICE, collateralAmount); + + address debtToken = IPool(pool) + .getReserveData(tokenWrapper.TOKEN_OUT()) + .variableDebtTokenAddress; + vm.startPrank(ALICE); + + ICreditDelegationToken(debtToken).approveDelegation( + address(tokenWrapper), + borrowAmount + ); + + IERC20(collateralAsset).approve(address(pool), collateralAmount); + IPool(pool).supply(collateralAsset, collateralAmount, ALICE, 0); + + if (borrowSupported) { + tokenWrapper.borrowToken(borrowAmount, 0); + uint256 borrowedAmount = tokenWrapper.getTokenInForTokenOut(borrowAmount); + assertEq( + IERC20(tokenWrapper.TOKEN_IN()).balanceOf(address(ALICE)), + borrowedAmount + ); + uint256 repayAmount = borrowedAmount; + + uint256 underlyingBalanceBeforeRepayment = IERC20(tokenWrapper.TOKEN_IN()) + .balanceOf(address(ALICE)); + + SigUtils.Permit memory permit = SigUtils.Permit({ + owner: address(ALICE), + spender: address(tokenWrapper), + value: repayAmount, + nonce: IERC2612(tokenWrapper.TOKEN_IN()).nonces(ALICE), + deadline: block.timestamp + 1 days + }); + + bytes32 digest = SigUtils.getTypedDataHash( + permit, + IERC2612(tokenWrapper.TOKEN_IN()).DOMAIN_SEPARATOR() + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ALICE_KEY, digest); + + IBaseTokenWrapper.PermitSignature memory signature = IBaseTokenWrapper + .PermitSignature({ + deadline: block.timestamp + 1 days, + v: v, + r: r, + s: s + }); + + tokenWrapper.repayWithPermit(repayAmount, ALICE, signature); + vm.stopPrank(); + assertEq( + IERC20(tokenWrapper.TOKEN_OUT()).balanceOf(ALICE), + underlyingBalanceBeforeRepayment - repayAmount + ); + } + } + + function testPartialRepayTokenWithPermit() public { + uint256 collateralAmount = 1000e18; + uint256 borrowAmount = 100e18; + uint256 partialRepayAmount = 60e18; // Repay 60% of the borrowed amount + deal(collateralAsset, ALICE, collateralAmount); + + address debtToken = IPool(pool) + .getReserveData(tokenWrapper.TOKEN_OUT()) + .variableDebtTokenAddress; + vm.startPrank(ALICE); + + ICreditDelegationToken(debtToken).approveDelegation( + address(tokenWrapper), + borrowAmount + ); + + IERC20(collateralAsset).approve(address(pool), collateralAmount); + IPool(pool).supply(collateralAsset, collateralAmount, ALICE, 0); + + if (borrowSupported) { + tokenWrapper.borrowToken(borrowAmount, 0); + uint256 borrowedAmount = tokenWrapper.getTokenInForTokenOut(borrowAmount); + assertEq( + IERC20(tokenWrapper.TOKEN_IN()).balanceOf(address(ALICE)), + borrowedAmount + ); + + vm.warp(block.timestamp + 1 days); + + uint256 underlyingBalanceBeforeRepayment = IERC20(tokenWrapper.TOKEN_IN()) + .balanceOf(address(ALICE)); + + uint256 debtBeforeRepayment = IERC20(debtToken).balanceOf(address(ALICE)); + + SigUtils.Permit memory permit = SigUtils.Permit({ + owner: address(ALICE), + spender: address(tokenWrapper), + value: partialRepayAmount, + nonce: IERC2612(tokenWrapper.TOKEN_IN()).nonces(ALICE), + deadline: block.timestamp + 1 days + }); + + bytes32 digest = SigUtils.getTypedDataHash( + permit, + IERC2612(tokenWrapper.TOKEN_IN()).DOMAIN_SEPARATOR() + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ALICE_KEY, digest); + + IBaseTokenWrapper.PermitSignature memory signature = IBaseTokenWrapper + .PermitSignature({ + deadline: block.timestamp + 1 days, + v: v, + r: r, + s: s + }); + + tokenWrapper.repayWithPermit(partialRepayAmount, ALICE, signature); + + uint256 underlyingBalanceAfterRepayment = IERC20(tokenWrapper.TOKEN_IN()) + .balanceOf(address(ALICE)); + uint256 debtAfterRepayment = IERC20(debtToken).balanceOf(address(ALICE)); + + assertApproxEqRel( + underlyingBalanceBeforeRepayment - underlyingBalanceAfterRepayment, + partialRepayAmount, + 0.001e18 // 0.1% tolerance + ); + + assertApproxEqRel( + debtBeforeRepayment - debtAfterRepayment, + partialRepayAmount, + 0.001e18 // 0.1% tolerance + ); + assertTrue(debtAfterRepayment > 0, 'Debt should not be fully repaid'); + } + vm.stopPrank(); + } + + function testRepayTokenZeroAmount() public { + vm.expectRevert('INSUFFICIENT_AMOUNT_TO_REPAY'); + tokenWrapper.repayToken(0, address(this)); + } + function _signCreditDelegation( uint256 privateKey, address delegatee,