diff --git a/src/pool-cl/base/ERC721Permit.sol b/src/pool-cl/base/ERC721Permit.sol index b197bcf..ea12e67 100644 --- a/src/pool-cl/base/ERC721Permit.sol +++ b/src/pool-cl/base/ERC721Permit.sol @@ -3,10 +3,8 @@ pragma solidity ^0.8.19; import {ERC721Enumerable, ERC721} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; -import {Address} from "@openzeppelin/contracts/utils/Address.sol"; -import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; - import {IERC721Permit} from "../interfaces/IERC721Permit.sol"; +import {ERC721PermitLib} from "../libraries/ERC721PermitLib.sol"; /// @title ERC721 with permit /// @notice Nonfungible tokens that support an approve via signature, i.e. permit @@ -28,22 +26,14 @@ abstract contract ERC721Permit is ERC721Enumerable, IERC721Permit { /// @inheritdoc IERC721Permit function DOMAIN_SEPARATOR() public view override returns (bytes32) { - return keccak256( - abi.encode( - /// @dev keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)') - 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f, - nameHash, - versionHash, - block.chainid, - address(this) - ) - ); + return ERC721PermitLib.DOMAIN_SEPARATOR(nameHash, versionHash); } /// @inheritdoc IERC721Permit /// @dev Value is equal to keccak256("Permit(address spender,uint256 tokenId,uint256 nonce,uint256 deadline)"); - bytes32 public constant override PERMIT_TYPEHASH = - 0x49ecf333e5b8c95c40fdafc95c1ad136e8914a8fb55e9dc8bb01eaa83a2df9ad; + function PERMIT_TYPEHASH() external pure override returns (bytes32) { + return ERC721PermitLib.PERMIT_TYPEHASH; + } /// @inheritdoc IERC721Permit function permit(address spender, uint256 tokenId, uint256 deadline, uint8 v, bytes32 r, bytes32 s) @@ -51,36 +41,9 @@ abstract contract ERC721Permit is ERC721Enumerable, IERC721Permit { payable override { - if (block.timestamp > deadline) { - revert PermitExpired(); - } - - bytes32 digest = keccak256( - abi.encodePacked( - "\x19\x01", - DOMAIN_SEPARATOR(), - keccak256(abi.encode(PERMIT_TYPEHASH, spender, tokenId, _getAndIncrementNonce(tokenId), deadline)) - ) + ERC721PermitLib.permitCheck( + spender, tokenId, deadline, v, r, s, ownerOf(tokenId), DOMAIN_SEPARATOR(), _getAndIncrementNonce(tokenId) ); - address owner = ownerOf(tokenId); - if (spender == owner) { - revert ApproveToOneself(); - } - - if (Address.isContract(owner)) { - /// @dev cast 4 isValidSignature(bytes32,bytes) == 0x1626ba7e - if (IERC1271(owner).isValidSignature(digest, abi.encodePacked(r, s, v)) == 0x1626ba7e) { - revert Unauthorized(); - } - } else { - address recoveredAddress = ecrecover(digest, v, r, s); - if (recoveredAddress == address(0)) { - revert InvalidSignature(); - } - if (recoveredAddress != owner) { - revert Unauthorized(); - } - } _approve(spender, tokenId); } diff --git a/src/pool-cl/interfaces/IERC721Permit.sol b/src/pool-cl/interfaces/IERC721Permit.sol index fb5b6eb..02422c3 100644 --- a/src/pool-cl/interfaces/IERC721Permit.sol +++ b/src/pool-cl/interfaces/IERC721Permit.sol @@ -7,11 +7,6 @@ import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; /// @title ERC721 with permit /// @notice Extension to ERC721 that includes a permit function for signature based approvals interface IERC721Permit is IERC721 { - error PermitExpired(); - error ApproveToOneself(); - error Unauthorized(); - error InvalidSignature(); - /// @notice The permit typehash used in the permit signature /// @return The typehash for the permit function PERMIT_TYPEHASH() external pure returns (bytes32); diff --git a/src/pool-cl/libraries/ERC721PermitLib.sol b/src/pool-cl/libraries/ERC721PermitLib.sol new file mode 100644 index 0000000..158a4f3 --- /dev/null +++ b/src/pool-cl/libraries/ERC721PermitLib.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2024 PancakeSwap +pragma solidity ^0.8.19; + +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; + +library ERC721PermitLib { + error PermitExpired(); + error ApproveToOneself(); + error Unauthorized(); + error InvalidSignature(); + + /// @dev Value is equal to keccak256("Permit(address spender,uint256 tokenId,uint256 nonce,uint256 deadline)"); + bytes32 public constant PERMIT_TYPEHASH = 0x49ecf333e5b8c95c40fdafc95c1ad136e8914a8fb55e9dc8bb01eaa83a2df9ad; + + function DOMAIN_SEPARATOR(bytes32 nameHash, bytes32 versionHash) external view returns (bytes32) { + return keccak256( + abi.encode( + /// @dev keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)') + 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f, + nameHash, + versionHash, + block.chainid, + address(this) + ) + ); + } + + function permitCheck( + address spender, + uint256 tokenId, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s, + address owner, + bytes32 domainSeparatorHash, + uint256 nonce + ) external view { + if (block.timestamp > deadline) { + revert PermitExpired(); + } + + bytes32 digest = keccak256( + abi.encodePacked( + "\x19\x01", + domainSeparatorHash, + keccak256(abi.encode(PERMIT_TYPEHASH, spender, tokenId, nonce, deadline)) + ) + ); + if (spender == owner) { + revert ApproveToOneself(); + } + + if (Address.isContract(owner)) { + /// @dev cast 4 isValidSignature(bytes32,bytes) == 0x1626ba7e + if (IERC1271(owner).isValidSignature(digest, abi.encodePacked(r, s, v)) == 0x1626ba7e) { + revert Unauthorized(); + } + } else { + address recoveredAddress = ecrecover(digest, v, r, s); + if (recoveredAddress == address(0)) { + revert InvalidSignature(); + } + if (recoveredAddress != owner) { + revert Unauthorized(); + } + } + } +} diff --git a/test/helpers/ERC721SigUtils.sol b/test/helpers/ERC721SigUtils.sol new file mode 100644 index 0000000..5e6d338 --- /dev/null +++ b/test/helpers/ERC721SigUtils.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +contract ERC721SigUtils { + /// @notice The ERC721 token domain separator + bytes32 internal immutable DOMAIN_SEPARATOR; + + constructor(bytes32 _DOMAIN_SEPARATOR) { + DOMAIN_SEPARATOR = _DOMAIN_SEPARATOR; + } + + /// @dev Value is equal to keccak256("Permit(address spender,uint256 tokenId,uint256 nonce,uint256 deadline)"); + bytes32 public constant PERMIT_TYPEHASH = 0x49ecf333e5b8c95c40fdafc95c1ad136e8914a8fb55e9dc8bb01eaa83a2df9ad; + + struct Permit { + address spender; + uint256 tokenId; + uint256 nonce; + uint256 deadline; + } + + /// @dev Computes the hash of a permit + /// @param _permit The approval to execute on-chain + /// @return The encoded permit + function getStructHash(Permit memory _permit) internal pure returns (bytes32) { + return keccak256(abi.encode(PERMIT_TYPEHASH, _permit.spender, _permit.tokenId, _permit.nonce, _permit.deadline)); + } + + /// @notice Computes the hash of a fully encoded EIP-712 message for the domain + /// @param _permit The approval to execute on-chain + /// @return The digest to sign and use to recover the signer + function getTypedDataHash(Permit memory _permit) public view returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, getStructHash(_permit))); + } +} diff --git a/test/helpers/MockERC721Permit.sol b/test/helpers/MockERC721Permit.sol new file mode 100644 index 0000000..daedf96 --- /dev/null +++ b/test/helpers/MockERC721Permit.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import {ERC721Permit} from "../../src/pool-cl/base/ERC721Permit.sol"; + +contract MockERC721Permit is ERC721Permit { + uint256 public tokenId; + mapping(uint256 => uint256) public tokenNonce; + + constructor() ERC721Permit("Pancake V4 Positions NFT-V1", "PCS-V4-POS", "1") {} + + function mint() external { + _mint(msg.sender, tokenId++); + } + + function mintTo(address to) external { + _mint(to, tokenId++); + } + + function _getAndIncrementNonce(uint256 _tokenId) internal override returns (uint256) { + return tokenNonce[_tokenId]++; + } +} diff --git a/test/pool-cl/ERC721Permit.t.sol b/test/pool-cl/ERC721Permit.t.sol new file mode 100644 index 0000000..5c8afd9 --- /dev/null +++ b/test/pool-cl/ERC721Permit.t.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import {Test} from "forge-std/Test.sol"; +import {ERC721SigUtils} from "../helpers/ERC721SigUtils.sol"; +import {MockERC721Permit} from "../helpers/MockERC721Permit.sol"; +import {ERC721PermitLib} from "../../src/pool-cl/libraries/ERC721PermitLib.sol"; + +contract ERC721PermitTest is Test { + MockERC721Permit ERC721PermitToken; + ERC721SigUtils sigUtils; + + uint256 alicePrivateKey = 0xA11CE; + address alice; + uint256 bobPrivateKey = 0xB0B; + address bob; + uint256 carolPrivateKey = 0xC0C; + address carol; + + function setUp() public { + ERC721PermitToken = new MockERC721Permit(); + sigUtils = new ERC721SigUtils(ERC721PermitToken.DOMAIN_SEPARATOR()); + alice = vm.addr(alicePrivateKey); + bob = vm.addr(bobPrivateKey); + carol = vm.addr(carolPrivateKey); + ERC721PermitToken.mintTo(alice); + } + + function testERC721Permit() public { + vm.startPrank(alice); + assertEq(ERC721PermitToken.ownerOf(0), alice); + + (uint8 v, bytes32 r, bytes32 s) = getPermitSignature(bob, 0, block.timestamp + 60, 0); + + ERC721PermitToken.permit(bob, 0, block.timestamp + 60, v, r, s); + assertEq(ERC721PermitToken.getApproved(0), bob); + + ERC721PermitToken.transferFrom(alice, carol, 0); + assertEq(ERC721PermitToken.ownerOf(0), carol); + assertEq(ERC721PermitToken.tokenNonce(0), 1); + } + + function testERC721Permit_PermitExpired() public { + vm.startPrank(alice); + assertEq(ERC721PermitToken.ownerOf(0), alice); + + (uint8 v, bytes32 r, bytes32 s) = getPermitSignature(bob, 0, block.timestamp - 1, 0); + + vm.expectRevert(ERC721PermitLib.PermitExpired.selector); + ERC721PermitToken.permit(bob, 0, block.timestamp - 1, v, r, s); + } + + function testERC721Permit_Unauthorized() public { + vm.startPrank(alice); + assertEq(ERC721PermitToken.ownerOf(0), alice); + + (uint8 v, bytes32 r, bytes32 s) = getPermitSignature(bob, 0, block.timestamp + 60, 0); + + vm.expectRevert(ERC721PermitLib.Unauthorized.selector); + ERC721PermitToken.permit(carol, 0, block.timestamp + 60, v, r, s); + } + + function testERC721Permit_ApproveToOneself() public { + vm.startPrank(alice); + assertEq(ERC721PermitToken.ownerOf(0), alice); + + (uint8 v, bytes32 r, bytes32 s) = getPermitSignature(alice, 0, block.timestamp + 60, 0); + + vm.expectRevert(ERC721PermitLib.ApproveToOneself.selector); + ERC721PermitToken.permit(alice, 0, block.timestamp + 60, v, r, s); + } + + function testERC721Permit_InvalidSignature() public { + vm.startPrank(alice); + assertEq(ERC721PermitToken.ownerOf(0), alice); + + (uint8 v, bytes32 r, bytes32 s) = getPermitSignature(bob, 0, block.timestamp + 60, 1); + // modify v to simulate invalid signature + if (v > 1) { + v = 0; + } else { + v = 1; + } + vm.expectRevert(ERC721PermitLib.InvalidSignature.selector); + ERC721PermitToken.permit(bob, 0, block.timestamp + 60, v, r, s); + } + + /// @dev get a permit signature from alice -> ERC721Permit + function getPermitSignature(address spender, uint256 tokenId, uint256 deadline, uint256 nonce) + internal + view + returns (uint8 v, bytes32 r, bytes32 s) + { + // Generate permit signature + ERC721SigUtils.Permit memory permit = + ERC721SigUtils.Permit({spender: spender, tokenId: tokenId, nonce: nonce, deadline: deadline}); + bytes32 digest = sigUtils.getTypedDataHash(permit); + (v, r, s) = vm.sign(alicePrivateKey, digest); + } +}