From b7824507cd8582665d15cbb9033db5f009d5df43 Mon Sep 17 00:00:00 2001 From: npu Date: Fri, 15 May 2026 21:47:50 +0300 Subject: [PATCH] Fix ERC-4494 permit: consume token nonce to prevent signature replay permit() never incremented the per-token nonce (only _transfer did), so a permit signature stayed valid until the token moved: signatures were replayable and approve(address(0)) did not durably revoke an approval. Increment _nonces[tokenId] on every successful permit, in both the ERC-1271 and EOA branches, per ERC-4494, in ERC721Permit, ERC721HybridPermit and ERC721HybridPermitV2. Add a regression test. --- .../erc721/abstract/ERC721HybridPermit.sol | 2 ++ .../erc721/abstract/ERC721HybridPermitV2.sol | 2 ++ .../token/erc721/abstract/ERC721Permit.sol | 2 ++ test/token/erc721/ERC721OperationalBase.t.sol | 33 +++++++++++++++++++ 4 files changed, 39 insertions(+) diff --git a/contracts/token/erc721/abstract/ERC721HybridPermit.sol b/contracts/token/erc721/abstract/ERC721HybridPermit.sol index 0dfecadd..fc6b2f4d 100644 --- a/contracts/token/erc721/abstract/ERC721HybridPermit.sol +++ b/contracts/token/erc721/abstract/ERC721HybridPermit.sol @@ -99,6 +99,7 @@ abstract contract ERC721HybridPermit is ERC721Hybrid, IERC4494, EIP712 { // smart contract wallet signature validation if (_isValidERC1271Signature(ownerOf(tokenId), digest, sig)) { + _nonces[tokenId]++; _approve(spender, tokenId); return; } @@ -121,6 +122,7 @@ abstract contract ERC721HybridPermit is ERC721Hybrid, IERC4494, EIP712 { } if (_isValidEOASignature(recoveredSigner, tokenId)) { + _nonces[tokenId]++; _approve(spender, tokenId); } else { revert InvalidSignature(); diff --git a/contracts/token/erc721/abstract/ERC721HybridPermitV2.sol b/contracts/token/erc721/abstract/ERC721HybridPermitV2.sol index d4ec2461..bc29b1e5 100644 --- a/contracts/token/erc721/abstract/ERC721HybridPermitV2.sol +++ b/contracts/token/erc721/abstract/ERC721HybridPermitV2.sol @@ -101,6 +101,7 @@ abstract contract ERC721HybridPermitV2 is ERC721HybridV2, IERC4494, EIP712 { // smart contract wallet signature validation if (_isValidERC1271Signature(ownerOf(tokenId), digest, sig)) { + _nonces[tokenId]++; _approve(spender, tokenId); return; } @@ -123,6 +124,7 @@ abstract contract ERC721HybridPermitV2 is ERC721HybridV2, IERC4494, EIP712 { } if (_isValidEOASignature(recoveredSigner, tokenId)) { + _nonces[tokenId]++; _approve(spender, tokenId); } else { revert InvalidSignature(); diff --git a/contracts/token/erc721/abstract/ERC721Permit.sol b/contracts/token/erc721/abstract/ERC721Permit.sol index a70dc823..7c0e51d6 100644 --- a/contracts/token/erc721/abstract/ERC721Permit.sol +++ b/contracts/token/erc721/abstract/ERC721Permit.sol @@ -100,6 +100,7 @@ abstract contract ERC721Permit is ERC721Burnable, IERC4494, EIP712, IImmutableER // smart contract signature validation if (_isValidERC1271Signature(ownerOf(tokenId), digest, sig)) { + _nonces[tokenId]++; _approve(spender, tokenId); return; } @@ -122,6 +123,7 @@ abstract contract ERC721Permit is ERC721Burnable, IERC4494, EIP712, IImmutableER } if (_isValidEOASignature(recoveredSigner, tokenId)) { + _nonces[tokenId]++; _approve(spender, tokenId); } else { revert InvalidSignature(); diff --git a/test/token/erc721/ERC721OperationalBase.t.sol b/test/token/erc721/ERC721OperationalBase.t.sol index 3e742a9c..72b0640b 100644 --- a/test/token/erc721/ERC721OperationalBase.t.sol +++ b/test/token/erc721/ERC721OperationalBase.t.sol @@ -484,4 +484,37 @@ abstract contract ERC721OperationalBaseTest is ERC721BaseTest { erc721.permit(user2, tokenId, deadline, signature); assertEq(erc721.getApproved(tokenId), user2); } + + /** + * @notice A permit must consume the token nonce (ERC-4494). A successful + * permit therefore cannot be replayed, and an owner's `approve(address(0))` + * durably revokes the approval (the prior signature is no longer valid). + */ + function testPermitConsumesNonceAndCannotBeReplayed() public { + uint256 tokenId = 1; + vm.prank(minter); + erc721.mint(user1, tokenId); + + uint256 deadline = block.timestamp + 1 days; + uint256 nonce = erc721.nonces(tokenId); + bytes memory signature = getSignature(user1Pkey, user2, tokenId, nonce, deadline); + + vm.prank(user2); + erc721.permit(user2, tokenId, deadline, signature); + assertEq(erc721.getApproved(tokenId), user2); + + // The nonce must advance on a successful permit. + assertEq(erc721.nonces(tokenId), nonce + 1); + + // Owner revokes the approval. + vm.prank(user1); + erc721.approve(address(0), tokenId); + assertEq(erc721.getApproved(tokenId), address(0)); + + // Replaying the original signature must now fail: revocation is durable. + vm.prank(user2); + vm.expectRevert(abi.encodeWithSelector(IImmutableERC721Errors.InvalidSignature.selector)); + erc721.permit(user2, tokenId, deadline, signature); + assertEq(erc721.getApproved(tokenId), address(0)); + } } \ No newline at end of file