Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/metal-chicken-dress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---

`ERC20TransferAuthorization`: Add an ERC-20 extension implementing ERC-3009's transfer with authorization using parallel nonces.
84 changes: 84 additions & 0 deletions contracts/interfaces/draft-IERC3009.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: MIT

pragma solidity >=0.4.16;

/**
* @dev Interface of the ERC-3009 standard as defined in https://eips.ethereum.org/EIPS/eip-3009[ERC-3009].
*/
interface IERC3009 {
/// @dev Emitted when an authorization is used.
event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce);

/**
* @dev Returns the state of an authorization.
*
* Nonces are randomly generated 32-byte values unique to the authorizer's address.
*/
function authorizationState(address authorizer, bytes32 nonce) external view returns (bool);
Comment on lines +12 to +17
Copy link
Contributor

@gonzaotc gonzaotc Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we be a bit more specific in the comments here about the boolean returned value?

i.e, what does it mean the authorization state to be returned as true or false?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without a clear explanation of the returned boolean, true sounds to me like the authorization state is "positive", while it is actually the other way around (the nonce is invalid, has been consumed already)


/**
* @dev Executes a transfer with a signed authorization.
*
* Requirements:
*
* * `validAfter` must be less than the current block timestamp.
* * `validBefore` must be greater than the current block timestamp.
* * `nonce` must not have been used by the `from` account.
* * the signature must be valid for the authorization.
*/
function transferWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
uint8 v,
bytes32 r,
bytes32 s
) external;

/**
* @dev Receives a transfer with a signed authorization from the payer.
*
* Includes an additional check to ensure that the payee's address (`to`) matches the caller
* to prevent front-running attacks.
*
* Requirements:
*
* * `to` must be the caller of this function.
* * `validAfter` must be less than the current block timestamp.
* * `validBefore` must be greater than the current block timestamp.
* * `nonce` must not have been used by the `from` account.
* * the signature must be valid for the authorization.
*/
function receiveWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
uint8 v,
bytes32 r,
bytes32 s
) external;
}

/**
* @dev Extension of {IERC3009} that adds the ability to cancel authorizations.
*/
interface IERC3009Cancel {
/// @dev Emitted when an authorization is canceled.
event AuthorizationCanceled(address indexed authorizer, bytes32 indexed nonce);

/**
* @dev Cancels an authorization.
*
* Requirements:
*
* * `nonce` must not have been used by the `authorizer` account.
* * the signature must be valid for the cancellation.
*/
function cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) external;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.26;

import {ERC20TransferAuthorization} from "../../token/ERC20/extensions/ERC20TransferAuthorization.sol";
import {Time} from "../../utils/types/Time.sol";

abstract contract ERC20TransferAuthorizationBlockNumberMock is ERC20TransferAuthorization {
function clock() public view virtual override returns (uint48) {
return Time.blockNumber();
}

// solhint-disable-next-line func-name-mixedcase
function CLOCK_MODE() public view virtual override returns (string memory) {
if (clock() != Time.blockNumber()) {
revert ERC3009InconsistentClock();
}
return "mode=blocknumber&from=default";
}
}
236 changes: 236 additions & 0 deletions contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {ERC20} from "../ERC20.sol";
import {EIP712} from "../../../utils/cryptography/EIP712.sol";
import {SignatureChecker} from "../../../utils/cryptography/SignatureChecker.sol";
import {ECDSA} from "../../../utils/cryptography/ECDSA.sol";
import {IERC3009, IERC3009Cancel} from "../../../interfaces/draft-IERC3009.sol";
import {NoncesKeyed} from "../../../utils/NoncesKeyed.sol";
import {IERC6372} from "../../../interfaces/IERC6372.sol";
import {Time} from "../../../utils/types/Time.sol";

/**
* @dev Implementation of the ERC-3009 Transfer With Authorization extension allowing
* transfers to be made via signatures, as defined in https://eips.ethereum.org/EIPS/eip-3009[ERC-3009].
*
* Adds the {transferWithAuthorization} and {receiveWithAuthorization} methods, which
* can be used to change an account's ERC-20 balance by presenting a message signed
* by the account. By not relying on {IERC20-approve} and {IERC20-transferFrom}, the
* token holder account doesn't need to send a transaction, and thus is not required
* to hold native currency (e.g. ETH) at all.
*
* NOTE: This extension uses keyed sequential nonces following the
* https://eips.ethereum.org/EIPS/eip-4337#semi-abstracted-nonce-support[ERC-4337 semi-abstracted nonce system].
* The {bytes32} nonce field is interpreted as a 192-bit key packed with a 64-bit sequence. Nonces with
* different keys are independent and can be submitted in parallel without ordering constraints, while nonces
* sharing the same key must be used sequentially. This is unlike {ERC20Permit} which uses a single global
* sequential nonce.
*/
abstract contract ERC20TransferAuthorization is ERC20, EIP712, NoncesKeyed, IERC6372, IERC3009, IERC3009Cancel {
/// @dev The signature is invalid
error ERC3009InvalidSignature();

/// @dev The authorization is not valid at the given time
error ERC3009InvalidAuthorizationTime(uint256 validAfter, uint256 validBefore);

/// @dev The clock was incorrectly modified.
error ERC3009InconsistentClock();

bytes32 private constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH =
keccak256(
"TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"
);
bytes32 private constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH =
keccak256(
"ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"
);
bytes32 private constant CANCEL_AUTHORIZATION_TYPEHASH =
keccak256("CancelAuthorization(address authorizer,bytes32 nonce)");

/**
* @dev Initializes the {EIP712} domain separator using the `name` parameter, and setting `version` to `"1"`.
*
* It's a good idea to use the same `name` that is defined as the ERC-20 token name.
*/
constructor(string memory name) EIP712(name, "1") {}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we ever do contract MyToken is ERC20Permit, ERC20TransferAuthorization {...} would this cause a conflict ?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, it would cause

DeclarationError: Base constructor arguments given twice.
 --> contracts/mocks/token/ERC20TransferAuthorizationBlockNumberMock.sol:9:1:
  |
9 | abstract contract ERC20TransferAuthorizationBlockNumberMock is ERC20Permit, ERC20TransferAuthorization {
  | ^ (Relevant source part starts here and spans across multiple lines).
Note: First constructor call is here:
  --> contracts/token/ERC20/extensions/ERC20Permit.sol:39:37:
   |
39 |     constructor(string memory name) EIP712(name, "1") {}
   |                                     ^^^^^^^^^^^^^^^^^
Note: Second constructor call is here:
  --> contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol:56:37:
   |
56 |     constructor(string memory name) EIP712(name, "1") {}
   |                                     ^^^^^^^^^^^^^^^^^

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solutions I see:

  • have ERC20TransferAuthorization inherit from ERC20Permit. That includes EIP712, and the corresponding constructor.
  • remove that constructor from ERC20TransferAuthorization
    • if the contract inherit from ERC20Permit and ERC20TransferAuthorization, then the EIP constructor from ERC20Permit would do the work
    • if the contract doesn't inherit from ERC20Permit, then the user will have to call EIP721 constructor itself.


/**
* @dev Clock used for validating authorization time windows ({transferWithAuthorization},
* {receiveWithAuthorization}). Defaults to {Time-timestamp}. Can be overridden to implement
* block-number based validation, in which case {CLOCK_MODE} should be overridden as well to match.
*/
function clock() public view virtual returns (uint48) {
return Time.timestamp();
}

/// @dev Machine-readable description of the clock as specified in ERC-6372.
// solhint-disable-next-line func-name-mixedcase
function CLOCK_MODE() public view virtual returns (string memory) {
// Check that the clock was not modified
if (clock() != Time.timestamp()) {
revert ERC3009InconsistentClock();
}
return "mode=timestamp";
}

/**
* @dev See {IERC3009-authorizationState}.
*
* NOTE: Returning `false` does not guarantee that the authorization is currently executable.
* With keyed sequential nonces, a nonce may be blocked by a predecessor in the same key's sequence
* that has not yet been consumed.
*/
function authorizationState(address authorizer, bytes32 nonce) public view virtual returns (bool) {
return uint64(nonces(authorizer, uint192(uint256(nonce) >> 64))) > uint64(uint256(nonce));
}

/**
* @dev See {IERC3009-transferWithAuthorization}.
*
* NOTE: A signed authorization will only succeed if its nonce is the next expected sequence
* for the given key. Authorizations sharing a key must be submitted in order.
*/
function transferWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
uint8 v,
bytes32 r,
bytes32 s
) public virtual {
bytes32 hash = _hashTypedDataV4(
keccak256(abi.encode(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce))
);
(address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(hash, v, r, s);
require(err == ECDSA.RecoverError.NoError && recovered == from, ERC3009InvalidSignature());
Comment on lines +108 to +109
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about using the ECDSA custom errors to be more explicit when the failure is caused by an improper signature format ?

Suggested change
(address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(hash, v, r, s);
require(err == ECDSA.RecoverError.NoError && recovered == from, ERC3009InvalidSignature());
require(from == ECDSA.recover(hash, v, r, s), ERC3009InvalidSignature());

_transferWithAuthorization(from, to, value, validAfter, validBefore, nonce);
}

/// @dev Same as {transferWithAuthorization} but with a bytes signature.
function transferWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
bytes memory signature
) public virtual {
bytes32 hash = _hashTypedDataV4(
keccak256(abi.encode(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce))
);
require(SignatureChecker.isValidSignatureNow(from, hash, signature), ERC3009InvalidSignature());
_transferWithAuthorization(from, to, value, validAfter, validBefore, nonce);
}

/**
* @dev See {IERC3009-receiveWithAuthorization}.
*
* NOTE: A signed authorization will only succeed if its nonce is the next expected sequence
* for the given key. Authorizations sharing a key must be submitted in order.
*/
function receiveWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
uint8 v,
bytes32 r,
bytes32 s
) public virtual {
bytes32 hash = _hashTypedDataV4(
keccak256(abi.encode(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce))
);
(address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(hash, v, r, s);
require(err == ECDSA.RecoverError.NoError && recovered == from, ERC3009InvalidSignature());
Comment on lines +150 to +151
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discussion here : https://github.com/OpenZeppelin/openzeppelin-contracts/pull/6354/changes#r2883684250

Suggested change
(address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(hash, v, r, s);
require(err == ECDSA.RecoverError.NoError && recovered == from, ERC3009InvalidSignature());
require(from == ECDSA.recover(hash, v, r, s), ERC3009InvalidSignature());

_receiveWithAuthorization(from, to, value, validAfter, validBefore, nonce);
}

/// @dev Same as {receiveWithAuthorization} but with a bytes signature.
function receiveWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
bytes memory signature
) public virtual {
bytes32 hash = _hashTypedDataV4(
keccak256(abi.encode(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce))
);
require(SignatureChecker.isValidSignatureNow(from, hash, signature), ERC3009InvalidSignature());
_receiveWithAuthorization(from, to, value, validAfter, validBefore, nonce);
}

/**
* @dev See {IERC3009Cancel-cancelAuthorization}.
*
* NOTE: Due to the keyed sequential nonce model, only the next nonce in a given key's sequence
* can be cancelled. It is not possible to directly cancel a future nonce whose predecessors in the
* same key have not yet been consumed or cancelled. To invalidate a future authorization, all
* preceding nonces in the same key must first be consumed or cancelled in order.
*/
function cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) public virtual {
bytes32 hash = _hashTypedDataV4(keccak256(abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce)));
(address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(hash, v, r, s);
require(err == ECDSA.RecoverError.NoError && recovered == authorizer, ERC3009InvalidSignature());
Comment on lines +182 to +183
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discussion here : https://github.com/OpenZeppelin/openzeppelin-contracts/pull/6354/changes#r2883684250

Suggested change
(address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(hash, v, r, s);
require(err == ECDSA.RecoverError.NoError && recovered == authorizer, ERC3009InvalidSignature());
require(from == ECDSA.recover(hash, v, r, s), ERC3009InvalidSignature());

_cancelAuthorization(authorizer, nonce);
}

/// @dev Same as {cancelAuthorization} but with a bytes signature.
function cancelAuthorization(address authorizer, bytes32 nonce, bytes memory signature) public virtual {
bytes32 hash = _hashTypedDataV4(keccak256(abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce)));
require(SignatureChecker.isValidSignatureNow(authorizer, hash, signature), ERC3009InvalidSignature());
_cancelAuthorization(authorizer, nonce);
}

/// @dev Internal version of {transferWithAuthorization} that accepts a bytes signature.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_transferWithAuthorization, _receiveWithAuthorization and _cancelAuthorization NatSpec claims that they receive a bytes signature, while they don't

function _transferWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce
) internal virtual {
require(
clock() > validAfter && clock() < validBefore,
ERC3009InvalidAuthorizationTime(validAfter, validBefore)
);
_useCheckedNonce(from, uint256(nonce));
emit AuthorizationUsed(from, nonce);
_transfer(from, to, value);
}

/// @dev Internal version of {receiveWithAuthorization} that accepts a bytes signature.
function _receiveWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce
) internal virtual {
require(to == _msgSender(), ERC20InvalidReceiver(to));
require(
clock() > validAfter && clock() < validBefore,
ERC3009InvalidAuthorizationTime(validAfter, validBefore)
);
_useCheckedNonce(from, uint256(nonce));
emit AuthorizationUsed(from, nonce);
_transfer(from, to, value);
}

/// @dev Internal version of {cancelAuthorization} that accepts a bytes signature.
function _cancelAuthorization(address authorizer, bytes32 nonce) internal virtual {
_useCheckedNonce(authorizer, uint256(nonce));
emit AuthorizationCanceled(authorizer, nonce);
}
}
17 changes: 17 additions & 0 deletions test/helpers/eip712-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,23 @@ module.exports = mapValues(
salt: 'bytes32',
},
Permit: { owner: 'address', spender: 'address', value: 'uint256', nonce: 'uint256', deadline: 'uint256' },
TransferWithAuthorization: {
from: 'address',
to: 'address',
value: 'uint256',
validAfter: 'uint256',
validBefore: 'uint256',
nonce: 'bytes32',
},
ReceiveWithAuthorization: {
from: 'address',
to: 'address',
value: 'uint256',
validAfter: 'uint256',
validBefore: 'uint256',
nonce: 'bytes32',
},
CancelAuthorization: { authorizer: 'address', nonce: 'bytes32' },
Ballot: { proposalId: 'uint256', support: 'uint8', voter: 'address', nonce: 'uint256' },
ExtendedBallot: {
proposalId: 'uint256',
Expand Down
Loading