Skip to main content

ERC-20 Permit Module

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

Helper functions for verifying EIP-2612 permits and accessing permit storage

Key Features
  • permit performs the full EIP-2612 flow: deadline check, signature recovery, allowance write, nonce bump.
  • It emits Approval itself, so your facet must not emit it again.
  • Exposes three storage getters, one per slot it touches.
Module Usage

Use helper functions from Compose using your own custom facets. See Facets & Modules for more information.

Storage

This module 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 module reads exactly the same string ERC20MetadataMod writes. 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

getERC20MetadataStorage

Returns a pointer to the one-field ERC20MetadataStorage struct, used to read the token name for the domain separator.

function getERC20MetadataStorage() pure returns (ERC20MetadataStorage storage s);

Returns:

PropertyTypeDescriptionsERC20MetadataStorage storageThe struct in storage, with name only.

getERC20Storage

Returns a pointer to the ERC20Storage struct, where allowances live.

function getERC20Storage() pure returns (ERC20Storage storage s);

Returns:

PropertyTypeDescriptionsERC20Storage storageThe struct in storage.

getPermitStorage

Returns a pointer to the NoncesStorage struct.

Read an owner's nonce with getPermitStorage().nonces[_owner]. This module has no nonces() view of its own, unlike ERC20PermitFacet.

function getPermitStorage() pure returns (NoncesStorage storage s);

Returns:

PropertyTypeDescriptionsNoncesStorage storageThe struct in storage.

DOMAIN_SEPARATOR

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

It is computed on every call rather than cached, from the token name in metadata storage, the hardcoded version string "1", the current block.chainid, and the address of the contract executing the code.

function DOMAIN_SEPARATOR() view returns (bytes32);

Returns:

PropertyTypeDescription-bytes32The domain separator for this token on this chain.
Why this module reads the address with assembly

A file-level free function has no this, so the module cannot write address(this) the way the facet does. It reads the executing address with a one-line assembly block instead. Under delegatecall both forms resolve to the same value, the diamond's address, so the module and the facet produce identical domain separators.


permit

Verifies an EIP-2612 signature and sets allowance[_owner][_spender] to _value, then increments the owner's nonce and emits Approval.

The whole flow lives here. Your facet supplies the external entrypoint and nothing else, and it must not emit Approval a second time.

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

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 by this module on every successful permit. Your facet should not emit it again, or the allowance change will appear twice in the logs.

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). One error covers all three, so a caller cannot tell them apart 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.

Best Practices

Integration Notes

Import the module under a namespace and call it from your facet:

Exposing permit from a custom facet
import "src/token/ERC20/Permit/ERC20PermitMod.sol" as ERC20PermitMod;

contract MyFacet {
function permit(
address _owner,
address _spender,
uint256 _value,
uint256 _deadline,
uint8 _v,
bytes32 _r,
bytes32 _s
) external {
ERC20PermitMod.permit(_owner, _spender, _value, _deadline, _v, _r, _s);
}

function nonces(address _owner) external view returns (uint256) {
return ERC20PermitMod.getPermitStorage().nonces[_owner];
}
}

The allowance is written to ERC20Storage at keccak256("erc20"), the same slot ERC20DataFacet reads and ERC20TransferFacet spends, so a permit and a plain approve are interchangeable from every other contract's point of view.

This module writes the allowance directly rather than calling ERC20ApproveMod. It has to: that helper always acts for msg.sender, and permit must act for the signer instead.

Last updated:

Newsletter

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

No spam. Unsubscribe anytime.