Skip to main content

EIP-3009: Gasless transfers

EIP-3009 moves tokens with a single signed authorization — no prior approve and no allowance bookkeeping. The holder signs the transfer parameters off-chain, and any account can execute the transfer. That makes EIP-3009 the building block for gasless payments, checkout flows, and relayer services. Every EVM token contract supports it except Polygon PoS, Tempo, and Hedera (see Availability).

Interface

The token exposes the following functions:

// Execute a transfer signed by `from`. Callable by anyone.
function transferWithAuthorization(
address from, address to, uint256 value,
uint256 validAfter, uint256 validBefore, bytes32 nonce,
uint8 v, bytes32 r, bytes32 s
) external;
function transferWithAuthorization(
address from, address to, uint256 value,
uint256 validAfter, uint256 validBefore, bytes32 nonce,
bytes memory signature
) external;

// Same as transferWithAuthorization, but only the payee can call it
// (`to` must equal msg.sender).
function receiveWithAuthorization(
address from, address to, uint256 value,
uint256 validAfter, uint256 validBefore, bytes32 nonce,
uint8 v, bytes32 r, bytes32 s
) external;
function receiveWithAuthorization(
address from, address to, uint256 value,
uint256 validAfter, uint256 validBefore, bytes32 nonce,
bytes memory signature
) external;

// Invalidate an unused authorization
function cancelAuthorization(
address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s
) external;
function cancelAuthorization(
address authorizer, bytes32 nonce, bytes memory signature
) external;

// True if the nonce has been used (or canceled)
function authorizationState(
address authorizer, bytes32 nonce
) external view returns (bool);

Signed messages

The signed messages use the following structs:

TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)
ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)
CancelAuthorization(address authorizer,bytes32 nonce)

Sign them as EIP-712 typed data over the token's signing domain.

EIP-3009 differs from EIP-2612 in three ways:

  • Random nonces. The nonce is a random 32-byte value chosen by the signer, not a sequential counter. You can sign multiple authorizations and execute them in any order or in parallel. To check whether an authorization has been used, call authorizationState(authorizer, nonce); the contract also emits AuthorizationUsed and AuthorizationCanceled events.
  • Validity window. The authorization is executable only while validAfter < block.timestamp < validBefore. For immediate validity, set validAfter to 0.
  • Direct transfer. Tokens move in the same call — there is no allowance for a third party to spend later.
Use receiveWithAuthorization when the payee is a contract

When the payee is a smart contract, such as a deposit function, use receiveWithAuthorization. The token requires to == msg.sender, so only the payee contract can execute the authorization. With transferWithAuthorization, anyone who sees the signature can submit it directly to the token before your contract does. The tokens arrive, but the contract's accounting logic never runs.

Example: Sign and relay a transfer

The following example signs a transfer authorization with the holder's key and submits it from a relayer account:

import { ethers } from 'ethers';

const EIP3009_ABI = [
'function name() view returns (string)',
'function transferWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)',
];

async function signAndSubmitTransfer(
from: ethers.Wallet, // token holder: signs, pays no gas
relayer: ethers.Signer, // any account: submits, pays gas
tokenAddress: string,
to: string,
value: ethers.BigNumber
) {
const token = new ethers.Contract(tokenAddress, EIP3009_ABI, relayer);
const { chainId } = await relayer.provider!.getNetwork();

const domain = {
name: await token.name(),
version: '1',
chainId,
verifyingContract: tokenAddress,
};

const types = {
TransferWithAuthorization: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'validAfter', type: 'uint256' },
{ name: 'validBefore', type: 'uint256' },
{ name: 'nonce', type: 'bytes32' },
],
};

const message = {
from: from.address,
to,
value,
validAfter: 0, // valid immediately
validBefore: Math.floor(Date.now() / 1000) + 3600, // expires in 1 hour
nonce: ethers.utils.hexlify(ethers.utils.randomBytes(32)), // random, not sequential
};

const signature = await from._signTypedData(domain, types, message);

return token.transferWithAuthorization(
from.address,
to,
value,
message.validAfter,
message.validBefore,
message.nonce,
signature
);
}