Skip to main content

ERC-20 Permit Facet

@perfect-abstractions/compose/token/ERC20/Permit/ERC20PermitFacet.sol

Set an allowance from a signed message instead of a transaction, following EIP-2612

Key Features
  • permit sets an allowance from an owner's signature, so the owner never has to send a transaction.
  • Anyone may submit a valid signature. The allowance is still recorded for the signer.
  • Each success increments the owner's nonce, so a signature works exactly once.

Storage

This facet touches three separate slots: the allowance it writes, the token name it hashes into the EIP-712 domain, and the nonce it consumes.

State Variables

PropertyTypeDescriptionERC20_METADATA_STORAGE_POSITIONbytes32Metadata storage position, read for the token name (Value: keccak256("erc20.metadata"))ERC20_STORAGE_POSITIONbytes32ERC-20 storage position, where the allowance is written (Value: keccak256("erc20"))STORAGE_POSITIONbytes32Nonce storage position (Value: keccak256("nonces"))

ERC20MetadataStorage

Only the name field is declared here, because the domain separator needs nothing else.

Definition
/** @custom:storage-location erc8042:erc20.metadata */
struct ERC20MetadataStorage {
string name;
}
Why the struct is shorter here

This is a layout-compatible prefix of the metadata struct, not a different slot. name is the first field either way, so this facet reads exactly the same string that ERC20MetadataFacet returns from name(). The symbol and decimals fields are simply not declared, because permit never reads them.

ERC20Storage

Definition
/** @custom:storage-location erc8042:erc20 */
struct ERC20Storage {
mapping(address owner => uint256 balance) balanceOf;
uint256 totalSupply;
mapping(address owner => mapping(address spender => uint256 allowance)) allowance;
}

NoncesStorage

Definition
/** @custom:storage-location erc8042:nonces */
struct NoncesStorage {
mapping(address owner => uint256) nonces;
}
Slot identifier is not namespaced

The nonce slot is keccak256("nonces"), a bare identifier rather than something like erc20.nonces. Any other contract in the same diamond that picks the string "nonces" for its own storage will land on this slot and corrupt permit nonces. Nothing else in Compose uses it today.

Functions

nonces

Returns the number of permits _owner has already used. The next signature must be signed with this value.

function nonces(address _owner) external view returns (uint256);

Parameters:

PropertyTypeDescription_owneraddressThe address to query. Any address that has never used a permit returns 0.

Returns:

PropertyTypeDescription-uint256The owner's current nonce, which is also the nonce the next signature must carry.

DOMAIN_SEPARATOR

Returns the EIP-712 domain separator that signatures must be built against.

It is computed on every call rather than cached at deployment, from four inputs: the token name read from metadata storage, the hardcoded version string "1", the current block.chainid, and the diamond's own address.

function DOMAIN_SEPARATOR() external view returns (bytes32);

Returns:

PropertyTypeDescription-bytes32The domain separator for this token on this chain.

permit

Verifies an EIP-2612 signature and sets allowance[_owner][_spender] to _value.

The call is permissionless. Whoever relays the signature pays the gas, and the allowance is recorded for _owner regardless of who submitted it. The owner's nonce increases by one, and only on success.

function permit(
address _owner,
address _spender,
uint256 _value,
uint256 _deadline,
uint8 _v,
bytes32 _r,
bytes32 _s
) external;

Parameters:

PropertyTypeDescription_owneraddressThe address that signed the permit and whose allowance is set. Must match the recovered signer._spenderaddressThe address receiving the allowance. Cannot be address(0)._valueuint256The new allowance. It replaces any previous value rather than adding to it._deadlineuint256Unix timestamp after which the signature is refused. A permit submitted in the block where block.timestamp equals the deadline is still accepted._vuint8Recovery byte of the signature._rbytes32The r value of the signature._sbytes32The s value of the signature.

Reverts:

PropertyTypeDescriptionERC20InvalidSpendererror_spender is address(0). Checked before anything else.ERC2612InvalidSignatureerrorThe deadline has passed, or the recovered signer is not _owner, or recovery failed. All three cases share this one error.

Events

Emitted on every successful permit. Identical in shape to the event ERC20ApproveFacet emits, so an allowance set by signature is indistinguishable in the logs from one set by a transaction.

Signature:
event Approval(address indexed _owner, address indexed _spender, uint256 _value);
Parameters:
PropertyTypeDescription_owneraddressThe signer granting the allowance. Not the account that submitted the transaction._spenderaddressThe address receiving the allowance._valueuint256The new allowance.

Errors

Thrown for every signature failure: an expired deadline, a signer that does not match _owner, or a recovery that returned address(0). Because one error covers all three, a caller cannot tell an expired permit from a malformed one without checking the deadline separately.

Signature:
error ERC2612InvalidSignature(
address _owner, address _spender, uint256 _value, uint256 _deadline, uint8 _v, bytes32 _r, bytes32 _s
);
Parameters:
PropertyTypeDescription_owneraddressThe address the permit claimed to be from._spenderaddressThe spender named in the permit._valueuint256The allowance the permit asked for._deadlineuint256The deadline carried by the permit._vuint8Recovery byte of the rejected signature._rbytes32The r value of the rejected signature._sbytes32The s value of the rejected signature.

Thrown when _spender is the zero address. This check runs before the deadline and signature checks, so it fires even on an otherwise invalid permit.

Signature:
error ERC20InvalidSpender(address _spender);
Parameters:
PropertyTypeDescription_spenderaddressThe rejected spender. Always address(0).

Best Practices

Security Considerations

Renaming the token invalidates every outstanding permit. The domain separator hashes the name read from metadata storage, and it is recomputed on each call rather than cached. Calling setMetadata with a different name silently changes the domain, so signatures already in flight stop verifying and any integrator caching the old DOMAIN_SEPARATOR() starts producing rejected signatures.

The nonce is consumed only on success. It is incremented after the signature check passes, so failed submissions do not burn nonces and cannot be used to grief an owner's pending signatures.

Anyone can submit someone else's permit. That is the design, but it means a contract that bundles permit and a follow-up action in one transaction can be broken by a third party submitting the permit first: the bundled call then reverts on the now-stale nonce. Tolerate an already-consumed permit if you bundle.

Signature malleability is not rejected, but replay is prevented. ecrecover is called directly, with no low-s bound and no check that _v is 27 or 28, so an altered but still valid encoding of a signature recovers the same signer. This does not enable replay, because the nonce has already moved on after the first use. The one failure mode ecrecover does have is covered: a recovery that returns address(0) is rejected explicitly, so a malformed signature cannot be passed off as a permit from the zero address.

The deadline is inclusive. The check rejects only when block.timestamp is strictly greater than _deadline, so a permit remains usable during the block whose timestamp equals its deadline.

Last updated:

Newsletter

Get notified about releases, feature announcements, and technical deep-dives on building smart contracts with Compose.

No spam. Unsubscribe anytime.