EIP4494 - Permit for ERC-721 NFTs

# Abstract

The "Permit" approval flow outlined in ERC-2612 has proven a very valuable advancement in UX by creating gasless approvals for ERC20 tokens. This EIP extends the pattern to ERC-721 NFTs. This EIP borrows heavily from ERC-2612.

This requires a separate EIP due to the difference in structure between ERC-20 and ERC-721 tokens. While ERC-20 permits use value (the amount of the ERC-20 token being approved) and a nonce based on the owner's address, ERC-721 permits focus on the tokenId of the NFT and increment nonce based on the transfers of the NFT.

# Motivation

The permit structure outlined in ERC-2612 allows for a signed message (structured as outlined in ERC-712) to be used in order to create an approval. Whereas the normal approval-based pull flow generally involves two transactions, one to approve a contract and a second for the contract to pull the asset, which is poor UX and often confuses new users, a permit-style flow only requires signing a message and a transaction. Additional information can be found in ERC-2612.

ERC-2612 only outlines a permit architecture for ERC-20 tokens. This ERC proposes an architecture for ERC-721 NFTs, which also contain an approve architecture that would benefit from a signed message-based approval flow.

# Specification

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

Three new functions MUST be added to ERC-721:

pragma solidity 0.8.10;

import "./IERC165.sol";

///
/// @dev Interface for token permits for ERC-721
///
interface IERC4494 is IERC165 {
  /// ERC165 bytes to add to interface array - set in parent contract
  ///
  /// _INTERFACE_ID_ERC4494 = 0x5604e225

  /// @notice Function to approve by way of owner signature
  /// @param spender the address to approve
  /// @param tokenId the index of the NFT to approve the spender on
  /// @param deadline a timestamp expiry for the permit
  /// @param sig a traditional or EIP-2098 signature
  function permit(address spender, uint256 tokenId, uint256 deadline, bytes memory sig) external;
  /// @notice Returns the nonce of an NFT - useful for creating permits
  /// @param tokenId the index of the NFT to get the nonce of
  /// @return the uint256 representation of the nonce
  function nonces(uint256 tokenId) external view returns(uint256);
  /// @notice Returns the domain separator used in the encoding of the signature for permits, as defined by EIP-712
  /// @return the bytes32 domain separator
  function DOMAIN_SEPARATOR() external view returns(bytes32);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

The semantics of which are as follows:

For all addresses spender, uint256s tokenId, deadline, and nonce, and bytes sig, a call to permit(spender, tokenId, deadline, sig) MUST set spender as approved on tokenId as long as the owner of tokenId remains in possession of it, and MUST emit a corresponding Approval event, if and only if the following conditions are met:

  • the current blocktime is less than or equal to deadline
  • the owner of the tokenId is not the zero address
  • nonces[tokenId] is equal to nonce
  • sig is a valid secp256k1 or EIP-2098 signature from owner of the tokenId:
keccak256(abi.encodePacked(
   hex"1901",
   DOMAIN_SEPARATOR,
   keccak256(abi.encode(
            keccak256("Permit(address spender,uint256 tokenId,uint256 nonce,uint256 deadline)"),
            spender,
            tokenId,
            nonce,
            deadline))
));
1
2
3
4
5
6
7
8
9
10

where DOMAIN_SEPARATOR MUST be defined according to EIP-712. The DOMAIN_SEPARATOR should be unique to the contract and chain to prevent replay attacks from other domains, and satisfy the requirements of EIP-712, but is otherwise unconstrained. A common choice for DOMAIN_SEPARATOR is:

DOMAIN_SEPARATOR = keccak256(
    abi.encode(
        keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
        keccak256(bytes(name)),
        keccak256(bytes(version)),
        chainid,
        address(this)
));
1
2
3
4
5
6
7
8

In other words, the message is the following ERC-712 typed structure:

{
  "types": {
    "EIP712Domain": [
      {
        "name": "name",
        "type": "string"
      },
      {
        "name": "version",
        "type": "string"
      },
      {
        "name": "chainId",
        "type": "uint256"
      },
      {
        "name": "verifyingContract",
        "type": "address"
      }
    ],
    "Permit": [
      {
        "name": "spender",
        "type": "address"
      },
      {
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "name": "nonce",
        "type": "uint256"
      },
      {
        "name": "deadline",
        "type": "uint256"
      }
    ],
    "primaryType": "Permit",
    "domain": {
      "name": erc721name,
      "version": version,
      "chainId": chainid,
      "verifyingContract": tokenAddress
  },
  "message": {
    "spender": spender,
    "value": value,
    "nonce": nonce,
    "deadline": deadline
  }
}}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

In addition:

  • the nonce of a particular tokenId (nonces[tokenId]) MUST be incremented upon any transfer of the tokenId
  • the permit function MUST check that the signer is not the zero address

Note that nowhere in this definition do we refer to msg.sender. The caller of the permit function can be any address.

This EIP requires EIP-165. EIP165 is already required in ERC-721, but is further necessary here in order to register the interface of this EIP. Doing so will allow easy verification if an NFT contract has implemented this EIP or not, enabling them to interact accordingly. The interface of this EIP (as defined in EIP-165) is 0x5604e225. Contracts implementing this EIP MUST have the supportsInterface function return true when called with 0x5604e225.

# Rationale

The permit function is sufficient for enabling a safeTransferFrom transaction to be made without the need for an additional transaction.

The format avoids any calls to unknown code.

The nonces mapping is given for replay protection.

A common use case of permit has a relayer submit a Permit on behalf of the owner. In this scenario, the relaying party is essentially given a free option to submit or withhold the Permit. If this is a cause of concern, the owner can limit the time a Permit is valid for by setting deadline to a value in the near future. The deadline argument can be set to uint(-1) to create Permits that effectively never expire.

ERC-712 typed messages are included because of its use in ERC-2612, which in turn cites widespread adoption in many wallet providers.

While ERC-2612 focuses on the value being approved, this EIP focuses on the tokenId of the NFT being approved via permit. This enables a flexibility that cannot be achieved with ERC-20 (or even ERC-1155) tokens, enabling a single owner to give multiple permits on the same NFT. This is possible since each ERC-721 token is discrete (oftentimes referred to as non-fungible), which allows assertion that this token is still in the possession of the owner simply and conclusively.

Whereas ERC-2612 splits signatures into their v,r,s components, this EIP opts to instead take a bytes array of variable length in order to support EIP-2098 signatures (64 bytes), which cannot be easily separated or reconstructed from r,s,v components (65 bytes).

# Backwards Compatibility

There are already some ERC-721 contracts implementing a permit-style architecture, most notably Uniswap v3.

Their implementation differs from the specification here in that:

  • the permit architecture is based on owner
  • the nonce is incremented at the time the permit is created
  • the permit function must be called by the NFT owner, who is set as the owner
  • the signature is split into r,s,v instead of bytes

Rationale for differing on design decisions is detailed above.

# Test Cases

Basic test cases for the reference implementation can be found here (opens new window).

In general, test suites should assert at least the following about any implementation of this EIP:

  • the nonce is incremented after each transfer
  • permit approves the spender on the correct tokenId
  • the permit cannot be used after the NFT is transferred
  • an expired permit cannot be used

# Reference Implementation

A reference implementation has been set up here (opens new window).

# Security Considerations

Extra care should be taken when creating transfer functions in which permit and a transfer function can be used in one function to make sure that invalid permits cannot be used in any way. This is especially relevant for automated NFT platforms, in which a careless implementation can result in the compromise of a number of user assets.

The remaining considerations have been copied from ERC-2612 with minor adaptation, and are equally relevant here:

Though the signer of a Permit may have a certain party in mind to submit their transaction, another party can always front run this transaction and call permit before the intended party. The end result is the same for the Permit signer, however.

Since the ecrecover precompile fails silently and just returns the zero address as signer when given malformed messages, it is important to ensure ownerOf(tokenId) != address(0) to avoid permit from creating an approval to any tokenId which does not have an approval set.

Signed Permit messages are censorable. The relaying party can always choose to not submit the Permit after having received it, withholding the option to submit it. The deadline parameter is one mitigation to this. If the signing party holds ETH they can also just submit the Permit themselves, which can render previously signed Permits invalid.

The standard ERC-20 race condition for approvals (opens new window) applies to permit as well.

If the DOMAIN_SEPARATOR contains the chainId and is defined at contract deployment instead of reconstructed for every signature, there is a risk of possible replay attacks between chains in the event of a future chain split.

Copyright and related rights waived via CC0 (opens new window).

▲ Powered by Vercel