diff --git a/lib/forge-std b/lib/forge-std index 2c7cbfc..37a37ab 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 2c7cbfc6fbede6d7c9e6b17afe997e3fdfe22fef +Subproject commit 37a37ab73364d6644bfe11edf88a07880f99bd56 diff --git a/lib/solmate b/lib/solmate index 10fc959..e0e9ff0 160000 --- a/lib/solmate +++ b/lib/solmate @@ -1 +1 @@ -Subproject commit 10fc959d987aab45f24e592d44449c723191ba2d +Subproject commit e0e9ff05d8aa5c7c48465511f85a6efdf5d5c30d diff --git a/patterns/permit2/Permit2Vault.sol b/patterns/permit2/Permit2Vault.sol index a3a2b28..46db0e9 100644 --- a/patterns/permit2/Permit2Vault.sol +++ b/patterns/permit2/Permit2Vault.sol @@ -21,8 +21,8 @@ contract Permit2Vault { _reentrancyGuard = false; } - // Deposit some amount of an ERC20 token into this contract - // using Permit2. + // Deposit some amount of an ERC20 token from the caller + // into this contract using Permit2. function depositERC20( IERC20 token, uint256 amount, @@ -34,7 +34,7 @@ contract Permit2Vault { tokenBalancesByUser[msg.sender][token] += amount; // Transfer tokens from the caller to ourselves. PERMIT2.permitTransferFrom( - // The permit message. + // The permit message. Spender will be inferred as the caller (us). IPermit2.PermitTransferFrom({ permitted: IPermit2.TokenPermissions({ token: token, @@ -58,6 +58,47 @@ contract Permit2Vault { ); } + // Deposit multiple ERC20 tokens from the caller + // into this contract using Permit2. + function depositBatchERC20( + IERC20[] calldata tokens, + uint256[] calldata amounts, + uint256 nonce, + uint256 deadline, + bytes calldata signature + ) external nonReentrant { + require(tokens.length == amounts.length, 'array mismatch'); + // The batch form of `permitTransferFrom()` takes an array of + // transfer details, which we will all direct to ourselves. + IPermit2.SignatureTransferDetails[] memory transferDetails = + new IPermit2.SignatureTransferDetails[](tokens.length); + // Credit the caller and populate the transferDetails. + for (uint256 i; i < tokens.length; ++i) { + tokenBalancesByUser[msg.sender][tokens[i]] += amounts[i]; + transferDetails[i] = IPermit2.SignatureTransferDetails({ + to: address(this), + requestedAmount: amounts[i] + }); + } + PERMIT2.permitTransferFrom( + // The permit message. Spender will be inferred as the caller (us). + IPermit2.PermitBatchTransferFrom({ + permitted: _toTokenPermissionsArray(tokens, amounts), + nonce: nonce, + deadline: deadline + }), + // The transfer recipients and amounts. + transferDetails, + // The owner of the tokens, which must also be + // the signer of the message, otherwise this call + // will fail. + msg.sender, + // The packed signature that was the result of signing + // the EIP712 hash of `permit`. + signature + ); + } + // Return ERC20 tokens deposited by the caller. function withdrawERC20(IERC20 token, uint256 amount) external nonReentrant { tokenBalancesByUser[msg.sender][token] -= amount; @@ -65,6 +106,15 @@ contract Permit2Vault { // execute thie transfer to support non-compliant tokens. token.transfer(msg.sender, amount); } + + function _toTokenPermissionsArray(IERC20[] calldata tokens, uint256[] calldata amounts) + private pure returns (IPermit2.TokenPermissions[] memory permissions) + { + permissions = new IPermit2.TokenPermissions[](tokens.length); + for (uint256 i; i < permissions.length; ++i) { + permissions[i] = IPermit2.TokenPermissions({ token: tokens[i], amount: amounts[i] }); + } + } } // Minimal Permit2 interface, derived from @@ -80,8 +130,18 @@ interface IPermit2 { // The permit2 message. struct PermitTransferFrom { - // Permitted token and amount. - TokenPermissions permitted; + // Permitted token and maximum amount. + TokenPermissions permitted;// deadline on the permit signature + // Unique identifier for this permit. + uint256 nonce; + // Expiration for this permit. + uint256 deadline; + } + + // The permit2 message for batch transfers. + struct PermitBatchTransferFrom { + // Permitted tokens and maximum amounts. + TokenPermissions[] permitted; // Unique identifier for this permit. uint256 nonce; // Expiration for this permit. @@ -103,6 +163,14 @@ interface IPermit2 { address owner, bytes calldata signature ) external; + + // Consume a batch permit2 message and transfer tokens. + function permitTransferFrom( + PermitBatchTransferFrom calldata permit, + SignatureTransferDetails[] calldata transferDetails, + address owner, + bytes calldata signature + ) external; } // Minimal ERC20 interface. diff --git a/patterns/permit2/README.md b/patterns/permit2/README.md index 477a78b..d77e06d 100644 --- a/patterns/permit2/README.md +++ b/patterns/permit2/README.md @@ -45,18 +45,20 @@ Finally, let's dive into the Permit2 approach, which echoes elements from both p 1. Alice calls `approve()` on an ERC20 to grant an infinite allowance to the canonical Permit2 contract. 2. Alice signs an off-chain "permit2" message that signals that the protocol contract is allowed to *transfer* tokens on her behalf. -3. Alice calls an interaction function on the protocol contract, passing in the signed permit2 message as a parameter. +3. The next step will vary depending on UX choices: + 1. In the simple case, Alice can just submit a transaction herself, including the signed permit2 message as part of an interaction with the protocol contract. + 2. If the protocol contract allows it, Alice can transmit her signed permit2 message to a relayer service that will submit interaction the transaction on Alice's behalf. 4. The protocol contract calls `permitTransferFrom()` on the Permit2 contract, which in turn uses its allowance (granted in 1.) to call `transferFrom()` on the ERC20 contract, moving the tokens held by Alice. -It might seem like a regression to require the user to grant an explicit allowance first. But rather than granting it to the protocol directly, the user will instead grant it to the canonical Permit2 contract. This means that if the user has already done this before, say to interact with another protocol that integrated Permit2, every other protocol can skip that step! 🎉 +It might seem like a regression to require the user to grant an explicit allowance first. But rather than granting it to the protocol directly, the user will instead grant it to the canonical Permit2 contract. This means that if the user has already done this before, say to interact with another protocol that integrated Permit2, *every other protocol can skip that step*. Instead of directly calling `transferFrom()` on the ERC20 token to perform a transfer, a protocol will call `permitTransferFrom()` on the canonical Permit2 contract. Permit2 sits between the protocol and the ERC20 token, tracking and validating permit2 messages, then ultimately using its allowance to perform the `transferFrom()` call directly on the ERC20. This indirection is what allows Permit2 to extend EIP-2612-like benefits to every existing ERC20 token! 🎉 -Also, like EIP-2612 permit messages, permit2 messages expire to limit the the attack window of an exploit. +Also, like EIP-2612 permit messages, permit2 messages expire to limit the the attack window of an exploit. It's much also easier to secure the small Permit2 contract than the contracts of individual defi protocols, so having an infinite allowance there is less of a concern. ## Integrating Permit2 -For a frontend integrating Permit2, it will need to collect a user signature that will be passed into the transaction. The Permit2 message struct (`PermitTransferFrom`) signed by these signatures must comply with the [EIP-712](https://eips.ethereum.org/EIPS/eip-712) standard (for which [we have a general guide](../eip712-signed-messages/)), using the Permit2 domain and type hashes defined [here](https://github.com/Uniswap/permit2/blob/main/src/EIP712.sol) and [here](https://github.com/Uniswap/permit2/blob/main/src/libraries/PermitHash.sol). Be aware that the `spender` field for the EIP-712 Permit2 object needs to be set to the contract address that will be consuming it. +For a frontend integrating Permit2, it will need to collect a user signature that will be passed into the transaction. The Permit2 message struct (`PermitTransferFrom`) signed by these signatures must comply with the [EIP-712](https://eips.ethereum.org/EIPS/eip-712) standard (for which [we have a general guide](../eip712-signed-messages/)), using the Permit2 domain and type hashes defined [here](https://github.com/Uniswap/permit2/blob/main/src/EIP712.sol) and [here](https://github.com/Uniswap/permit2/blob/main/src/libraries/PermitHash.sol). Be aware that the `spender` field for the EIP-712 Permit2 object needs to be set to the contract address that will be consuming it and directly calling the Permit2 contract functions. The smart contract integration is actually fairly easy! Any function that needs to move tokens held by a user just needs to accept any unknown permit message details and the corresponding EIP-712 user signature. To actually move the tokens, we will call `permitTransferFrom()` on the canonical Permit2 contract. That function is declared as: @@ -76,23 +78,40 @@ The parameters for this function are: - `amount` - *Maximum* amount that can be transferred when consuming this permit. - `nonce` - A unique number, chosen by our app, to identify this permit. Once a permit is consumed, any other permit using that nonce will be invalid. - `deadline` - The latest possible block timestamp for when this permit is valid. -- `transferDetails` - A struct containing the transfer recipient and transfer amount, which can be less than the amount the user signed for. +- `transferDetails` - A struct detailing where permitted token should be transfered to and how much. + - `to` - Who receives the permitted token. + - `requestedAmount`: How much should be transferred. This can be less than the amount that the user signed in `permit.permitted`. - `owner` - Who signed the permit and also holds the tokens. Often, in simple use-cases where the caller and the user are one and the same, this should be set to the caller (`msg.sender`). But in more exotic integrations, [you may need more sophisticated checks](https://docs.uniswap.org/contracts/permit2/reference/signature-transfer#security-considerations). - `signature` - The corresponding EIP-712 signature for the permit2 message, signed by `owner`. If the recovered address from signature verification does not match `owner`, the call will fail. > 🛈 Note that the `PermitTransferFrom` struct does not include the `spender` field found in the [EIP-712 typehash definition for the permit message](https://github.com/Uniswap/permit2/blob/main/src/libraries/PermitHash.sol#L21). It will be populated with our contract's address (the direct caller of `permitTransferFrom()`) during processing. This is why the `spender` field of the EIP-712 object the user signs must be the address of this contract. + +### Batch Transfers + +If you need to transfer multiple tokens, the good news is Permit2 also supports batch transfers via an overloaded implementation of `permitTransferFrom()`: + +```solidity +function permitTransferFrom( + PermitBatchTransferFrom memory permit, + SignatureTransferDetails[] calldata transferDetails, + address owner, + bytes calldata signature + ) external; +``` + + Instead of having the user sign a `PermitTransferFrom` message struct, you'll need the user to sign a `PermitBatchTransferFrom` message struct, inside which the `permitted` field is now an *array* of `TokenPermissions` structs instead of a single item. This version of `permitTransferFrom()` also accepts an array of `SignatureTransferDetails`, meaning each token permission can be directed towards different recipients. + ### Advanced Integrations This guide covers the basic functionality offered by Permit2 but there's more you can do with it! -- [Custom Witness Data](https://docs.uniswap.org/contracts/permit2/reference/signature-transfer#single-permitwitnesstransferfrom) - You can append custom data to the permit2 message, which means the Permit2 signature validation will extend to that data as well. -- [Batch Transfers](https://docs.uniswap.org/contracts/permit2/reference/signature-transfer#batched-permittransferfrom) - A batched permit2 message for performing multiple transfers, secured by a single signature. +- [Custom Witness Data](https://docs.uniswap.org/contracts/permit2/reference/signature-transfer#single-permitwitnesstransferfrom) - You can append custom data to the permit2 message, which means the Permit2 signature validation will extend to that data as well. Extremely useful to add validation to the rest of the interaction when employing the relayer approach. - [Smart Nonces](https://docs.uniswap.org/contracts/permit2/reference/signature-transfer#nonce-schema) - Under the hood, nonces are actually written as bit fields in an storage slot indexed by the upper 248 bits. You can save a signficant amount of gas by carefully choosing nonce values that reuse storage slots. - [Callback signatures](https://github.com/Uniswap/permit2/blob/main/src/libraries/SignatureVerification.sol#L43) - Permit2 supports [EIP-1271](https://eips.ethereum.org/EIPS/eip-1271) callback signatures, which allow smart contracts to also sign permit2 messages. - [Permit2 Allowances](https://docs.uniswap.org/contracts/permit2/reference/allowance-transfer) - For protocols that need more flexibility, Permit2 supports a more conventional allowance model that gets the added benefit of expiration times. ## The Demo -The provided [example code](./Permit2Vault.sol) is a simple vault that users can deposit ERC20 tokens into using Permit2, which they can later withdraw. Because it's multi-user, it needs to initiate the transfer in order to reliably credit which account owns which balance. Normally this requires granting an allowance to the vault contract and then having the vault perform the `transferFrom()` on the token itself, but Permit2 allows us to skip that hassle! +The provided [example code](./Permit2Vault.sol) is a simple vault that users can deposit ERC20 tokens into using Permit2, which they can later withdraw. Because it's multi-user, it needs to initiate the transfer in order to reliably credit which account owns which balance. Normally this requires granting an allowance to the vault contract and then having the vault perform the `transferFrom()` on the token itself, but Permit2 allows us to skip that hassle! The demo provides examples for both single and batch transfers. The [tests](../../test/Permit2Vault.t.sol) deploy a local, bytecode fork of the mainnet Permit2 contract to test an instance of the vault against. The EIP-712 hashing and signature generation is written in solidity/foundry as well, but should normally be performed off-chain at the frontend/backend level in your language of choice. diff --git a/patterns/permit2/permit2-permitTransferFrom.png b/patterns/permit2/permit2-permitTransferFrom.png index 076b74f..6a76154 100644 Binary files a/patterns/permit2/permit2-permitTransferFrom.png and b/patterns/permit2/permit2-permitTransferFrom.png differ diff --git a/test/Permit2Vault.t.sol b/test/Permit2Vault.t.sol index 78dc44b..cbcdfe2 100644 --- a/test/Permit2Vault.t.sol +++ b/test/Permit2Vault.t.sol @@ -11,9 +11,13 @@ contract Permit2VaultTest is TestUtils { bytes32 constant PERMIT_TRANSFER_FROM_TYPEHASH = keccak256( "PermitTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline)TokenPermissions(address token,uint256 amount)" ); + bytes32 constant PERMIT_BATCH_TRANSFER_FROM_TYPEHASH = keccak256( + "PermitBatchTransferFrom(TokenPermissions[] permitted,address spender,uint256 nonce,uint256 deadline)TokenPermissions(address token,uint256 amount)" + ); Permit2Clone permit2 = new Permit2Clone(); - TestERC20 token = new TestERC20(); + TestERC20 token1 = new TestERC20(); + TestERC20 token2 = new TestERC20(); ReenteringERC20 badToken = new ReenteringERC20(); Permit2Vault vault; uint256 ownerKey; @@ -24,16 +28,19 @@ contract Permit2VaultTest is TestUtils { vault = new Permit2Vault(permit2); ownerKey = _randomUint256(); owner = vm.addr(ownerKey); + // Set up unlimited token approvals from the user onto the permit2 contract. + vm.prank(owner); + token1.approve(address(permit2), type(uint256).max); vm.prank(owner); - token.approve(address(permit2), type(uint256).max); + token2.approve(address(permit2), type(uint256).max); } function test_canDeposit() external { uint256 amount = _randomUint256() % 1e18 + 1; - token.mint(owner, amount); + token1.mint(owner, amount); IPermit2.PermitTransferFrom memory permit = IPermit2.PermitTransferFrom({ permitted: IPermit2.TokenPermissions({ - token: IERC20(address(token)), + token: IERC20(address(token1)), amount: amount }), nonce: _randomUint256(), @@ -42,23 +49,23 @@ contract Permit2VaultTest is TestUtils { bytes memory sig = _signPermit(permit, address(vault), ownerKey); vm.prank(owner); vault.depositERC20( - IERC20(address(token)), + IERC20(address(token1)), amount, permit.nonce, permit.deadline, sig ); - assertEq(vault.tokenBalancesByUser(owner, IERC20(address(token))), amount); - assertEq(token.balanceOf(address(vault)), amount); - assertEq(token.balanceOf(owner), 0); + assertEq(vault.tokenBalancesByUser(owner, IERC20(address(token1))), amount); + assertEq(token1.balanceOf(address(vault)), amount); + assertEq(token1.balanceOf(owner), 0); } function test_cannotReusePermit() external { uint256 amount = _randomUint256() % 1e18 + 1; - token.mint(owner, amount); + token1.mint(owner, amount); IPermit2.PermitTransferFrom memory permit = IPermit2.PermitTransferFrom({ permitted: IPermit2.TokenPermissions({ - token: IERC20(address(token)), + token: IERC20(address(token1)), amount: amount }), nonce: _randomUint256(), @@ -67,7 +74,7 @@ contract Permit2VaultTest is TestUtils { bytes memory sig = _signPermit(permit, address(vault), ownerKey); vm.prank(owner); vault.depositERC20( - IERC20(address(token)), + IERC20(address(token1)), amount, permit.nonce, permit.deadline, @@ -76,7 +83,7 @@ contract Permit2VaultTest is TestUtils { vm.expectRevert(abi.encodeWithSelector(Permit2Clone.InvalidNonce.selector)); vm.prank(owner); vault.depositERC20( - IERC20(address(token)), + IERC20(address(token1)), amount, permit.nonce, permit.deadline, @@ -86,10 +93,10 @@ contract Permit2VaultTest is TestUtils { function test_cannotUseOthersPermit() external { uint256 amount = _randomUint256() % 1e18 + 1; - token.mint(owner, amount); + token1.mint(owner, amount); IPermit2.PermitTransferFrom memory permit = IPermit2.PermitTransferFrom({ permitted: IPermit2.TokenPermissions({ - token: IERC20(address(token)), + token: IERC20(address(token1)), amount: amount }), nonce: _randomUint256(), @@ -99,7 +106,7 @@ contract Permit2VaultTest is TestUtils { vm.expectRevert(abi.encodeWithSelector(Permit2Clone.InvalidSigner.selector)); vm.prank(_randomAddress()); vault.depositERC20( - IERC20(address(token)), + IERC20(address(token1)), amount, permit.nonce, permit.deadline, @@ -108,11 +115,10 @@ contract Permit2VaultTest is TestUtils { } function test_cannotUseOtherTokenPermit() external { - TestERC20 token2 = new TestERC20(); vm.prank(owner); token2.approve(address(permit2), type(uint256).max); uint256 amount = _randomUint256() % 1e18 + 1; - token.mint(owner, amount); + token1.mint(owner, amount); token2.mint(owner, amount); IPermit2.PermitTransferFrom memory permit = IPermit2.PermitTransferFrom({ permitted: IPermit2.TokenPermissions({ @@ -126,7 +132,7 @@ contract Permit2VaultTest is TestUtils { vm.prank(owner); vm.expectRevert(Permit2Clone.InvalidSigner.selector); vault.depositERC20( - IERC20(address(token)), + IERC20(address(token1)), amount, permit.nonce, permit.deadline, @@ -136,10 +142,10 @@ contract Permit2VaultTest is TestUtils { function test_canWithdraw() external { uint256 amount = _randomUint256() % 1e18 + 2; - token.mint(owner, amount); + token1.mint(owner, amount); IPermit2.PermitTransferFrom memory permit = IPermit2.PermitTransferFrom({ permitted: IPermit2.TokenPermissions({ - token: IERC20(address(token)), + token: IERC20(address(token1)), amount: amount }), nonce: _randomUint256(), @@ -148,24 +154,24 @@ contract Permit2VaultTest is TestUtils { bytes memory sig = _signPermit(permit, address(vault), ownerKey); vm.prank(owner); vault.depositERC20( - IERC20(address(token)), + IERC20(address(token1)), amount, permit.nonce, permit.deadline, sig ); vm.prank(owner); - vault.withdrawERC20(IERC20(address(token)), amount - 1); - assertEq(token.balanceOf(owner), amount - 1); - assertEq(token.balanceOf(address(vault)), 1); + vault.withdrawERC20(IERC20(address(token1)), amount - 1); + assertEq(token1.balanceOf(owner), amount - 1); + assertEq(token1.balanceOf(address(vault)), 1); } function test_cannotWithdrawOthers() external { uint256 amount = _randomUint256() % 1e18 + 1; - token.mint(owner, amount); + token1.mint(owner, amount); IPermit2.PermitTransferFrom memory permit = IPermit2.PermitTransferFrom({ permitted: IPermit2.TokenPermissions({ - token: IERC20(address(token)), + token: IERC20(address(token1)), amount: amount }), nonce: _randomUint256(), @@ -174,7 +180,7 @@ contract Permit2VaultTest is TestUtils { bytes memory sig = _signPermit(permit, address(vault), ownerKey); vm.prank(owner); vault.depositERC20( - IERC20(address(token)), + IERC20(address(token1)), amount, permit.nonce, permit.deadline, @@ -182,7 +188,7 @@ contract Permit2VaultTest is TestUtils { ); vm.expectRevert(); vm.prank(_randomAddress()); - vault.withdrawERC20(IERC20(address(token)), amount); + vault.withdrawERC20(IERC20(address(token1)), amount); } function test_cannotReenter() external { @@ -212,6 +218,49 @@ contract Permit2VaultTest is TestUtils { ); } + function test_canBatchDeposit() external { + uint256 amount1 = _randomUint256() % 1e18 + 1; + uint256 amount2 = _randomUint256() % 1e18 + 1; + token1.mint(owner, amount1); + token2.mint(owner, amount2); + IPermit2.TokenPermissions[] memory permitted = new IPermit2.TokenPermissions[](2); + permitted[0] = IPermit2.TokenPermissions({ + token: IERC20(address(token1)), + amount: amount1 + }); + permitted[1] = IPermit2.TokenPermissions({ + token: IERC20(address(token2)), + amount: amount2 + }); + IPermit2.PermitBatchTransferFrom memory permit = IPermit2.PermitBatchTransferFrom({ + permitted: permitted, + nonce: _randomUint256(), + deadline: block.timestamp + }); + bytes memory sig = _signPermit(permit, address(vault), ownerKey); + vm.prank(owner); + { + IERC20[] memory tokens = new IERC20[](permitted.length); + uint256[] memory amounts = new uint256[](permitted.length); + for (uint256 i; i < permitted.length; ++i) { + (tokens[i], amounts[i]) = (permitted[i].token, permitted[i].amount); + } + vault.depositBatchERC20( + tokens, + amounts, + permit.nonce, + permit.deadline, + sig + ); + } + assertEq(vault.tokenBalancesByUser(owner, IERC20(address(token1))), amount1); + assertEq(vault.tokenBalancesByUser(owner, IERC20(address(token2))), amount2); + assertEq(token1.balanceOf(address(vault)), amount1); + assertEq(token2.balanceOf(address(vault)), amount2); + assertEq(token1.balanceOf(owner), 0); + assertEq(token2.balanceOf(owner), 0); + } + // Generate a signature for a permit message. function _signPermit( IPermit2.PermitTransferFrom memory permit, @@ -219,6 +268,22 @@ contract Permit2VaultTest is TestUtils { uint256 signerKey ) internal + view + returns (bytes memory sig) + { + (uint8 v, bytes32 r, bytes32 s) = + vm.sign(signerKey, _getEIP712Hash(permit, spender)); + return abi.encodePacked(r, s, v); + } + + // Generate a signature for a batch permit message. + function _signPermit( + IPermit2.PermitBatchTransferFrom memory permit, + address spender, + uint256 signerKey + ) + internal + view returns (bytes memory sig) { (uint8 v, bytes32 r, bytes32 s) = @@ -249,6 +314,39 @@ contract Permit2VaultTest is TestUtils { )) )); } + + // Compute the EIP712 hash of the batch permit object. + // Normally this would be implemented off-chain. + function _getEIP712Hash(IPermit2.PermitBatchTransferFrom memory permit, address spender) + internal + view + returns (bytes32 h) + { + bytes32 permittedHash; + { + uint256 n = permit.permitted.length; + bytes32[] memory contentHashes = new bytes32[](n); + for (uint256 i; i < n; ++i) { + contentHashes[i] = keccak256(abi.encode( + TOKEN_PERMISSIONS_TYPEHASH, + permit.permitted[i].token, + permit.permitted[i].amount + )); + } + permittedHash = keccak256(abi.encodePacked(contentHashes)); + } + return keccak256(abi.encodePacked( + "\x19\x01", + permit2.DOMAIN_SEPARATOR(), + keccak256(abi.encode( + PERMIT_BATCH_TRANSFER_FROM_TYPEHASH, + permittedHash, + spender, + permit.nonce, + permit.deadline + )) + )); + } } contract TestERC20 is ERC20 { @@ -300,4 +398,11 @@ contract Permit2Clone is IPermit2 { address owner, bytes calldata signature ) external {} + + function permitTransferFrom( + PermitBatchTransferFrom calldata permit, + SignatureTransferDetails[] calldata transferDetails, + address owner, + bytes calldata signature + ) external {} } \ No newline at end of file