diff --git a/src/Interfaces/IERC20PermitExtended.sol b/src/Interfaces/IERC20PermitExtended.sol new file mode 100644 index 000000000..f427ac7ac --- /dev/null +++ b/src/Interfaces/IERC20PermitExtended.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import { IERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; + +interface IERC20PermitExtended is IERC20Permit { + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + bytes calldata signature + ) external; + + function transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external; + + function transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + bytes calldata signature + ) external; +} diff --git a/src/Periphery/Permit2Proxy.sol b/src/Periphery/Permit2Proxy.sol index ba6139ed1..9130d1098 100644 --- a/src/Periphery/Permit2Proxy.sol +++ b/src/Periphery/Permit2Proxy.sol @@ -5,7 +5,7 @@ import { ISignatureTransfer } from "permit2/interfaces/ISignatureTransfer.sol"; import { LibAsset, IERC20 } from "lifi/Libraries/LibAsset.sol"; import { LibUtil } from "lifi/Libraries/LibUtil.sol"; import { PermitHash } from "permit2/libraries/PermitHash.sol"; -import { ERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; +import { IERC20PermitExtended } from "lifi/Interfaces/IERC20PermitExtended.sol"; import { WithdrawablePeriphery } from "lifi/Helpers/WithdrawablePeriphery.sol"; /// @title Permit2Proxy @@ -61,6 +61,60 @@ contract Permit2Proxy is WithdrawablePeriphery { /// External Functions /// + /// @notice Allows to bridge tokens through a LI.FI diamond contract using + /// an EIP2612 gasless permit (only works with tokenAddresses that + /// implement EIP2612) + /// The permit signer must be the caller to prevent front-running and ensure + /// the calldata cannot be replaced by others. + /// Can only be called by the permit signer to prevent front-running. + /// @param tokenAddress Address of the token to be bridged + /// @param amount Amount of tokens to be bridged + /// @param deadline Transaction must be completed before this timestamp + /// @param signature User signature (packed bytes) + /// @param diamondCalldata calldata to execute + function callDiamondWithEIP2612Signature( + address tokenAddress, + uint256 amount, + uint256 deadline, + bytes calldata signature, + bytes calldata diamondCalldata + ) public payable returns (bytes memory) { + try + IERC20PermitExtended(tokenAddress).permit( + msg.sender, + address(this), + amount, + deadline, + signature + ) + {} catch Error(string memory reason) { + if ( + IERC20(tokenAddress).allowance(msg.sender, address(this)) < + amount + ) { + revert(reason); + } + } catch (bytes memory reason) { + if ( + IERC20(tokenAddress).allowance(msg.sender, address(this)) < + amount + ) { + LibUtil.revertWith(reason); + } + } + + LibAsset.transferFromERC20( + tokenAddress, + msg.sender, + address(this), + amount + ); + + LibAsset.maxApproveERC20(IERC20(tokenAddress), LIFI_DIAMOND, amount); + + return _executeCalldata(diamondCalldata); + } + /// @notice Allows to bridge tokens through a LI.FI diamond contract using /// an EIP2612 gasless permit (only works with tokenAddresses that /// implement EIP2612) @@ -83,10 +137,9 @@ contract Permit2Proxy is WithdrawablePeriphery { bytes32 s, bytes calldata diamondCalldata ) public payable returns (bytes memory) { - // call permit on token contract to register approval using signature try - ERC20Permit(tokenAddress).permit( - msg.sender, // Ensure msg.sender is same wallet that signed permit + IERC20PermitExtended(tokenAddress).permit( + msg.sender, address(this), amount, deadline, @@ -110,7 +163,6 @@ contract Permit2Proxy is WithdrawablePeriphery { } } - // deposit assets LibAsset.transferFromERC20( tokenAddress, msg.sender, @@ -118,10 +170,82 @@ contract Permit2Proxy is WithdrawablePeriphery { amount ); - // maxApprove token to diamond if current allowance is insufficient LibAsset.maxApproveERC20(IERC20(tokenAddress), LIFI_DIAMOND, amount); - // call our diamond to execute calldata + return _executeCalldata(diamondCalldata); + } + + /// @notice Allows to bridge tokens through a LI.FI diamond contract using + /// an EIP3009 transferWithAuthorization (only works with tokenAddresses that + /// implement EIP3009, such as USDC) + /// @param tokenAddress Address of the token to be bridged + /// @param amount Amount of tokens to be bridged + /// @param validAfter The time after which this is valid (unix time) + /// @param validBefore The time before which this is valid (unix time) + /// @param nonce Unique nonce for this authorization + /// @param signature User signature (packed bytes) + /// @param diamondCalldata calldata to execute + function callDiamondWithEIP3009Signature( + address tokenAddress, + uint256 amount, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + bytes calldata signature, + bytes calldata diamondCalldata + ) public payable returns (bytes memory) { + IERC20PermitExtended(tokenAddress).transferWithAuthorization( + msg.sender, + address(this), + amount, + validAfter, + validBefore, + nonce, + signature + ); + + LibAsset.maxApproveERC20(IERC20(tokenAddress), LIFI_DIAMOND, amount); + + return _executeCalldata(diamondCalldata); + } + + /// @notice Allows to bridge tokens through a LI.FI diamond contract using + /// an EIP3009 transferWithAuthorization (only works with tokenAddresses that + /// implement EIP3009, such as USDC) + /// @param tokenAddress Address of the token to be bridged + /// @param amount Amount of tokens to be bridged + /// @param validAfter The time after which this is valid (unix time) + /// @param validBefore The time before which this is valid (unix time) + /// @param nonce Unique nonce for this authorization + /// @param v User signature (recovery ID) + /// @param r User signature (ECDSA output) + /// @param s User signature (ECDSA output) + /// @param diamondCalldata calldata to execute + function callDiamondWithEIP3009Signature( + address tokenAddress, + uint256 amount, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s, + bytes calldata diamondCalldata + ) public payable returns (bytes memory) { + IERC20PermitExtended(tokenAddress).transferWithAuthorization( + msg.sender, + address(this), + amount, + validAfter, + validBefore, + nonce, + v, + r, + s + ); + + LibAsset.maxApproveERC20(IERC20(tokenAddress), LIFI_DIAMOND, amount); + return _executeCalldata(diamondCalldata); } diff --git a/test/solidity/Periphery/Permit2Proxy.t.sol b/test/solidity/Periphery/Permit2Proxy.t.sol index 34710c55d..0e9dd668b 100644 --- a/test/solidity/Periphery/Permit2Proxy.t.sol +++ b/test/solidity/Periphery/Permit2Proxy.t.sol @@ -110,6 +110,18 @@ contract Permit2ProxyTest is TestBase { bytes32 s; } + struct TestDataEIP3009 { + address tokenAddress; + address userWallet; + bytes32 nonce; + uint256 validAfter; + uint256 validBefore; + bytes diamondCalldata; + uint8 v; + bytes32 r; + bytes32 s; + } + /// Errors /// error InvalidSigner(); @@ -118,7 +130,7 @@ contract Permit2ProxyTest is TestBase { error CallToDiamondFailed(bytes); function setUp() public { - customBlockNumberForForking = 20261175; + customBlockNumberForForking = 23496255; initTestBase(); uniPermit2 = ISignatureTransfer(PERMIT2_ADDRESS); @@ -147,7 +159,7 @@ contract Permit2ProxyTest is TestBase { /// EIP2612 (native permit) related test cases /// - function test_can_execute_calldata_using_eip2612_signature_usdc() + function test_CanExecuteCalldataUsingEip2612SignatureUsdc() public assertBalanceChange( ADDRESS_USDC, @@ -187,7 +199,45 @@ contract Permit2ProxyTest is TestBase { return testdata; } - function testRevert_when_called_with_invalid_calldata() public { + function test_CanExecuteCalldataUsingEip2612SignatureBytes() + public + assertBalanceChange( + ADDRESS_USDC, + permit2User, + -int256(defaultUSDCAmount) + ) + { + vm.startPrank(permit2User); + + bytes32 domainSeparator = ERC20Permit(ADDRESS_USDC).DOMAIN_SEPARATOR(); + + TestDataEIP2612 + memory testdata = _getTestDataEIP2612SignedBypermit2User( + ADDRESS_USDC, + domainSeparator, + block.timestamp + 1000 + ); + + bytes memory signature = abi.encodePacked( + testdata.r, + testdata.s, + testdata.v + ); + + vm.expectEmit(true, true, true, true, DIAMOND_ADDRESS); + emit LiFiTransferStarted(bridgeData); + + permit2Proxy.callDiamondWithEIP2612Signature( + ADDRESS_USDC, + defaultUSDCAmount, + testdata.deadline, + signature, + testdata.diamondCalldata + ); + vm.stopPrank(); + } + + function testRevert_WhenCalledWithInvalidCalldata() public { vm.startPrank(permit2User); // get token-specific domainSeparator @@ -219,9 +269,9 @@ contract Permit2ProxyTest is TestBase { ); } - function testRevert_cannot_use_eip2612_signature_twice() public { + function testRevert_CannotUseEip2612SignatureTwice() public { TestDataEIP2612 - memory testdata = test_can_execute_calldata_using_eip2612_signature_usdc(); + memory testdata = test_CanExecuteCalldataUsingEip2612SignatureUsdc(); vm.startPrank(permit2User); @@ -240,7 +290,7 @@ contract Permit2ProxyTest is TestBase { vm.stopPrank(); } - function testRevert_cannot_use_expired_eip2612_signature() public { + function testRevert_CannotUseExpiredEip2612Signature() public { vm.startPrank(permit2User); // get token-specific domainSeparator @@ -271,7 +321,7 @@ contract Permit2ProxyTest is TestBase { vm.stopPrank(); } - function testRevert_cannot_use_invalid_eip2612_signature() public { + function testRevert_CannotUseInvalidEip2612Signature() public { vm.startPrank(permit2User); // get token-specific domainSeparator @@ -286,7 +336,7 @@ contract Permit2ProxyTest is TestBase { ); // expect call to revert since signature is invalid - vm.expectRevert("ECRecover: invalid signature 'v' value"); + vm.expectRevert("EIP2612: invalid signature"); // call Permit2Proxy with signature permit2Proxy.callDiamondWithEIP2612Signature( @@ -302,7 +352,7 @@ contract Permit2ProxyTest is TestBase { vm.stopPrank(); } - function testRevert_sign_and_call_using_different_addresses() public { + function testRevert_SignAndCallUsingDifferentAddresses() public { vm.startPrank(USER_SENDER); // get token-specific domainSeparator @@ -332,9 +382,91 @@ contract Permit2ProxyTest is TestBase { vm.stopPrank(); } + /// EIP3009 related test cases /// + + function test_CanExecuteCalldataUsingEip3009SignatureUsdc() + public + assertBalanceChange( + ADDRESS_USDC, + permit2User, + -int256(defaultUSDCAmount) + ) + returns (TestDataEIP3009 memory) + { + vm.startPrank(permit2User); + + bytes32 domainSeparator = ERC20Permit(ADDRESS_USDC).DOMAIN_SEPARATOR(); + + TestDataEIP3009 + memory testdata = _getTestDataEIP3009SignedBypermit2User( + ADDRESS_USDC, + domainSeparator, + 0, + block.timestamp + 1000 + ); + + vm.expectEmit(true, true, true, true, DIAMOND_ADDRESS); + emit LiFiTransferStarted(bridgeData); + + permit2Proxy.callDiamondWithEIP3009Signature( + ADDRESS_USDC, + defaultUSDCAmount, + testdata.validAfter, + testdata.validBefore, + testdata.nonce, + testdata.v, + testdata.r, + testdata.s, + testdata.diamondCalldata + ); + vm.stopPrank(); + return testdata; + } + + function test_CanExecuteCalldataUsingEip3009SignatureBytes() + public + assertBalanceChange( + ADDRESS_USDC, + permit2User, + -int256(defaultUSDCAmount) + ) + { + vm.startPrank(permit2User); + + bytes32 domainSeparator = ERC20Permit(ADDRESS_USDC).DOMAIN_SEPARATOR(); + + TestDataEIP3009 + memory testdata = _getTestDataEIP3009SignedBypermit2User( + ADDRESS_USDC, + domainSeparator, + 0, + block.timestamp + 1000 + ); + + bytes memory signature = abi.encodePacked( + testdata.r, + testdata.s, + testdata.v + ); + + vm.expectEmit(true, true, true, true, DIAMOND_ADDRESS); + emit LiFiTransferStarted(bridgeData); + + permit2Proxy.callDiamondWithEIP3009Signature( + ADDRESS_USDC, + defaultUSDCAmount, + testdata.validAfter, + testdata.validBefore, + testdata.nonce, + signature, + testdata.diamondCalldata + ); + vm.stopPrank(); + } + /// Permit2 specific tests /// - function test_user_can_call_diamond_with_own_permit2_signature() public { + function test_UserCanCallDiamondWithOwnPermit2Signature() public { bytes memory diamondCalldata; ISignatureTransfer.PermitTransferFrom memory permitTransferFrom; bytes memory signature; @@ -354,7 +486,7 @@ contract Permit2ProxyTest is TestBase { ); } - function testRevert_cannot_call_diamond_with_permit2_using_different_wallet_address() + function testRevert_CannotCallDiamondWithPermit2UsingDifferentWalletAddress() public { bytes memory diamondCalldata; @@ -377,7 +509,7 @@ contract Permit2ProxyTest is TestBase { ); } - function test_can_call_diamond_with_permit2_plus_witness() public { + function test_CanCallDiamondWithPermit2PlusWitness() public { bytes memory diamondCalldata; ISignatureTransfer.PermitTransferFrom memory permitTransferFrom; bytes memory signature; @@ -398,7 +530,7 @@ contract Permit2ProxyTest is TestBase { ); } - function test_can_generate_a_valid_msg_hash_for_signing() public { + function test_CanGenerateAValidMsgHashForSigning() public { bytes32 msgHash; bytes32 generatedMsgHash; ( @@ -419,7 +551,7 @@ contract Permit2ProxyTest is TestBase { assertEq(msgHash, generatedMsgHash); } - function testRevert_cannot_call_diamond_single_with_same_signature_more_than_once() + function testRevert_CannotCallDiamondSingleWithSameSignatureMoreThanOnce() public { deal(ADDRESS_USDC, permit2User, 10000 ether); @@ -449,7 +581,7 @@ contract Permit2ProxyTest is TestBase { ); } - function testRevert_cannot_set_different_calldata_than_intended() public { + function testRevert_CannotSetDifferentCalldataThanIntended() public { deal(ADDRESS_USDC, permit2User, 10000 ether); bytes memory diamondCalldata; ISignatureTransfer.PermitTransferFrom memory permitTransferFrom; @@ -473,9 +605,7 @@ contract Permit2ProxyTest is TestBase { ); } - function testRevert_cannot_use_permit2_signature_from_another_wallet() - public - { + function testRevert_CannotUsePermit2SignatureFromAnotherWallet() public { deal(ADDRESS_USDC, permit2User, 10000 ether); bytes memory diamondCalldata; ISignatureTransfer.PermitTransferFrom memory permitTransferFrom; @@ -500,7 +630,7 @@ contract Permit2ProxyTest is TestBase { ); } - function testRevert_cannot_transfer_more_tokens_than_intended() public { + function testRevert_CannotTransferMoreTokensThanIntended() public { deal(ADDRESS_USDC, permit2User, 10000 ether); bytes memory diamondCalldata; ISignatureTransfer.PermitTransferFrom memory permitTransferFrom; @@ -527,7 +657,7 @@ contract Permit2ProxyTest is TestBase { /// The following test code was adapted from https://github.com/flood-protocol/permit2-nonce-finder/blob/7a4ac8a58d0b499308000b75ddb2384834f31fac/test/Permit2NonceFinder.t.sol - function test_can_find_nonce() public { + function test_CanFindNonce() public { // We invalidate the first nonce to make sure it's not returned. // We pass a mask of 0...0011 to invalidate nonce 0 and 1. uniPermit2.invalidateUnorderedNonces(0, 3); @@ -539,7 +669,7 @@ contract Permit2ProxyTest is TestBase { assertEq(permit2Proxy.nextNonce(address(this)), 255); } - function test_can_find_nonce_after() public { + function test_CanFindNonceAfter() public { // We want to start from the second word uint256 start = 256; // We invalidate the whole next word to make sure it's not returned. @@ -555,7 +685,7 @@ contract Permit2ProxyTest is TestBase { assertEq(permit2Proxy.nextNonceAfter(address(this), 1), 2); } - function test_eip2612_flow_is_resistant_to_frontrun_attack() + function test_Eip2612FlowIsResistantToFrontrunAttack() public returns (TestDataEIP2612 memory) { @@ -1008,4 +1138,63 @@ contract Permit2ProxyTest is TestBase { ) ); } + + function _getTestDataEIP3009SignedBypermit2User( + address tokenAddress, + bytes32 domainSeparator, + uint256 validAfter, + uint256 validBefore + ) internal view returns (TestDataEIP3009 memory testdata) { + testdata.tokenAddress = tokenAddress; + testdata.userWallet = permit2User; + testdata.nonce = bytes32( + uint256(keccak256(abi.encodePacked(permit2User, block.timestamp))) + ); + testdata.validAfter = validAfter; + testdata.validBefore = validBefore; + + bytes32 digest = _generateEIP3009MsgHash( + testdata.userWallet, + address(permit2Proxy), + defaultUSDCAmount, + testdata.validAfter, + testdata.validBefore, + testdata.nonce, + domainSeparator + ); + + (testdata.v, testdata.r, testdata.s) = vm.sign(PRIVATE_KEY, digest); + + testdata.diamondCalldata = _getCalldataForBridging(); + } + + function _generateEIP3009MsgHash( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + bytes32 domainSeparator + ) internal pure returns (bytes32 digest) { + digest = keccak256( + abi.encodePacked( + "\x19\x01", + domainSeparator, + keccak256( + abi.encode( + keccak256( + "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" + ), + from, + to, + value, + validAfter, + validBefore, + nonce + ) + ) + ) + ); + } }