From 481470ff64d50aa61b95e0cb9466d3822045ac40 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Tue, 17 Feb 2026 15:55:03 -0600 Subject: [PATCH 01/10] Add ERC3009 --- contracts/interfaces/draft-IERC3009.sol | 82 +++++++ .../extensions/ERC20TransferAuthorization.sol | 208 ++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 contracts/interfaces/draft-IERC3009.sol create mode 100644 contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol diff --git a/contracts/interfaces/draft-IERC3009.sol b/contracts/interfaces/draft-IERC3009.sol new file mode 100644 index 00000000000..3b1c052874f --- /dev/null +++ b/contracts/interfaces/draft-IERC3009.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT + +pragma solidity >=0.4.16; + +interface IERC3009 { + event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce); + + /** + * @notice Returns the state of an authorization + * @dev Nonces are randomly generated 32-byte data unique to the authorizer's + * address + * @param authorizer Authorizer's address + * @param nonce Nonce of the authorization + * @return True if the nonce is used + */ + function authorizationState(address authorizer, bytes32 nonce) external view returns (bool); + + /** + * @notice Execute a transfer with a signed authorization + * @param from Payer's address (Authorizer) + * @param to Payee's address + * @param value Amount to be transferred + * @param validAfter The time after which this is valid (unix time) + * @param validBefore The time before which this is valid (unix time) + * @param nonce Unique nonce + * @param v v of the signature + * @param r r of the signature + * @param s s of the signature + */ + function transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external; + + /** + * @notice Receive a transfer with a signed authorization from the payer + * @dev This has an additional check to ensure that the payee's address matches + * the caller of this function to prevent front-running attacks. (See security + * considerations) + * @param from Payer's address (Authorizer) + * @param to Payee's address + * @param value Amount to be transferred + * @param validAfter The time after which this is valid (unix time) + * @param validBefore The time before which this is valid (unix time) + * @param nonce Unique nonce + * @param v v of the signature + * @param r r of the signature + * @param s s of the signature + */ + function receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external; +} + +interface IERC3009Cancel { + event AuthorizationCanceled(address indexed authorizer, bytes32 indexed nonce); + + /** + * @notice Attempt to cancel an authorization + * @param authorizer Authorizer's address + * @param nonce Nonce of the authorization + * @param v v of the signature + * @param r r of the signature + * @param s s of the signature + */ + function cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) external; +} diff --git a/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol b/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol new file mode 100644 index 00000000000..b7639b478c2 --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ERC20} from "../ERC20.sol"; +import {EIP712} from "../../../utils/cryptography/EIP712.sol"; +import {SignatureChecker} from "../../../utils/cryptography/SignatureChecker.sol"; +import {MessageHashUtils} from "../../../utils/cryptography/MessageHashUtils.sol"; +import {IERC3009, IERC3009Cancel} from "../../../interfaces/draft-IERC3009.sol"; + +abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC3009Cancel { + /// @dev The signature is invalid + error ERC3009InvalidSignature(); + + /// @dev The authorization is already used or canceled + error ERC3009ConsumedAuthorization(address authorizer, bytes32 nonce); + + /// @dev The authorization is not valid at the given time + error ERC3009InvalidAuthorizationTime(uint256 validAfter, uint256 validBefore); + + 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)"); + + mapping(address => mapping(bytes32 => bool)) private _consumed; + + /// @inheritdoc IERC3009 + function authorizationState(address authorizer, bytes32 nonce) public view virtual returns (bool) { + return _consumed[authorizer][nonce]; + } + + /// @inheritdoc IERC3009 + function transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) public virtual { + _transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, v, r, s); + } + + /// @inheritdoc IERC3009 + function receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) public virtual { + _receiveWithAuthorization(from, to, value, validAfter, validBefore, nonce, v, r, s); + } + + /// @inheritdoc IERC3009Cancel + function cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) public virtual { + _cancelAuthorization(authorizer, nonce, v, r, s); + } + + /// @dev Internal version of {transferWithAuthorization}. + function _transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) internal virtual { + _transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, abi.encodePacked(r, s, v)); + } + + /// @dev Internal version of {receiveWithAuthorization} that accepts a packed signature. + function _transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + bytes memory signature + ) internal virtual { + require( + block.timestamp > validAfter && block.timestamp < validBefore, + ERC3009InvalidAuthorizationTime(validAfter, validBefore) + ); + require(!_consumed[from][nonce], ERC3009ConsumedAuthorization(from, nonce)); + require( + SignatureChecker.isValidSignatureNow( + from, + MessageHashUtils.toTypedDataHash( + _domainSeparatorV4(), + keccak256( + abi.encode( + TRANSFER_WITH_AUTHORIZATION_TYPEHASH, + from, + to, + value, + validAfter, + validBefore, + nonce + ) + ) + ), + signature + ), + ERC3009InvalidSignature() + ); + + _consumed[from][nonce] = true; + emit AuthorizationUsed(from, nonce); + _transfer(from, to, value); + } + + /** + * @dev Receive a transfer with a signed authorization from the payer + * + * This has an additional check to ensure that the payee's address + * matches the caller of this function to prevent front-running attacks. + */ + function _receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) internal virtual { + _receiveWithAuthorization(from, to, value, validAfter, validBefore, nonce, abi.encodePacked(r, s, v)); + } + + /// @dev Internal version of {receiveWithAuthorization} that accepts a packed signature. + function _receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + bytes memory signature + ) internal virtual { + require(to == msg.sender, ERC20InvalidReceiver(to)); + require( + block.timestamp > validAfter && block.timestamp < validBefore, + ERC3009InvalidAuthorizationTime(validAfter, validBefore) + ); + require(!_consumed[from][nonce], ERC3009ConsumedAuthorization(from, nonce)); + require( + SignatureChecker.isValidSignatureNow( + from, + MessageHashUtils.toTypedDataHash( + _domainSeparatorV4(), + keccak256( + abi.encode(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce) + ) + ), + signature + ), + ERC3009InvalidSignature() + ); + + _consumed[from][nonce] = true; + emit AuthorizationUsed(from, nonce); + _transfer(from, to, value); + } + + /// @dev Internal version of {cancelAuthorization}/ + function _cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) internal virtual { + _cancelAuthorization(authorizer, nonce, abi.encodePacked(r, s, v)); + } + + /// @dev Internal version of {cancelAuthorization} that accepts a packed signature. + function _cancelAuthorization(address authorizer, bytes32 nonce, bytes memory signature) internal virtual { + require(!_consumed[authorizer][nonce], ERC3009ConsumedAuthorization(authorizer, nonce)); + require( + SignatureChecker.isValidSignatureNow( + authorizer, + MessageHashUtils.toTypedDataHash( + _domainSeparatorV4(), + keccak256(abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce)) + ), + signature + ), + ERC3009InvalidSignature() + ); + + _consumed[authorizer][nonce] = true; + emit AuthorizationCanceled(authorizer, nonce); + } +} From d66d1e82e382a28c6232e05dd126b9d13b3a621f Mon Sep 17 00:00:00 2001 From: ernestognw Date: Tue, 17 Feb 2026 20:51:52 -0600 Subject: [PATCH 02/10] Add tests --- contracts/interfaces/draft-IERC3009.sol | 72 +- .../extensions/ERC20TransferAuthorization.sol | 166 ++-- test/helpers/eip712-types.js | 17 + .../ERC20TransferAuthorization.test.js | 841 ++++++++++++++++++ 4 files changed, 1011 insertions(+), 85 deletions(-) create mode 100644 test/token/ERC20/extensions/ERC20TransferAuthorization.test.js diff --git a/contracts/interfaces/draft-IERC3009.sol b/contracts/interfaces/draft-IERC3009.sol index 3b1c052874f..fcde71bdef8 100644 --- a/contracts/interfaces/draft-IERC3009.sol +++ b/contracts/interfaces/draft-IERC3009.sol @@ -2,30 +2,29 @@ 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); /** - * @notice Returns the state of an authorization - * @dev Nonces are randomly generated 32-byte data unique to the authorizer's - * address - * @param authorizer Authorizer's address - * @param nonce Nonce of the authorization - * @return True if the nonce is used + * @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); /** - * @notice Execute a transfer with a signed authorization - * @param from Payer's address (Authorizer) - * @param to Payee's address - * @param value Amount to be transferred - * @param validAfter The time after which this is valid (unix time) - * @param validBefore The time before which this is valid (unix time) - * @param nonce Unique nonce - * @param v v of the signature - * @param r r of the signature - * @param s s of the signature + * @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, @@ -40,19 +39,18 @@ interface IERC3009 { ) external; /** - * @notice Receive a transfer with a signed authorization from the payer - * @dev This has an additional check to ensure that the payee's address matches - * the caller of this function to prevent front-running attacks. (See security - * considerations) - * @param from Payer's address (Authorizer) - * @param to Payee's address - * @param value Amount to be transferred - * @param validAfter The time after which this is valid (unix time) - * @param validBefore The time before which this is valid (unix time) - * @param nonce Unique nonce - * @param v v of the signature - * @param r r of the signature - * @param s s of the signature + * @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, @@ -67,16 +65,20 @@ interface IERC3009 { ) 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); /** - * @notice Attempt to cancel an authorization - * @param authorizer Authorizer's address - * @param nonce Nonce of the authorization - * @param v v of the signature - * @param r r of the signature - * @param s s of the signature + * @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; } diff --git a/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol b/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol index b7639b478c2..fef98f2285f 100644 --- a/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol +++ b/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol @@ -7,6 +7,19 @@ import {SignatureChecker} from "../../../utils/cryptography/SignatureChecker.sol import {MessageHashUtils} from "../../../utils/cryptography/MessageHashUtils.sol"; import {IERC3009, IERC3009Cancel} from "../../../interfaces/draft-IERC3009.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 non-sequential nonces to allow for flexible transaction ordering + * and parallel transaction submission, unlike {ERC20Permit} which uses sequential nonces. + */ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC3009Cancel { /// @dev The signature is invalid error ERC3009InvalidSignature(); @@ -30,6 +43,13 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 mapping(address => mapping(bytes32 => bool)) private _consumed; + /** + * @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") {} + /// @inheritdoc IERC3009 function authorizationState(address authorizer, bytes32 nonce) public view virtual returns (bool) { return _consumed[authorizer][nonce]; @@ -50,6 +70,19 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 _transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, v, r, s); } + /// @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 { + _transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, signature); + } + /// @inheritdoc IERC3009 function receiveWithAuthorization( address from, @@ -65,11 +98,38 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 _receiveWithAuthorization(from, to, value, validAfter, validBefore, nonce, v, r, s); } + /// @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 { + _receiveWithAuthorization(from, to, value, validAfter, validBefore, nonce, signature); + } + /// @inheritdoc IERC3009Cancel function cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) public virtual { _cancelAuthorization(authorizer, nonce, v, r, s); } + /// @dev Same as {cancelAuthorization} but with a bytes signature. + function cancelAuthorization(address authorizer, bytes32 nonce, bytes memory signature) public virtual { + _cancelAuthorization(authorizer, nonce, signature); + } + + /** + * @dev Returns the domain separator used in the encoding of the signature for + * {transferWithAuthorization} and {receiveWithAuthorization}, as defined by {EIP712}. + */ + // solhint-disable-next-line func-name-mixedcase + function DOMAIN_SEPARATOR() external view returns (bytes32) { + return _domainSeparatorV4(); + } + /// @dev Internal version of {transferWithAuthorization}. function _transferWithAuthorization( address from, @@ -85,7 +145,7 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 _transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, abi.encodePacked(r, s, v)); } - /// @dev Internal version of {receiveWithAuthorization} that accepts a packed signature. + /// @dev Internal version of {transferWithAuthorization} that accepts a bytes signature. function _transferWithAuthorization( address from, address to, @@ -99,26 +159,9 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 block.timestamp > validAfter && block.timestamp < validBefore, ERC3009InvalidAuthorizationTime(validAfter, validBefore) ); - require(!_consumed[from][nonce], ERC3009ConsumedAuthorization(from, nonce)); + require(!authorizationState(from, nonce), ERC3009ConsumedAuthorization(from, nonce)); require( - SignatureChecker.isValidSignatureNow( - from, - MessageHashUtils.toTypedDataHash( - _domainSeparatorV4(), - keccak256( - abi.encode( - TRANSFER_WITH_AUTHORIZATION_TYPEHASH, - from, - to, - value, - validAfter, - validBefore, - nonce - ) - ) - ), - signature - ), + _validateTransferWithAuthorization(from, to, value, validAfter, validBefore, nonce, signature), ERC3009InvalidSignature() ); @@ -128,10 +171,10 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 } /** - * @dev Receive a transfer with a signed authorization from the payer + * @dev Internal version of {receiveWithAuthorization}. * - * This has an additional check to ensure that the payee's address - * matches the caller of this function to prevent front-running attacks. + * Includes an additional check to ensure that the payee's address matches the caller to prevent + * front-running attacks. */ function _receiveWithAuthorization( address from, @@ -147,7 +190,7 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 _receiveWithAuthorization(from, to, value, validAfter, validBefore, nonce, abi.encodePacked(r, s, v)); } - /// @dev Internal version of {receiveWithAuthorization} that accepts a packed signature. + /// @dev Internal version of {receiveWithAuthorization} that accepts a bytes signature. function _receiveWithAuthorization( address from, address to, @@ -157,23 +200,14 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 bytes32 nonce, bytes memory signature ) internal virtual { - require(to == msg.sender, ERC20InvalidReceiver(to)); + require(to == _msgSender(), ERC20InvalidReceiver(to)); require( block.timestamp > validAfter && block.timestamp < validBefore, ERC3009InvalidAuthorizationTime(validAfter, validBefore) ); - require(!_consumed[from][nonce], ERC3009ConsumedAuthorization(from, nonce)); + require(!authorizationState(from, nonce), ERC3009ConsumedAuthorization(from, nonce)); require( - SignatureChecker.isValidSignatureNow( - from, - MessageHashUtils.toTypedDataHash( - _domainSeparatorV4(), - keccak256( - abi.encode(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce) - ) - ), - signature - ), + _validateReceiveWithAuthorization(from, to, value, validAfter, validBefore, nonce, signature), ERC3009InvalidSignature() ); @@ -182,27 +216,59 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 _transfer(from, to, value); } - /// @dev Internal version of {cancelAuthorization}/ + /// @dev Internal version of {cancelAuthorization}. function _cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) internal virtual { _cancelAuthorization(authorizer, nonce, abi.encodePacked(r, s, v)); } - /// @dev Internal version of {cancelAuthorization} that accepts a packed signature. + /// @dev Internal version of {cancelAuthorization} that accepts a bytes signature. function _cancelAuthorization(address authorizer, bytes32 nonce, bytes memory signature) internal virtual { - require(!_consumed[authorizer][nonce], ERC3009ConsumedAuthorization(authorizer, nonce)); - require( - SignatureChecker.isValidSignatureNow( - authorizer, - MessageHashUtils.toTypedDataHash( - _domainSeparatorV4(), - keccak256(abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce)) - ), - signature - ), - ERC3009InvalidSignature() - ); + require(!authorizationState(authorizer, nonce), ERC3009ConsumedAuthorization(authorizer, nonce)); + require(_validateCancelAuthorization(authorizer, nonce, signature), ERC3009InvalidSignature()); _consumed[authorizer][nonce] = true; emit AuthorizationCanceled(authorizer, nonce); } + + /// @dev Validates the transfer with authorization signature. + function _validateTransferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + bytes memory signature + ) internal virtual returns (bool) { + bytes32 hash = _hashTypedDataV4( + keccak256(abi.encode(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce)) + ); + return SignatureChecker.isValidSignatureNow(from, hash, signature); + } + + /// @dev Validates the receive with authorization signature. + function _validateReceiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + bytes memory signature + ) internal virtual returns (bool) { + bytes32 hash = _hashTypedDataV4( + keccak256(abi.encode(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce)) + ); + return SignatureChecker.isValidSignatureNow(from, hash, signature); + } + + /// @dev Validates the cancel authorization signature. + function _validateCancelAuthorization( + address authorizer, + bytes32 nonce, + bytes memory signature + ) internal virtual returns (bool) { + bytes32 hash = _hashTypedDataV4(keccak256(abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce))); + return SignatureChecker.isValidSignatureNow(authorizer, hash, signature); + } } diff --git a/test/helpers/eip712-types.js b/test/helpers/eip712-types.js index fb6fe3aebaf..3f39d106308 100644 --- a/test/helpers/eip712-types.js +++ b/test/helpers/eip712-types.js @@ -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', diff --git a/test/token/ERC20/extensions/ERC20TransferAuthorization.test.js b/test/token/ERC20/extensions/ERC20TransferAuthorization.test.js new file mode 100644 index 00000000000..cda5d1ae5c0 --- /dev/null +++ b/test/token/ERC20/extensions/ERC20TransferAuthorization.test.js @@ -0,0 +1,841 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { + getDomain, + domainSeparator, + TransferWithAuthorization, + ReceiveWithAuthorization, + CancelAuthorization, +} = require('../../../helpers/eip712'); +const time = require('../../../helpers/time'); + +const name = 'My Token'; +const symbol = 'MTKN'; +const initialSupply = 100n; + +async function fixture() { + const [holder, recipient, other] = await ethers.getSigners(); + + const token = await ethers.deployContract('$ERC20TransferAuthorization', [name, symbol, name]); + await token.$_mint(holder, initialSupply); + + const wallet = await ethers.deployContract('ERC1271WalletMock', [holder]); + await token.$_mint(wallet, initialSupply); + + return { + holder, + recipient, + other, + token, + wallet, + }; +} + +describe('ERC20TransferAuthorization', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + it('domain separator', async function () { + await expect(this.token.DOMAIN_SEPARATOR()).to.eventually.equal(await getDomain(this.token).then(domainSeparator)); + }); + + describe('authorizationState', function () { + it('returns false for unused nonce', async function () { + const nonce = ethers.hexlify(ethers.randomBytes(32)); + await expect(this.token.authorizationState(this.holder, nonce)).to.eventually.be.false; + }); + }); + + describe('transferWithAuthorization', function () { + const value = 42n; + + beforeEach(async function () { + this.nonce = ethers.hexlify(ethers.randomBytes(32)); + this.validAfter = 0n; + this.validBefore = ethers.MaxUint256; + + this.buildData = (contract, from, to, validBefore = this.validBefore, nonce = this.nonce) => + getDomain(contract).then(domain => ({ + domain, + types: { TransferWithAuthorization }, + message: { + from: from.address, + to: to.address, + value, + validAfter: this.validAfter, + validBefore, + nonce, + }, + })); + }); + + it('accepts holder signature', async function () { + const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await expect( + this.token.transferWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + v, + r, + s, + ), + ) + .to.emit(this.token, 'AuthorizationUsed') + .withArgs(this.holder.address, this.nonce) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder.address, this.recipient.address, value); + + await expect(this.token.balanceOf(this.holder)).to.eventually.equal(initialSupply - value); + await expect(this.token.balanceOf(this.recipient)).to.eventually.equal(value); + await expect(this.token.authorizationState(this.holder, this.nonce)).to.eventually.be.true; + }); + + it('rejects reused signature', async function () { + const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await this.token.transferWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + v, + r, + s, + ); + + await expect( + this.token.transferWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + v, + r, + s, + ), + ) + .to.be.revertedWithCustomError(this.token, 'ERC3009ConsumedAuthorization') + .withArgs(this.holder.address, this.nonce); + }); + + it('rejects other signature', async function () { + const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) + .then(({ domain, types, message }) => this.other.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await expect( + this.token.transferWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + v, + r, + s, + ), + ).to.be.revertedWithCustomError(this.token, 'ERC3009InvalidSignature'); + }); + + it('rejects authorization not yet valid', async function () { + const validAfter = (await time.clock.timestamp()) + time.duration.weeks(1); + + const { v, r, s } = await getDomain(this.token) + .then(domain => + this.holder.signTypedData( + domain, + { TransferWithAuthorization }, + { + from: this.holder.address, + to: this.recipient.address, + value, + validAfter, + validBefore: this.validBefore, + nonce: this.nonce, + }, + ), + ) + .then(ethers.Signature.from); + + await expect( + this.token.transferWithAuthorization( + this.holder, + this.recipient, + value, + validAfter, + this.validBefore, + this.nonce, + v, + r, + s, + ), + ) + .to.be.revertedWithCustomError(this.token, 'ERC3009InvalidAuthorizationTime') + .withArgs(validAfter, this.validBefore); + }); + + it('rejects expired authorization', async function () { + const validBefore = (await time.clock.timestamp()) - time.duration.weeks(1); + + const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient, validBefore) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await expect( + this.token.transferWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + validBefore, + this.nonce, + v, + r, + s, + ), + ) + .to.be.revertedWithCustomError(this.token, 'ERC3009InvalidAuthorizationTime') + .withArgs(this.validAfter, validBefore); + }); + + it('works with different nonces in parallel', async function () { + const nonce1 = ethers.hexlify(ethers.randomBytes(32)); + const nonce2 = ethers.hexlify(ethers.randomBytes(32)); + + const { + v: v1, + r: r1, + s: s1, + } = await this.buildData(this.token, this.holder, this.recipient, this.validBefore, nonce1) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + const { + v: v2, + r: r2, + s: s2, + } = await this.buildData(this.token, this.holder, this.recipient, this.validBefore, nonce2) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + // Submit in reverse order to show non-sequential nonces work + await this.token.transferWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + nonce2, + v2, + r2, + s2, + ); + await this.token.transferWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + nonce1, + v1, + r1, + s1, + ); + + await expect(this.token.authorizationState(this.holder, nonce1)).to.eventually.be.true; + await expect(this.token.authorizationState(this.holder, nonce2)).to.eventually.be.true; + }); + + describe('with bytes signature', function () { + it('accepts holder signature', async function () { + const signature = await this.buildData(this.token, this.holder, this.recipient).then( + ({ domain, types, message }) => this.holder.signTypedData(domain, types, message), + ); + + await expect( + this.token.getFunction('transferWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)')( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + signature, + ), + ) + .to.emit(this.token, 'AuthorizationUsed') + .withArgs(this.holder.address, this.nonce) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder.address, this.recipient.address, value); + + await expect(this.token.balanceOf(this.holder)).to.eventually.equal(initialSupply - value); + await expect(this.token.balanceOf(this.recipient)).to.eventually.equal(value); + await expect(this.token.authorizationState(this.holder, this.nonce)).to.eventually.be.true; + }); + + it('accepts ERC1271 wallet signature', async function () { + const signature = await getDomain(this.token).then(domain => + this.holder.signTypedData( + domain, + { TransferWithAuthorization }, + { + from: this.wallet.target, + to: this.recipient.address, + value, + validAfter: this.validAfter, + validBefore: this.validBefore, + nonce: this.nonce, + }, + ), + ); + + await expect( + this.token.getFunction('transferWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)')( + this.wallet, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + signature, + ), + ) + .to.emit(this.token, 'AuthorizationUsed') + .withArgs(this.wallet.target, this.nonce) + .to.emit(this.token, 'Transfer') + .withArgs(this.wallet.target, this.recipient.address, value); + + await expect(this.token.balanceOf(this.wallet)).to.eventually.equal(initialSupply - value); + await expect(this.token.balanceOf(this.recipient)).to.eventually.equal(value); + await expect(this.token.authorizationState(this.wallet, this.nonce)).to.eventually.be.true; + }); + + it('rejects invalid ERC1271 signature', async function () { + const signature = await getDomain(this.token).then(domain => + this.other.signTypedData( + domain, + { TransferWithAuthorization }, + { + from: this.wallet.target, + to: this.recipient.address, + value, + validAfter: this.validAfter, + validBefore: this.validBefore, + nonce: this.nonce, + }, + ), + ); + + await expect( + this.token.getFunction('transferWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)')( + this.wallet, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + signature, + ), + ).to.be.revertedWithCustomError(this.token, 'ERC3009InvalidSignature'); + }); + }); + }); + + describe('receiveWithAuthorization', function () { + const value = 42n; + + beforeEach(async function () { + this.nonce = ethers.hexlify(ethers.randomBytes(32)); + this.validAfter = 0n; + this.validBefore = ethers.MaxUint256; + + this.buildData = (contract, from, to, validBefore = this.validBefore, nonce = this.nonce) => + getDomain(contract).then(domain => ({ + domain, + types: { ReceiveWithAuthorization }, + message: { + from: from.address, + to: to.address, + value, + validAfter: this.validAfter, + validBefore, + nonce, + }, + })); + }); + + it('accepts holder signature when called by recipient', async function () { + const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await expect( + this.token + .connect(this.recipient) + .receiveWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + v, + r, + s, + ), + ) + .to.emit(this.token, 'AuthorizationUsed') + .withArgs(this.holder.address, this.nonce) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder.address, this.recipient.address, value); + + await expect(this.token.balanceOf(this.holder)).to.eventually.equal(initialSupply - value); + await expect(this.token.balanceOf(this.recipient)).to.eventually.equal(value); + await expect(this.token.authorizationState(this.holder, this.nonce)).to.eventually.be.true; + }); + + it('rejects when caller is not the recipient', async function () { + const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await expect( + this.token + .connect(this.other) + .receiveWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + v, + r, + s, + ), + ) + .to.be.revertedWithCustomError(this.token, 'ERC20InvalidReceiver') + .withArgs(this.recipient.address); + }); + + it('rejects reused signature', async function () { + const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await this.token + .connect(this.recipient) + .receiveWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + v, + r, + s, + ); + + await expect( + this.token + .connect(this.recipient) + .receiveWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + v, + r, + s, + ), + ) + .to.be.revertedWithCustomError(this.token, 'ERC3009ConsumedAuthorization') + .withArgs(this.holder.address, this.nonce); + }); + + it('rejects other signature', async function () { + const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) + .then(({ domain, types, message }) => this.other.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await expect( + this.token + .connect(this.recipient) + .receiveWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + v, + r, + s, + ), + ).to.be.revertedWithCustomError(this.token, 'ERC3009InvalidSignature'); + }); + + it('rejects authorization not yet valid', async function () { + const validAfter = (await time.clock.timestamp()) + time.duration.weeks(1); + + const { v, r, s } = await getDomain(this.token) + .then(domain => + this.holder.signTypedData( + domain, + { ReceiveWithAuthorization }, + { + from: this.holder.address, + to: this.recipient.address, + value, + validAfter, + validBefore: this.validBefore, + nonce: this.nonce, + }, + ), + ) + .then(ethers.Signature.from); + + await expect( + this.token + .connect(this.recipient) + .receiveWithAuthorization( + this.holder, + this.recipient, + value, + validAfter, + this.validBefore, + this.nonce, + v, + r, + s, + ), + ) + .to.be.revertedWithCustomError(this.token, 'ERC3009InvalidAuthorizationTime') + .withArgs(validAfter, this.validBefore); + }); + + it('rejects expired authorization', async function () { + const validBefore = (await time.clock.timestamp()) - time.duration.weeks(1); + + const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient, validBefore) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await expect( + this.token + .connect(this.recipient) + .receiveWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + validBefore, + this.nonce, + v, + r, + s, + ), + ) + .to.be.revertedWithCustomError(this.token, 'ERC3009InvalidAuthorizationTime') + .withArgs(this.validAfter, validBefore); + }); + + describe('with bytes signature', function () { + it('accepts holder signature when called by recipient', async function () { + const signature = await this.buildData(this.token, this.holder, this.recipient).then( + ({ domain, types, message }) => this.holder.signTypedData(domain, types, message), + ); + + await expect( + this.token + .connect(this.recipient) + .getFunction('receiveWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)')( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + signature, + ), + ) + .to.emit(this.token, 'AuthorizationUsed') + .withArgs(this.holder.address, this.nonce) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder.address, this.recipient.address, value); + + await expect(this.token.balanceOf(this.holder)).to.eventually.equal(initialSupply - value); + await expect(this.token.balanceOf(this.recipient)).to.eventually.equal(value); + await expect(this.token.authorizationState(this.holder, this.nonce)).to.eventually.be.true; + }); + + it('accepts ERC1271 wallet signature when called by recipient', async function () { + const signature = await getDomain(this.token).then(domain => + this.holder.signTypedData( + domain, + { ReceiveWithAuthorization }, + { + from: this.wallet.target, + to: this.recipient.address, + value, + validAfter: this.validAfter, + validBefore: this.validBefore, + nonce: this.nonce, + }, + ), + ); + + await expect( + this.token + .connect(this.recipient) + .getFunction('receiveWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)')( + this.wallet, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + signature, + ), + ) + .to.emit(this.token, 'AuthorizationUsed') + .withArgs(this.wallet.target, this.nonce) + .to.emit(this.token, 'Transfer') + .withArgs(this.wallet.target, this.recipient.address, value); + + await expect(this.token.balanceOf(this.wallet)).to.eventually.equal(initialSupply - value); + await expect(this.token.balanceOf(this.recipient)).to.eventually.equal(value); + await expect(this.token.authorizationState(this.wallet, this.nonce)).to.eventually.be.true; + }); + + it('rejects when caller is not the recipient', async function () { + const signature = await this.buildData(this.token, this.holder, this.recipient).then( + ({ domain, types, message }) => this.holder.signTypedData(domain, types, message), + ); + + await expect( + this.token + .connect(this.other) + .getFunction('receiveWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)')( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + signature, + ), + ) + .to.be.revertedWithCustomError(this.token, 'ERC20InvalidReceiver') + .withArgs(this.recipient.address); + }); + + it('rejects invalid ERC1271 signature', async function () { + const signature = await getDomain(this.token).then(domain => + this.other.signTypedData( + domain, + { ReceiveWithAuthorization }, + { + from: this.wallet.target, + to: this.recipient.address, + value, + validAfter: this.validAfter, + validBefore: this.validBefore, + nonce: this.nonce, + }, + ), + ); + + await expect( + this.token + .connect(this.recipient) + .getFunction('receiveWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)')( + this.wallet, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + signature, + ), + ).to.be.revertedWithCustomError(this.token, 'ERC3009InvalidSignature'); + }); + }); + }); + + describe('cancelAuthorization', function () { + beforeEach(async function () { + this.nonce = ethers.hexlify(ethers.randomBytes(32)); + + this.buildData = (contract, authorizer, nonce = this.nonce) => + getDomain(contract).then(domain => ({ + domain, + types: { CancelAuthorization }, + message: { + authorizer: authorizer.address, + nonce, + }, + })); + }); + + it('accepts authorizer signature', async function () { + const { v, r, s } = await this.buildData(this.token, this.holder) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await expect(this.token.cancelAuthorization(this.holder, this.nonce, v, r, s)) + .to.emit(this.token, 'AuthorizationCanceled') + .withArgs(this.holder.address, this.nonce); + + await expect(this.token.authorizationState(this.holder, this.nonce)).to.eventually.be.true; + }); + + it('rejects reused nonce', async function () { + const { v, r, s } = await this.buildData(this.token, this.holder) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await this.token.cancelAuthorization(this.holder, this.nonce, v, r, s); + + await expect(this.token.cancelAuthorization(this.holder, this.nonce, v, r, s)) + .to.be.revertedWithCustomError(this.token, 'ERC3009ConsumedAuthorization') + .withArgs(this.holder.address, this.nonce); + }); + + it('rejects other signature', async function () { + const { v, r, s } = await this.buildData(this.token, this.holder) + .then(({ domain, types, message }) => this.other.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await expect(this.token.cancelAuthorization(this.holder, this.nonce, v, r, s)).to.be.revertedWithCustomError( + this.token, + 'ERC3009InvalidSignature', + ); + }); + + it('prevents usage of canceled authorization in transferWithAuthorization', async function () { + const value = 42n; + const validAfter = 0n; + const validBefore = ethers.MaxUint256; + + // Cancel the authorization + const { + v: vCancel, + r: rCancel, + s: sCancel, + } = await this.buildData(this.token, this.holder) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await this.token.cancelAuthorization(this.holder, this.nonce, vCancel, rCancel, sCancel); + + // Try to use the same nonce for transfer + const { v, r, s } = await getDomain(this.token) + .then(domain => + this.holder.signTypedData( + domain, + { TransferWithAuthorization }, + { + from: this.holder.address, + to: this.recipient.address, + value, + validAfter, + validBefore, + nonce: this.nonce, + }, + ), + ) + .then(ethers.Signature.from); + + await expect( + this.token.transferWithAuthorization( + this.holder, + this.recipient, + value, + validAfter, + validBefore, + this.nonce, + v, + r, + s, + ), + ) + .to.be.revertedWithCustomError(this.token, 'ERC3009ConsumedAuthorization') + .withArgs(this.holder.address, this.nonce); + }); + + describe('with bytes signature', function () { + it('accepts authorizer signature', async function () { + const signature = await this.buildData(this.token, this.holder).then(({ domain, types, message }) => + this.holder.signTypedData(domain, types, message), + ); + + await expect( + this.token.getFunction('cancelAuthorization(address,bytes32,bytes)')(this.holder, this.nonce, signature), + ) + .to.emit(this.token, 'AuthorizationCanceled') + .withArgs(this.holder.address, this.nonce); + + await expect(this.token.authorizationState(this.holder, this.nonce)).to.eventually.be.true; + }); + + it('accepts ERC1271 wallet signature', async function () { + const signature = await getDomain(this.token).then(domain => + this.holder.signTypedData( + domain, + { CancelAuthorization }, + { + authorizer: this.wallet.target, + nonce: this.nonce, + }, + ), + ); + + await expect( + this.token.getFunction('cancelAuthorization(address,bytes32,bytes)')(this.wallet, this.nonce, signature), + ) + .to.emit(this.token, 'AuthorizationCanceled') + .withArgs(this.wallet.target, this.nonce); + + await expect(this.token.authorizationState(this.wallet, this.nonce)).to.eventually.be.true; + }); + + it('rejects invalid ERC1271 signature', async function () { + const signature = await getDomain(this.token).then(domain => + this.other.signTypedData( + domain, + { CancelAuthorization }, + { + authorizer: this.wallet.target, + nonce: this.nonce, + }, + ), + ); + + await expect( + this.token.getFunction('cancelAuthorization(address,bytes32,bytes)')(this.wallet, this.nonce, signature), + ).to.be.revertedWithCustomError(this.token, 'ERC3009InvalidSignature'); + }); + }); + }); +}); From 7ed2271a2f59bf600abf504c61e3a64a92610607 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 18 Feb 2026 09:48:06 -0600 Subject: [PATCH 03/10] Inline _validate functions --- .../extensions/ERC20TransferAuthorization.sol | 57 +++---------------- 1 file changed, 8 insertions(+), 49 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol b/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol index fef98f2285f..a456a0f6e67 100644 --- a/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol +++ b/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol @@ -160,10 +160,10 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 ERC3009InvalidAuthorizationTime(validAfter, validBefore) ); require(!authorizationState(from, nonce), ERC3009ConsumedAuthorization(from, nonce)); - require( - _validateTransferWithAuthorization(from, to, value, validAfter, validBefore, nonce, signature), - ERC3009InvalidSignature() + bytes32 hash = _hashTypedDataV4( + keccak256(abi.encode(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce)) ); + require(SignatureChecker.isValidSignatureNow(from, hash, signature), ERC3009InvalidSignature()); _consumed[from][nonce] = true; emit AuthorizationUsed(from, nonce); @@ -206,10 +206,10 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 ERC3009InvalidAuthorizationTime(validAfter, validBefore) ); require(!authorizationState(from, nonce), ERC3009ConsumedAuthorization(from, nonce)); - require( - _validateReceiveWithAuthorization(from, to, value, validAfter, validBefore, nonce, signature), - ERC3009InvalidSignature() + bytes32 hash = _hashTypedDataV4( + keccak256(abi.encode(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce)) ); + require(SignatureChecker.isValidSignatureNow(from, hash, signature), ERC3009InvalidSignature()); _consumed[from][nonce] = true; emit AuthorizationUsed(from, nonce); @@ -224,51 +224,10 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 /// @dev Internal version of {cancelAuthorization} that accepts a bytes signature. function _cancelAuthorization(address authorizer, bytes32 nonce, bytes memory signature) internal virtual { require(!authorizationState(authorizer, nonce), ERC3009ConsumedAuthorization(authorizer, nonce)); - require(_validateCancelAuthorization(authorizer, nonce, signature), ERC3009InvalidSignature()); + bytes32 hash = _hashTypedDataV4(keccak256(abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce))); + require(SignatureChecker.isValidSignatureNow(authorizer, hash, signature), ERC3009InvalidSignature()); _consumed[authorizer][nonce] = true; emit AuthorizationCanceled(authorizer, nonce); } - - /// @dev Validates the transfer with authorization signature. - function _validateTransferWithAuthorization( - address from, - address to, - uint256 value, - uint256 validAfter, - uint256 validBefore, - bytes32 nonce, - bytes memory signature - ) internal virtual returns (bool) { - bytes32 hash = _hashTypedDataV4( - keccak256(abi.encode(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce)) - ); - return SignatureChecker.isValidSignatureNow(from, hash, signature); - } - - /// @dev Validates the receive with authorization signature. - function _validateReceiveWithAuthorization( - address from, - address to, - uint256 value, - uint256 validAfter, - uint256 validBefore, - bytes32 nonce, - bytes memory signature - ) internal virtual returns (bool) { - bytes32 hash = _hashTypedDataV4( - keccak256(abi.encode(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce)) - ); - return SignatureChecker.isValidSignatureNow(from, hash, signature); - } - - /// @dev Validates the cancel authorization signature. - function _validateCancelAuthorization( - address authorizer, - bytes32 nonce, - bytes memory signature - ) internal virtual returns (bool) { - bytes32 hash = _hashTypedDataV4(keccak256(abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce))); - return SignatureChecker.isValidSignatureNow(authorizer, hash, signature); - } } From 3f448e65e61cd0dca1f47e7287a9a527c8a10ad0 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 18 Feb 2026 09:57:15 -0600 Subject: [PATCH 04/10] up --- .../extensions/ERC20TransferAuthorization.sol | 64 ++++--------------- 1 file changed, 12 insertions(+), 52 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol b/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol index a456a0f6e67..1584d492277 100644 --- a/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol +++ b/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol @@ -50,6 +50,15 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 */ constructor(string memory name) EIP712(name, "1") {} + /** + * @dev Returns the domain separator used in the encoding of the signature for + * {transferWithAuthorization} and {receiveWithAuthorization}, as defined by {EIP712}. + */ + // solhint-disable-next-line func-name-mixedcase + function DOMAIN_SEPARATOR() external view returns (bytes32) { + return _domainSeparatorV4(); + } + /// @inheritdoc IERC3009 function authorizationState(address authorizer, bytes32 nonce) public view virtual returns (bool) { return _consumed[authorizer][nonce]; @@ -67,7 +76,7 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 bytes32 r, bytes32 s ) public virtual { - _transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, v, r, s); + _transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, abi.encodePacked(r, s, v)); } /// @dev Same as {transferWithAuthorization} but with a bytes signature. @@ -95,7 +104,7 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 bytes32 r, bytes32 s ) public virtual { - _receiveWithAuthorization(from, to, value, validAfter, validBefore, nonce, v, r, s); + _receiveWithAuthorization(from, to, value, validAfter, validBefore, nonce, abi.encodePacked(r, s, v)); } /// @dev Same as {receiveWithAuthorization} but with a bytes signature. @@ -113,7 +122,7 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 /// @inheritdoc IERC3009Cancel function cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) public virtual { - _cancelAuthorization(authorizer, nonce, v, r, s); + _cancelAuthorization(authorizer, nonce, abi.encodePacked(r, s, v)); } /// @dev Same as {cancelAuthorization} but with a bytes signature. @@ -121,30 +130,6 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 _cancelAuthorization(authorizer, nonce, signature); } - /** - * @dev Returns the domain separator used in the encoding of the signature for - * {transferWithAuthorization} and {receiveWithAuthorization}, as defined by {EIP712}. - */ - // solhint-disable-next-line func-name-mixedcase - function DOMAIN_SEPARATOR() external view returns (bytes32) { - return _domainSeparatorV4(); - } - - /// @dev Internal version of {transferWithAuthorization}. - function _transferWithAuthorization( - address from, - address to, - uint256 value, - uint256 validAfter, - uint256 validBefore, - bytes32 nonce, - uint8 v, - bytes32 r, - bytes32 s - ) internal virtual { - _transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, abi.encodePacked(r, s, v)); - } - /// @dev Internal version of {transferWithAuthorization} that accepts a bytes signature. function _transferWithAuthorization( address from, @@ -170,26 +155,6 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 _transfer(from, to, value); } - /** - * @dev Internal version of {receiveWithAuthorization}. - * - * Includes an additional check to ensure that the payee's address matches the caller to prevent - * front-running attacks. - */ - function _receiveWithAuthorization( - address from, - address to, - uint256 value, - uint256 validAfter, - uint256 validBefore, - bytes32 nonce, - uint8 v, - bytes32 r, - bytes32 s - ) internal virtual { - _receiveWithAuthorization(from, to, value, validAfter, validBefore, nonce, abi.encodePacked(r, s, v)); - } - /// @dev Internal version of {receiveWithAuthorization} that accepts a bytes signature. function _receiveWithAuthorization( address from, @@ -216,11 +181,6 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 _transfer(from, to, value); } - /// @dev Internal version of {cancelAuthorization}. - function _cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) internal virtual { - _cancelAuthorization(authorizer, nonce, abi.encodePacked(r, s, v)); - } - /// @dev Internal version of {cancelAuthorization} that accepts a bytes signature. function _cancelAuthorization(address authorizer, bytes32 nonce, bytes memory signature) internal virtual { require(!authorizationState(authorizer, nonce), ERC3009ConsumedAuthorization(authorizer, nonce)); From c83f9721a1d0f4d69c1a6b460745151b6bc6beab Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 18 Feb 2026 10:06:09 -0600 Subject: [PATCH 05/10] Skip SignatureChecker for r,s,v functions --- .../extensions/ERC20TransferAuthorization.sol | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol b/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol index 1584d492277..a45a1e2aab1 100644 --- a/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol +++ b/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.20; 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 {MessageHashUtils} from "../../../utils/cryptography/MessageHashUtils.sol"; import {IERC3009, IERC3009Cancel} from "../../../interfaces/draft-IERC3009.sol"; @@ -76,7 +77,12 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 bytes32 r, bytes32 s ) public virtual { - _transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, abi.encodePacked(r, s, v)); + 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()); + _transferWithAuthorization(from, to, value, validAfter, validBefore, nonce); } /// @dev Same as {transferWithAuthorization} but with a bytes signature. @@ -89,7 +95,11 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 bytes32 nonce, bytes memory signature ) public virtual { - _transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, signature); + 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); } /// @inheritdoc IERC3009 @@ -104,7 +114,12 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 bytes32 r, bytes32 s ) public virtual { - _receiveWithAuthorization(from, to, value, validAfter, validBefore, nonce, abi.encodePacked(r, s, v)); + 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()); + _receiveWithAuthorization(from, to, value, validAfter, validBefore, nonce); } /// @dev Same as {receiveWithAuthorization} but with a bytes signature. @@ -117,17 +132,26 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 bytes32 nonce, bytes memory signature ) public virtual { - _receiveWithAuthorization(from, to, value, validAfter, validBefore, nonce, signature); + 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); } /// @inheritdoc IERC3009Cancel function cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) public virtual { - _cancelAuthorization(authorizer, nonce, abi.encodePacked(r, s, v)); + 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()); + _cancelAuthorization(authorizer, nonce); } /// @dev Same as {cancelAuthorization} but with a bytes signature. function cancelAuthorization(address authorizer, bytes32 nonce, bytes memory signature) public virtual { - _cancelAuthorization(authorizer, nonce, signature); + 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. @@ -137,19 +161,13 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 uint256 value, uint256 validAfter, uint256 validBefore, - bytes32 nonce, - bytes memory signature + bytes32 nonce ) internal virtual { require( block.timestamp > validAfter && block.timestamp < validBefore, ERC3009InvalidAuthorizationTime(validAfter, validBefore) ); require(!authorizationState(from, nonce), ERC3009ConsumedAuthorization(from, nonce)); - bytes32 hash = _hashTypedDataV4( - keccak256(abi.encode(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce)) - ); - require(SignatureChecker.isValidSignatureNow(from, hash, signature), ERC3009InvalidSignature()); - _consumed[from][nonce] = true; emit AuthorizationUsed(from, nonce); _transfer(from, to, value); @@ -162,8 +180,7 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 uint256 value, uint256 validAfter, uint256 validBefore, - bytes32 nonce, - bytes memory signature + bytes32 nonce ) internal virtual { require(to == _msgSender(), ERC20InvalidReceiver(to)); require( @@ -171,22 +188,14 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 ERC3009InvalidAuthorizationTime(validAfter, validBefore) ); require(!authorizationState(from, nonce), ERC3009ConsumedAuthorization(from, nonce)); - bytes32 hash = _hashTypedDataV4( - keccak256(abi.encode(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce)) - ); - require(SignatureChecker.isValidSignatureNow(from, hash, signature), ERC3009InvalidSignature()); - _consumed[from][nonce] = true; emit AuthorizationUsed(from, nonce); _transfer(from, to, value); } /// @dev Internal version of {cancelAuthorization} that accepts a bytes signature. - function _cancelAuthorization(address authorizer, bytes32 nonce, bytes memory signature) internal virtual { + function _cancelAuthorization(address authorizer, bytes32 nonce) internal virtual { require(!authorizationState(authorizer, nonce), ERC3009ConsumedAuthorization(authorizer, nonce)); - bytes32 hash = _hashTypedDataV4(keccak256(abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce))); - require(SignatureChecker.isValidSignatureNow(authorizer, hash, signature), ERC3009InvalidSignature()); - _consumed[authorizer][nonce] = true; emit AuthorizationCanceled(authorizer, nonce); } From 41131a16920b0ead61f1d528f5e0bd4043bd1fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Wed, 18 Feb 2026 10:07:32 -0600 Subject: [PATCH 06/10] Update contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol --- contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol b/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol index a45a1e2aab1..d8469caae52 100644 --- a/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol +++ b/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; +pragma solidity ^0.8.26; import {ERC20} from "../ERC20.sol"; import {EIP712} from "../../../utils/cryptography/EIP712.sol"; From 7a72a6b58d95509f82bbcc687f6976f9fd18995b Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 25 Feb 2026 12:06:51 -0600 Subject: [PATCH 07/10] Add NoncesKeyed --- .../extensions/ERC20TransferAuthorization.sol | 64 +++++++++++-------- .../ERC20TransferAuthorization.test.js | 44 +++++++------ 2 files changed, 60 insertions(+), 48 deletions(-) diff --git a/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol b/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol index d8469caae52..909b741f163 100644 --- a/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol +++ b/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol @@ -5,8 +5,8 @@ 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 {MessageHashUtils} from "../../../utils/cryptography/MessageHashUtils.sol"; import {IERC3009, IERC3009Cancel} from "../../../interfaces/draft-IERC3009.sol"; +import {NoncesKeyed} from "../../../utils/NoncesKeyed.sol"; /** * @dev Implementation of the ERC-3009 Transfer With Authorization extension allowing @@ -18,16 +18,17 @@ import {IERC3009, IERC3009Cancel} from "../../../interfaces/draft-IERC3009.sol"; * 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 non-sequential nonces to allow for flexible transaction ordering - * and parallel transaction submission, unlike {ERC20Permit} which uses sequential nonces. + * 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, IERC3009, IERC3009Cancel { +abstract contract ERC20TransferAuthorization is ERC20, EIP712, NoncesKeyed, IERC3009, IERC3009Cancel { /// @dev The signature is invalid error ERC3009InvalidSignature(); - /// @dev The authorization is already used or canceled - error ERC3009ConsumedAuthorization(address authorizer, bytes32 nonce); - /// @dev The authorization is not valid at the given time error ERC3009InvalidAuthorizationTime(uint256 validAfter, uint256 validBefore); @@ -42,8 +43,6 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 bytes32 private constant CANCEL_AUTHORIZATION_TYPEHASH = keccak256("CancelAuthorization(address authorizer,bytes32 nonce)"); - mapping(address => mapping(bytes32 => bool)) private _consumed; - /** * @dev Initializes the {EIP712} domain separator using the `name` parameter, and setting `version` to `"1"`. * @@ -52,20 +51,22 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 constructor(string memory name) EIP712(name, "1") {} /** - * @dev Returns the domain separator used in the encoding of the signature for - * {transferWithAuthorization} and {receiveWithAuthorization}, as defined by {EIP712}. + * @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. */ - // solhint-disable-next-line func-name-mixedcase - function DOMAIN_SEPARATOR() external view returns (bytes32) { - return _domainSeparatorV4(); - } - - /// @inheritdoc IERC3009 function authorizationState(address authorizer, bytes32 nonce) public view virtual returns (bool) { - return _consumed[authorizer][nonce]; + return uint64(nonces(authorizer, uint192(uint256(nonce) >> 64))) > uint64(uint256(nonce)); } - /// @inheritdoc IERC3009 + /** + * @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, @@ -102,7 +103,12 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 _transferWithAuthorization(from, to, value, validAfter, validBefore, nonce); } - /// @inheritdoc IERC3009 + /** + * @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, @@ -139,7 +145,14 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 _receiveWithAuthorization(from, to, value, validAfter, validBefore, nonce); } - /// @inheritdoc IERC3009Cancel + /** + * @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); @@ -167,8 +180,7 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 block.timestamp > validAfter && block.timestamp < validBefore, ERC3009InvalidAuthorizationTime(validAfter, validBefore) ); - require(!authorizationState(from, nonce), ERC3009ConsumedAuthorization(from, nonce)); - _consumed[from][nonce] = true; + _useCheckedNonce(from, uint256(nonce)); emit AuthorizationUsed(from, nonce); _transfer(from, to, value); } @@ -187,16 +199,14 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, IERC3009, IERC300 block.timestamp > validAfter && block.timestamp < validBefore, ERC3009InvalidAuthorizationTime(validAfter, validBefore) ); - require(!authorizationState(from, nonce), ERC3009ConsumedAuthorization(from, nonce)); - _consumed[from][nonce] = true; + _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 { - require(!authorizationState(authorizer, nonce), ERC3009ConsumedAuthorization(authorizer, nonce)); - _consumed[authorizer][nonce] = true; + _useCheckedNonce(authorizer, uint256(nonce)); emit AuthorizationCanceled(authorizer, nonce); } } diff --git a/test/token/ERC20/extensions/ERC20TransferAuthorization.test.js b/test/token/ERC20/extensions/ERC20TransferAuthorization.test.js index cda5d1ae5c0..4117da02cba 100644 --- a/test/token/ERC20/extensions/ERC20TransferAuthorization.test.js +++ b/test/token/ERC20/extensions/ERC20TransferAuthorization.test.js @@ -4,7 +4,6 @@ const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const { getDomain, - domainSeparator, TransferWithAuthorization, ReceiveWithAuthorization, CancelAuthorization, @@ -15,6 +14,8 @@ const name = 'My Token'; const symbol = 'MTKN'; const initialSupply = 100n; +const packNonce = (key, seq = 0n) => ethers.toBeHex((BigInt(key) << 64n) | BigInt(seq), 32); + async function fixture() { const [holder, recipient, other] = await ethers.getSigners(); @@ -38,13 +39,9 @@ describe('ERC20TransferAuthorization', function () { Object.assign(this, await loadFixture(fixture)); }); - it('domain separator', async function () { - await expect(this.token.DOMAIN_SEPARATOR()).to.eventually.equal(await getDomain(this.token).then(domainSeparator)); - }); - describe('authorizationState', function () { it('returns false for unused nonce', async function () { - const nonce = ethers.hexlify(ethers.randomBytes(32)); + const nonce = packNonce(ethers.toBigInt(ethers.randomBytes(24)), 0n); await expect(this.token.authorizationState(this.holder, nonce)).to.eventually.be.false; }); }); @@ -53,7 +50,8 @@ describe('ERC20TransferAuthorization', function () { const value = 42n; beforeEach(async function () { - this.nonce = ethers.hexlify(ethers.randomBytes(32)); + this.key = ethers.toBigInt(ethers.randomBytes(24)); + this.nonce = packNonce(this.key, 0n); this.validAfter = 0n; this.validBefore = ethers.MaxUint256; @@ -130,8 +128,8 @@ describe('ERC20TransferAuthorization', function () { s, ), ) - .to.be.revertedWithCustomError(this.token, 'ERC3009ConsumedAuthorization') - .withArgs(this.holder.address, this.nonce); + .to.be.revertedWithCustomError(this.token, 'InvalidAccountNonce') + .withArgs(this.holder.address, packNonce(this.key, 1n)); }); it('rejects other signature', async function () { @@ -215,9 +213,11 @@ describe('ERC20TransferAuthorization', function () { .withArgs(this.validAfter, validBefore); }); - it('works with different nonces in parallel', async function () { - const nonce1 = ethers.hexlify(ethers.randomBytes(32)); - const nonce2 = ethers.hexlify(ethers.randomBytes(32)); + it('works with different keys in parallel', async function () { + const key1 = ethers.toBigInt(ethers.randomBytes(24)); + const key2 = ethers.toBigInt(ethers.randomBytes(24)); + const nonce1 = packNonce(key1, 0n); + const nonce2 = packNonce(key2, 0n); const { v: v1, @@ -235,7 +235,7 @@ describe('ERC20TransferAuthorization', function () { .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) .then(ethers.Signature.from); - // Submit in reverse order to show non-sequential nonces work + // Submit in any order to show that different keys are independent await this.token.transferWithAuthorization( this.holder, this.recipient, @@ -362,7 +362,8 @@ describe('ERC20TransferAuthorization', function () { const value = 42n; beforeEach(async function () { - this.nonce = ethers.hexlify(ethers.randomBytes(32)); + this.key = ethers.toBigInt(ethers.randomBytes(24)); + this.nonce = packNonce(this.key, 0n); this.validAfter = 0n; this.validBefore = ethers.MaxUint256; @@ -469,8 +470,8 @@ describe('ERC20TransferAuthorization', function () { s, ), ) - .to.be.revertedWithCustomError(this.token, 'ERC3009ConsumedAuthorization') - .withArgs(this.holder.address, this.nonce); + .to.be.revertedWithCustomError(this.token, 'InvalidAccountNonce') + .withArgs(this.holder.address, packNonce(this.key, 1n)); }); it('rejects other signature', async function () { @@ -685,7 +686,8 @@ describe('ERC20TransferAuthorization', function () { describe('cancelAuthorization', function () { beforeEach(async function () { - this.nonce = ethers.hexlify(ethers.randomBytes(32)); + this.key = ethers.toBigInt(ethers.randomBytes(24)); + this.nonce = packNonce(this.key, 0n); this.buildData = (contract, authorizer, nonce = this.nonce) => getDomain(contract).then(domain => ({ @@ -718,8 +720,8 @@ describe('ERC20TransferAuthorization', function () { await this.token.cancelAuthorization(this.holder, this.nonce, v, r, s); await expect(this.token.cancelAuthorization(this.holder, this.nonce, v, r, s)) - .to.be.revertedWithCustomError(this.token, 'ERC3009ConsumedAuthorization') - .withArgs(this.holder.address, this.nonce); + .to.be.revertedWithCustomError(this.token, 'InvalidAccountNonce') + .withArgs(this.holder.address, packNonce(this.key, 1n)); }); it('rejects other signature', async function () { @@ -780,8 +782,8 @@ describe('ERC20TransferAuthorization', function () { s, ), ) - .to.be.revertedWithCustomError(this.token, 'ERC3009ConsumedAuthorization') - .withArgs(this.holder.address, this.nonce); + .to.be.revertedWithCustomError(this.token, 'InvalidAccountNonce') + .withArgs(this.holder.address, packNonce(this.key, 1n)); }); describe('with bytes signature', function () { From 6931616c00ba4b7f470584915952f74d2c977b1e Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 25 Feb 2026 12:15:45 -0600 Subject: [PATCH 08/10] Add changeset --- .changeset/metal-chicken-dress.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/metal-chicken-dress.md diff --git a/.changeset/metal-chicken-dress.md b/.changeset/metal-chicken-dress.md new file mode 100644 index 00000000000..2e7c2f152dd --- /dev/null +++ b/.changeset/metal-chicken-dress.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`ERC20TransferAuthorization`: Add an ERC-20 extension implementing ERC-3009's transfer with authorization using {NoncesKeyed} for parallel nonces. From f3f1ffbd7e47bfa81c3f37c865d43462c78521db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Wed, 25 Feb 2026 12:16:40 -0600 Subject: [PATCH 09/10] Update .changeset/metal-chicken-dress.md --- .changeset/metal-chicken-dress.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/metal-chicken-dress.md b/.changeset/metal-chicken-dress.md index 2e7c2f152dd..c94cabbc963 100644 --- a/.changeset/metal-chicken-dress.md +++ b/.changeset/metal-chicken-dress.md @@ -2,4 +2,4 @@ 'openzeppelin-solidity': minor --- -`ERC20TransferAuthorization`: Add an ERC-20 extension implementing ERC-3009's transfer with authorization using {NoncesKeyed} for parallel nonces. +`ERC20TransferAuthorization`: Add an ERC-20 extension implementing ERC-3009's transfer with authorization using parallel nonces. From a3db8c77fb540b46046abf71865843d8a58b7a81 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 25 Feb 2026 19:50:41 -0600 Subject: [PATCH 10/10] Add clock --- ...20TransferAuthorizationBlockNumberMock.sol | 20 + .../extensions/ERC20TransferAuthorization.sol | 30 +- .../ERC20TransferAuthorization.test.js | 1563 ++++++++--------- 3 files changed, 824 insertions(+), 789 deletions(-) create mode 100644 contracts/mocks/token/ERC20TransferAuthorizationBlockNumberMock.sol diff --git a/contracts/mocks/token/ERC20TransferAuthorizationBlockNumberMock.sol b/contracts/mocks/token/ERC20TransferAuthorizationBlockNumberMock.sol new file mode 100644 index 00000000000..1e99bc1c9f9 --- /dev/null +++ b/contracts/mocks/token/ERC20TransferAuthorizationBlockNumberMock.sol @@ -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"; + } +} diff --git a/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol b/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol index 909b741f163..2fd7d7bc871 100644 --- a/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol +++ b/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol @@ -7,6 +7,8 @@ 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 @@ -25,13 +27,16 @@ import {NoncesKeyed} from "../../../utils/NoncesKeyed.sol"; * 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, IERC3009, IERC3009Cancel { +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)" @@ -50,6 +55,25 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, NoncesKeyed, IERC */ constructor(string memory name) EIP712(name, "1") {} + /** + * @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}. * @@ -177,7 +201,7 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, NoncesKeyed, IERC bytes32 nonce ) internal virtual { require( - block.timestamp > validAfter && block.timestamp < validBefore, + clock() > validAfter && clock() < validBefore, ERC3009InvalidAuthorizationTime(validAfter, validBefore) ); _useCheckedNonce(from, uint256(nonce)); @@ -196,7 +220,7 @@ abstract contract ERC20TransferAuthorization is ERC20, EIP712, NoncesKeyed, IERC ) internal virtual { require(to == _msgSender(), ERC20InvalidReceiver(to)); require( - block.timestamp > validAfter && block.timestamp < validBefore, + clock() > validAfter && clock() < validBefore, ERC3009InvalidAuthorizationTime(validAfter, validBefore) ); _useCheckedNonce(from, uint256(nonce)); diff --git a/test/token/ERC20/extensions/ERC20TransferAuthorization.test.js b/test/token/ERC20/extensions/ERC20TransferAuthorization.test.js index 4117da02cba..5b874e94ef4 100644 --- a/test/token/ERC20/extensions/ERC20TransferAuthorization.test.js +++ b/test/token/ERC20/extensions/ERC20TransferAuthorization.test.js @@ -10,417 +10,107 @@ const { } = require('../../../helpers/eip712'); const time = require('../../../helpers/time'); +const TOKENS = [ + { Token: '$ERC20TransferAuthorization', mode: 'timestamp' }, + { Token: '$ERC20TransferAuthorizationBlockNumberMock', mode: 'blocknumber' }, +]; + const name = 'My Token'; const symbol = 'MTKN'; const initialSupply = 100n; const packNonce = (key, seq = 0n) => ethers.toBeHex((BigInt(key) << 64n) | BigInt(seq), 32); -async function fixture() { - const [holder, recipient, other] = await ethers.getSigners(); - - const token = await ethers.deployContract('$ERC20TransferAuthorization', [name, symbol, name]); - await token.$_mint(holder, initialSupply); - - const wallet = await ethers.deployContract('ERC1271WalletMock', [holder]); - await token.$_mint(wallet, initialSupply); - - return { - holder, - recipient, - other, - token, - wallet, - }; -} - describe('ERC20TransferAuthorization', function () { - beforeEach(async function () { - Object.assign(this, await loadFixture(fixture)); - }); - - describe('authorizationState', function () { - it('returns false for unused nonce', async function () { - const nonce = packNonce(ethers.toBigInt(ethers.randomBytes(24)), 0n); - await expect(this.token.authorizationState(this.holder, nonce)).to.eventually.be.false; - }); - }); - - describe('transferWithAuthorization', function () { - const value = 42n; - - beforeEach(async function () { - this.key = ethers.toBigInt(ethers.randomBytes(24)); - this.nonce = packNonce(this.key, 0n); - this.validAfter = 0n; - this.validBefore = ethers.MaxUint256; - - this.buildData = (contract, from, to, validBefore = this.validBefore, nonce = this.nonce) => - getDomain(contract).then(domain => ({ - domain, - types: { TransferWithAuthorization }, - message: { - from: from.address, - to: to.address, - value, - validAfter: this.validAfter, - validBefore, - nonce, - }, - })); - }); - - it('accepts holder signature', async function () { - const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) - .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) - .then(ethers.Signature.from); - - await expect( - this.token.transferWithAuthorization( - this.holder, - this.recipient, - value, - this.validAfter, - this.validBefore, - this.nonce, - v, - r, - s, - ), - ) - .to.emit(this.token, 'AuthorizationUsed') - .withArgs(this.holder.address, this.nonce) - .to.emit(this.token, 'Transfer') - .withArgs(this.holder.address, this.recipient.address, value); - - await expect(this.token.balanceOf(this.holder)).to.eventually.equal(initialSupply - value); - await expect(this.token.balanceOf(this.recipient)).to.eventually.equal(value); - await expect(this.token.authorizationState(this.holder, this.nonce)).to.eventually.be.true; - }); - - it('rejects reused signature', async function () { - const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) - .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) - .then(ethers.Signature.from); - - await this.token.transferWithAuthorization( - this.holder, - this.recipient, - value, - this.validAfter, - this.validBefore, - this.nonce, - v, - r, - s, - ); - - await expect( - this.token.transferWithAuthorization( - this.holder, - this.recipient, - value, - this.validAfter, - this.validBefore, - this.nonce, - v, - r, - s, - ), - ) - .to.be.revertedWithCustomError(this.token, 'InvalidAccountNonce') - .withArgs(this.holder.address, packNonce(this.key, 1n)); - }); - - it('rejects other signature', async function () { - const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) - .then(({ domain, types, message }) => this.other.signTypedData(domain, types, message)) - .then(ethers.Signature.from); - - await expect( - this.token.transferWithAuthorization( - this.holder, - this.recipient, - value, - this.validAfter, - this.validBefore, - this.nonce, - v, - r, - s, - ), - ).to.be.revertedWithCustomError(this.token, 'ERC3009InvalidSignature'); - }); - - it('rejects authorization not yet valid', async function () { - const validAfter = (await time.clock.timestamp()) + time.duration.weeks(1); - - const { v, r, s } = await getDomain(this.token) - .then(domain => - this.holder.signTypedData( - domain, - { TransferWithAuthorization }, - { - from: this.holder.address, - to: this.recipient.address, - value, - validAfter, - validBefore: this.validBefore, - nonce: this.nonce, - }, - ), - ) - .then(ethers.Signature.from); - - await expect( - this.token.transferWithAuthorization( - this.holder, - this.recipient, - value, - validAfter, - this.validBefore, - this.nonce, - v, - r, - s, - ), - ) - .to.be.revertedWithCustomError(this.token, 'ERC3009InvalidAuthorizationTime') - .withArgs(validAfter, this.validBefore); - }); - - it('rejects expired authorization', async function () { - const validBefore = (await time.clock.timestamp()) - time.duration.weeks(1); - - const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient, validBefore) - .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) - .then(ethers.Signature.from); - - await expect( - this.token.transferWithAuthorization( - this.holder, - this.recipient, - value, - this.validAfter, - validBefore, - this.nonce, - v, - r, - s, - ), - ) - .to.be.revertedWithCustomError(this.token, 'ERC3009InvalidAuthorizationTime') - .withArgs(this.validAfter, validBefore); - }); - - it('works with different keys in parallel', async function () { - const key1 = ethers.toBigInt(ethers.randomBytes(24)); - const key2 = ethers.toBigInt(ethers.randomBytes(24)); - const nonce1 = packNonce(key1, 0n); - const nonce2 = packNonce(key2, 0n); - - const { - v: v1, - r: r1, - s: s1, - } = await this.buildData(this.token, this.holder, this.recipient, this.validBefore, nonce1) - .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) - .then(ethers.Signature.from); - - const { - v: v2, - r: r2, - s: s2, - } = await this.buildData(this.token, this.holder, this.recipient, this.validBefore, nonce2) - .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) - .then(ethers.Signature.from); - - // Submit in any order to show that different keys are independent - await this.token.transferWithAuthorization( - this.holder, - this.recipient, - value, - this.validAfter, - this.validBefore, - nonce2, - v2, - r2, - s2, - ); - await this.token.transferWithAuthorization( - this.holder, - this.recipient, - value, - this.validAfter, - this.validBefore, - nonce1, - v1, - r1, - s1, - ); - - await expect(this.token.authorizationState(this.holder, nonce1)).to.eventually.be.true; - await expect(this.token.authorizationState(this.holder, nonce2)).to.eventually.be.true; - }); - - describe('with bytes signature', function () { - it('accepts holder signature', async function () { - const signature = await this.buildData(this.token, this.holder, this.recipient).then( - ({ domain, types, message }) => this.holder.signTypedData(domain, types, message), - ); - - await expect( - this.token.getFunction('transferWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)')( - this.holder, - this.recipient, - value, - this.validAfter, - this.validBefore, - this.nonce, - signature, - ), - ) - .to.emit(this.token, 'AuthorizationUsed') - .withArgs(this.holder.address, this.nonce) - .to.emit(this.token, 'Transfer') - .withArgs(this.holder.address, this.recipient.address, value); - - await expect(this.token.balanceOf(this.holder)).to.eventually.equal(initialSupply - value); - await expect(this.token.balanceOf(this.recipient)).to.eventually.equal(value); - await expect(this.token.authorizationState(this.holder, this.nonce)).to.eventually.be.true; + for (const { Token, mode } of TOKENS) { + const fixture = async () => { + const [holder, recipient, other] = await ethers.getSigners(); + + const token = await ethers.deployContract(Token, [name, symbol, name]); + await token.$_mint(holder, initialSupply); + + const wallet = await ethers.deployContract('ERC1271WalletMock', [holder]); + await token.$_mint(wallet, initialSupply); + + return { + holder, + recipient, + other, + token, + wallet, + }; + }; + + describe(`with ${mode}`, function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); }); - it('accepts ERC1271 wallet signature', async function () { - const signature = await getDomain(this.token).then(domain => - this.holder.signTypedData( - domain, - { TransferWithAuthorization }, - { - from: this.wallet.target, - to: this.recipient.address, - value, - validAfter: this.validAfter, - validBefore: this.validBefore, - nonce: this.nonce, - }, - ), - ); - - await expect( - this.token.getFunction('transferWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)')( - this.wallet, - this.recipient, - value, - this.validAfter, - this.validBefore, - this.nonce, - signature, - ), - ) - .to.emit(this.token, 'AuthorizationUsed') - .withArgs(this.wallet.target, this.nonce) - .to.emit(this.token, 'Transfer') - .withArgs(this.wallet.target, this.recipient.address, value); - - await expect(this.token.balanceOf(this.wallet)).to.eventually.equal(initialSupply - value); - await expect(this.token.balanceOf(this.recipient)).to.eventually.equal(value); - await expect(this.token.authorizationState(this.wallet, this.nonce)).to.eventually.be.true; + describe('authorizationState', function () { + it('returns false for unused nonce', async function () { + const nonce = packNonce(ethers.toBigInt(ethers.randomBytes(24)), 0n); + await expect(this.token.authorizationState(this.holder, nonce)).to.eventually.be.false; + }); }); - it('rejects invalid ERC1271 signature', async function () { - const signature = await getDomain(this.token).then(domain => - this.other.signTypedData( - domain, - { TransferWithAuthorization }, - { - from: this.wallet.target, - to: this.recipient.address, + describe('transferWithAuthorization', function () { + const value = 42n; + + beforeEach(async function () { + this.key = ethers.toBigInt(ethers.randomBytes(24)); + this.nonce = packNonce(this.key, 0n); + this.validAfter = 0n; + this.validBefore = ethers.MaxUint256; + + this.buildData = (contract, from, to, validBefore = this.validBefore, nonce = this.nonce) => + getDomain(contract).then(domain => ({ + domain, + types: { TransferWithAuthorization }, + message: { + from: from.address, + to: to.address, + value, + validAfter: this.validAfter, + validBefore, + nonce, + }, + })); + }); + + it('accepts holder signature', async function () { + const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await expect( + this.token.transferWithAuthorization( + this.holder, + this.recipient, value, - validAfter: this.validAfter, - validBefore: this.validBefore, - nonce: this.nonce, - }, - ), - ); - - await expect( - this.token.getFunction('transferWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)')( - this.wallet, - this.recipient, - value, - this.validAfter, - this.validBefore, - this.nonce, - signature, - ), - ).to.be.revertedWithCustomError(this.token, 'ERC3009InvalidSignature'); - }); - }); - }); - - describe('receiveWithAuthorization', function () { - const value = 42n; - - beforeEach(async function () { - this.key = ethers.toBigInt(ethers.randomBytes(24)); - this.nonce = packNonce(this.key, 0n); - this.validAfter = 0n; - this.validBefore = ethers.MaxUint256; - - this.buildData = (contract, from, to, validBefore = this.validBefore, nonce = this.nonce) => - getDomain(contract).then(domain => ({ - domain, - types: { ReceiveWithAuthorization }, - message: { - from: from.address, - to: to.address, - value, - validAfter: this.validAfter, - validBefore, - nonce, - }, - })); - }); - - it('accepts holder signature when called by recipient', async function () { - const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) - .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) - .then(ethers.Signature.from); - - await expect( - this.token - .connect(this.recipient) - .receiveWithAuthorization( - this.holder, - this.recipient, - value, - this.validAfter, - this.validBefore, - this.nonce, - v, - r, - s, - ), - ) - .to.emit(this.token, 'AuthorizationUsed') - .withArgs(this.holder.address, this.nonce) - .to.emit(this.token, 'Transfer') - .withArgs(this.holder.address, this.recipient.address, value); - - await expect(this.token.balanceOf(this.holder)).to.eventually.equal(initialSupply - value); - await expect(this.token.balanceOf(this.recipient)).to.eventually.equal(value); - await expect(this.token.authorizationState(this.holder, this.nonce)).to.eventually.be.true; - }); - - it('rejects when caller is not the recipient', async function () { - const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) - .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) - .then(ethers.Signature.from); - - await expect( - this.token - .connect(this.other) - .receiveWithAuthorization( + this.validAfter, + this.validBefore, + this.nonce, + v, + r, + s, + ), + ) + .to.emit(this.token, 'AuthorizationUsed') + .withArgs(this.holder.address, this.nonce) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder.address, this.recipient.address, value); + + await expect(this.token.balanceOf(this.holder)).to.eventually.equal(initialSupply - value); + await expect(this.token.balanceOf(this.recipient)).to.eventually.equal(value); + await expect(this.token.authorizationState(this.holder, this.nonce)).to.eventually.be.true; + }); + + it('rejects reused signature', async function () { + const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await this.token.transferWithAuthorization( this.holder, this.recipient, value, @@ -430,414 +120,715 @@ describe('ERC20TransferAuthorization', function () { v, r, s, - ), - ) - .to.be.revertedWithCustomError(this.token, 'ERC20InvalidReceiver') - .withArgs(this.recipient.address); - }); + ); - it('rejects reused signature', async function () { - const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) - .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) - .then(ethers.Signature.from); - - await this.token - .connect(this.recipient) - .receiveWithAuthorization( - this.holder, - this.recipient, - value, - this.validAfter, - this.validBefore, - this.nonce, - v, - r, - s, - ); - - await expect( - this.token - .connect(this.recipient) - .receiveWithAuthorization( - this.holder, - this.recipient, - value, - this.validAfter, - this.validBefore, - this.nonce, - v, - r, - s, - ), - ) - .to.be.revertedWithCustomError(this.token, 'InvalidAccountNonce') - .withArgs(this.holder.address, packNonce(this.key, 1n)); - }); - - it('rejects other signature', async function () { - const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) - .then(({ domain, types, message }) => this.other.signTypedData(domain, types, message)) - .then(ethers.Signature.from); - - await expect( - this.token - .connect(this.recipient) - .receiveWithAuthorization( - this.holder, - this.recipient, - value, - this.validAfter, - this.validBefore, - this.nonce, - v, - r, - s, - ), - ).to.be.revertedWithCustomError(this.token, 'ERC3009InvalidSignature'); - }); - - it('rejects authorization not yet valid', async function () { - const validAfter = (await time.clock.timestamp()) + time.duration.weeks(1); - - const { v, r, s } = await getDomain(this.token) - .then(domain => - this.holder.signTypedData( - domain, - { ReceiveWithAuthorization }, - { - from: this.holder.address, - to: this.recipient.address, + await expect( + this.token.transferWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + v, + r, + s, + ), + ) + .to.be.revertedWithCustomError(this.token, 'InvalidAccountNonce') + .withArgs(this.holder.address, packNonce(this.key, 1n)); + }); + + it('rejects other signature', async function () { + const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) + .then(({ domain, types, message }) => this.other.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await expect( + this.token.transferWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + v, + r, + s, + ), + ).to.be.revertedWithCustomError(this.token, 'ERC3009InvalidSignature'); + }); + + it('rejects authorization not yet valid', async function () { + const validAfter = (await time.clock[mode]()) + time.duration.weeks(1); + + const { v, r, s } = await getDomain(this.token) + .then(domain => + this.holder.signTypedData( + domain, + { TransferWithAuthorization }, + { + from: this.holder.address, + to: this.recipient.address, + value, + validAfter, + validBefore: this.validBefore, + nonce: this.nonce, + }, + ), + ) + .then(ethers.Signature.from); + + await expect( + this.token.transferWithAuthorization( + this.holder, + this.recipient, value, validAfter, - validBefore: this.validBefore, - nonce: this.nonce, - }, - ), - ) - .then(ethers.Signature.from); - - await expect( - this.token - .connect(this.recipient) - .receiveWithAuthorization( - this.holder, - this.recipient, - value, - validAfter, - this.validBefore, - this.nonce, - v, - r, - s, - ), - ) - .to.be.revertedWithCustomError(this.token, 'ERC3009InvalidAuthorizationTime') - .withArgs(validAfter, this.validBefore); - }); - - it('rejects expired authorization', async function () { - const validBefore = (await time.clock.timestamp()) - time.duration.weeks(1); - - const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient, validBefore) - .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) - .then(ethers.Signature.from); - - await expect( - this.token - .connect(this.recipient) - .receiveWithAuthorization( - this.holder, - this.recipient, - value, - this.validAfter, - validBefore, - this.nonce, - v, - r, - s, - ), - ) - .to.be.revertedWithCustomError(this.token, 'ERC3009InvalidAuthorizationTime') - .withArgs(this.validAfter, validBefore); - }); - - describe('with bytes signature', function () { - it('accepts holder signature when called by recipient', async function () { - const signature = await this.buildData(this.token, this.holder, this.recipient).then( - ({ domain, types, message }) => this.holder.signTypedData(domain, types, message), - ); - - await expect( - this.token - .connect(this.recipient) - .getFunction('receiveWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)')( - this.holder, - this.recipient, - value, - this.validAfter, - this.validBefore, - this.nonce, - signature, - ), - ) - .to.emit(this.token, 'AuthorizationUsed') - .withArgs(this.holder.address, this.nonce) - .to.emit(this.token, 'Transfer') - .withArgs(this.holder.address, this.recipient.address, value); - - await expect(this.token.balanceOf(this.holder)).to.eventually.equal(initialSupply - value); - await expect(this.token.balanceOf(this.recipient)).to.eventually.equal(value); - await expect(this.token.authorizationState(this.holder, this.nonce)).to.eventually.be.true; - }); - - it('accepts ERC1271 wallet signature when called by recipient', async function () { - const signature = await getDomain(this.token).then(domain => - this.holder.signTypedData( - domain, - { ReceiveWithAuthorization }, - { - from: this.wallet.target, - to: this.recipient.address, + this.validBefore, + this.nonce, + v, + r, + s, + ), + ) + .to.be.revertedWithCustomError(this.token, 'ERC3009InvalidAuthorizationTime') + .withArgs(validAfter, this.validBefore); + }); + + it('rejects expired authorization', async function () { + const validBefore = (await time.clock[mode]()) - 5n; + + const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient, validBefore) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await expect( + this.token.transferWithAuthorization( + this.holder, + this.recipient, value, - validAfter: this.validAfter, - validBefore: this.validBefore, - nonce: this.nonce, - }, - ), - ); - - await expect( - this.token - .connect(this.recipient) - .getFunction('receiveWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)')( - this.wallet, + this.validAfter, + validBefore, + this.nonce, + v, + r, + s, + ), + ) + .to.be.revertedWithCustomError(this.token, 'ERC3009InvalidAuthorizationTime') + .withArgs(this.validAfter, validBefore); + }); + + it('works with different keys in parallel', async function () { + const key1 = ethers.toBigInt(ethers.randomBytes(24)); + const key2 = ethers.toBigInt(ethers.randomBytes(24)); + const nonce1 = packNonce(key1, 0n); + const nonce2 = packNonce(key2, 0n); + + const { + v: v1, + r: r1, + s: s1, + } = await this.buildData(this.token, this.holder, this.recipient, this.validBefore, nonce1) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + const { + v: v2, + r: r2, + s: s2, + } = await this.buildData(this.token, this.holder, this.recipient, this.validBefore, nonce2) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + // Submit in any order to show that different keys are independent + await this.token.transferWithAuthorization( + this.holder, this.recipient, value, this.validAfter, this.validBefore, - this.nonce, - signature, - ), - ) - .to.emit(this.token, 'AuthorizationUsed') - .withArgs(this.wallet.target, this.nonce) - .to.emit(this.token, 'Transfer') - .withArgs(this.wallet.target, this.recipient.address, value); - - await expect(this.token.balanceOf(this.wallet)).to.eventually.equal(initialSupply - value); - await expect(this.token.balanceOf(this.recipient)).to.eventually.equal(value); - await expect(this.token.authorizationState(this.wallet, this.nonce)).to.eventually.be.true; - }); - - it('rejects when caller is not the recipient', async function () { - const signature = await this.buildData(this.token, this.holder, this.recipient).then( - ({ domain, types, message }) => this.holder.signTypedData(domain, types, message), - ); - - await expect( - this.token - .connect(this.other) - .getFunction('receiveWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)')( + nonce2, + v2, + r2, + s2, + ); + await this.token.transferWithAuthorization( this.holder, this.recipient, value, this.validAfter, this.validBefore, - this.nonce, - signature, - ), - ) - .to.be.revertedWithCustomError(this.token, 'ERC20InvalidReceiver') - .withArgs(this.recipient.address); + nonce1, + v1, + r1, + s1, + ); + + await expect(this.token.authorizationState(this.holder, nonce1)).to.eventually.be.true; + await expect(this.token.authorizationState(this.holder, nonce2)).to.eventually.be.true; + }); + + describe('with bytes signature', function () { + it('accepts holder signature', async function () { + const signature = await this.buildData(this.token, this.holder, this.recipient).then( + ({ domain, types, message }) => this.holder.signTypedData(domain, types, message), + ); + + await expect( + this.token.getFunction( + 'transferWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)', + )(this.holder, this.recipient, value, this.validAfter, this.validBefore, this.nonce, signature), + ) + .to.emit(this.token, 'AuthorizationUsed') + .withArgs(this.holder.address, this.nonce) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder.address, this.recipient.address, value); + + await expect(this.token.balanceOf(this.holder)).to.eventually.equal(initialSupply - value); + await expect(this.token.balanceOf(this.recipient)).to.eventually.equal(value); + await expect(this.token.authorizationState(this.holder, this.nonce)).to.eventually.be.true; + }); + + it('accepts ERC1271 wallet signature', async function () { + const signature = await getDomain(this.token).then(domain => + this.holder.signTypedData( + domain, + { TransferWithAuthorization }, + { + from: this.wallet.target, + to: this.recipient.address, + value, + validAfter: this.validAfter, + validBefore: this.validBefore, + nonce: this.nonce, + }, + ), + ); + + await expect( + this.token.getFunction( + 'transferWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)', + )(this.wallet, this.recipient, value, this.validAfter, this.validBefore, this.nonce, signature), + ) + .to.emit(this.token, 'AuthorizationUsed') + .withArgs(this.wallet.target, this.nonce) + .to.emit(this.token, 'Transfer') + .withArgs(this.wallet.target, this.recipient.address, value); + + await expect(this.token.balanceOf(this.wallet)).to.eventually.equal(initialSupply - value); + await expect(this.token.balanceOf(this.recipient)).to.eventually.equal(value); + await expect(this.token.authorizationState(this.wallet, this.nonce)).to.eventually.be.true; + }); + + it('rejects invalid ERC1271 signature', async function () { + const signature = await getDomain(this.token).then(domain => + this.other.signTypedData( + domain, + { TransferWithAuthorization }, + { + from: this.wallet.target, + to: this.recipient.address, + value, + validAfter: this.validAfter, + validBefore: this.validBefore, + nonce: this.nonce, + }, + ), + ); + + await expect( + this.token.getFunction( + 'transferWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)', + )(this.wallet, this.recipient, value, this.validAfter, this.validBefore, this.nonce, signature), + ).to.be.revertedWithCustomError(this.token, 'ERC3009InvalidSignature'); + }); + }); }); - it('rejects invalid ERC1271 signature', async function () { - const signature = await getDomain(this.token).then(domain => - this.other.signTypedData( - domain, - { ReceiveWithAuthorization }, - { - from: this.wallet.target, - to: this.recipient.address, - value, - validAfter: this.validAfter, - validBefore: this.validBefore, - nonce: this.nonce, - }, - ), - ); - - await expect( - this.token + describe('receiveWithAuthorization', function () { + const value = 42n; + + beforeEach(async function () { + this.key = ethers.toBigInt(ethers.randomBytes(24)); + this.nonce = packNonce(this.key, 0n); + this.validAfter = 0n; + this.validBefore = ethers.MaxUint256; + + this.buildData = (contract, from, to, validBefore = this.validBefore, nonce = this.nonce) => + getDomain(contract).then(domain => ({ + domain, + types: { ReceiveWithAuthorization }, + message: { + from: from.address, + to: to.address, + value, + validAfter: this.validAfter, + validBefore, + nonce, + }, + })); + }); + + it('accepts holder signature when called by recipient', async function () { + const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await expect( + this.token + .connect(this.recipient) + .receiveWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + v, + r, + s, + ), + ) + .to.emit(this.token, 'AuthorizationUsed') + .withArgs(this.holder.address, this.nonce) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder.address, this.recipient.address, value); + + await expect(this.token.balanceOf(this.holder)).to.eventually.equal(initialSupply - value); + await expect(this.token.balanceOf(this.recipient)).to.eventually.equal(value); + await expect(this.token.authorizationState(this.holder, this.nonce)).to.eventually.be.true; + }); + + it('rejects when caller is not the recipient', async function () { + const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await expect( + this.token + .connect(this.other) + .receiveWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + v, + r, + s, + ), + ) + .to.be.revertedWithCustomError(this.token, 'ERC20InvalidReceiver') + .withArgs(this.recipient.address); + }); + + it('rejects reused signature', async function () { + const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await this.token .connect(this.recipient) - .getFunction('receiveWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)')( - this.wallet, - this.recipient, - value, - this.validAfter, - this.validBefore, - this.nonce, - signature, - ), - ).to.be.revertedWithCustomError(this.token, 'ERC3009InvalidSignature'); + .receiveWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + v, + r, + s, + ); + + await expect( + this.token + .connect(this.recipient) + .receiveWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + v, + r, + s, + ), + ) + .to.be.revertedWithCustomError(this.token, 'InvalidAccountNonce') + .withArgs(this.holder.address, packNonce(this.key, 1n)); + }); + + it('rejects other signature', async function () { + const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient) + .then(({ domain, types, message }) => this.other.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await expect( + this.token + .connect(this.recipient) + .receiveWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + v, + r, + s, + ), + ).to.be.revertedWithCustomError(this.token, 'ERC3009InvalidSignature'); + }); + + it('rejects authorization not yet valid', async function () { + const validAfter = (await time.clock[mode]()) + time.duration.weeks(1); + + const { v, r, s } = await getDomain(this.token) + .then(domain => + this.holder.signTypedData( + domain, + { ReceiveWithAuthorization }, + { + from: this.holder.address, + to: this.recipient.address, + value, + validAfter, + validBefore: this.validBefore, + nonce: this.nonce, + }, + ), + ) + .then(ethers.Signature.from); + + await expect( + this.token + .connect(this.recipient) + .receiveWithAuthorization( + this.holder, + this.recipient, + value, + validAfter, + this.validBefore, + this.nonce, + v, + r, + s, + ), + ) + .to.be.revertedWithCustomError(this.token, 'ERC3009InvalidAuthorizationTime') + .withArgs(validAfter, this.validBefore); + }); + + it('rejects expired authorization', async function () { + const validBefore = (await time.clock[mode]()) - 5n; + + const { v, r, s } = await this.buildData(this.token, this.holder, this.recipient, validBefore) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await expect( + this.token + .connect(this.recipient) + .receiveWithAuthorization( + this.holder, + this.recipient, + value, + this.validAfter, + validBefore, + this.nonce, + v, + r, + s, + ), + ) + .to.be.revertedWithCustomError(this.token, 'ERC3009InvalidAuthorizationTime') + .withArgs(this.validAfter, validBefore); + }); + + describe('with bytes signature', function () { + it('accepts holder signature when called by recipient', async function () { + const signature = await this.buildData(this.token, this.holder, this.recipient).then( + ({ domain, types, message }) => this.holder.signTypedData(domain, types, message), + ); + + await expect( + this.token + .connect(this.recipient) + .getFunction('receiveWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)')( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + signature, + ), + ) + .to.emit(this.token, 'AuthorizationUsed') + .withArgs(this.holder.address, this.nonce) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder.address, this.recipient.address, value); + + await expect(this.token.balanceOf(this.holder)).to.eventually.equal(initialSupply - value); + await expect(this.token.balanceOf(this.recipient)).to.eventually.equal(value); + await expect(this.token.authorizationState(this.holder, this.nonce)).to.eventually.be.true; + }); + + it('accepts ERC1271 wallet signature when called by recipient', async function () { + const signature = await getDomain(this.token).then(domain => + this.holder.signTypedData( + domain, + { ReceiveWithAuthorization }, + { + from: this.wallet.target, + to: this.recipient.address, + value, + validAfter: this.validAfter, + validBefore: this.validBefore, + nonce: this.nonce, + }, + ), + ); + + await expect( + this.token + .connect(this.recipient) + .getFunction('receiveWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)')( + this.wallet, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + signature, + ), + ) + .to.emit(this.token, 'AuthorizationUsed') + .withArgs(this.wallet.target, this.nonce) + .to.emit(this.token, 'Transfer') + .withArgs(this.wallet.target, this.recipient.address, value); + + await expect(this.token.balanceOf(this.wallet)).to.eventually.equal(initialSupply - value); + await expect(this.token.balanceOf(this.recipient)).to.eventually.equal(value); + await expect(this.token.authorizationState(this.wallet, this.nonce)).to.eventually.be.true; + }); + + it('rejects when caller is not the recipient', async function () { + const signature = await this.buildData(this.token, this.holder, this.recipient).then( + ({ domain, types, message }) => this.holder.signTypedData(domain, types, message), + ); + + await expect( + this.token + .connect(this.other) + .getFunction('receiveWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)')( + this.holder, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + signature, + ), + ) + .to.be.revertedWithCustomError(this.token, 'ERC20InvalidReceiver') + .withArgs(this.recipient.address); + }); + + it('rejects invalid ERC1271 signature', async function () { + const signature = await getDomain(this.token).then(domain => + this.other.signTypedData( + domain, + { ReceiveWithAuthorization }, + { + from: this.wallet.target, + to: this.recipient.address, + value, + validAfter: this.validAfter, + validBefore: this.validBefore, + nonce: this.nonce, + }, + ), + ); + + await expect( + this.token + .connect(this.recipient) + .getFunction('receiveWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)')( + this.wallet, + this.recipient, + value, + this.validAfter, + this.validBefore, + this.nonce, + signature, + ), + ).to.be.revertedWithCustomError(this.token, 'ERC3009InvalidSignature'); + }); + }); }); - }); - }); - - describe('cancelAuthorization', function () { - beforeEach(async function () { - this.key = ethers.toBigInt(ethers.randomBytes(24)); - this.nonce = packNonce(this.key, 0n); - - this.buildData = (contract, authorizer, nonce = this.nonce) => - getDomain(contract).then(domain => ({ - domain, - types: { CancelAuthorization }, - message: { - authorizer: authorizer.address, - nonce, - }, - })); - }); - it('accepts authorizer signature', async function () { - const { v, r, s } = await this.buildData(this.token, this.holder) - .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) - .then(ethers.Signature.from); - - await expect(this.token.cancelAuthorization(this.holder, this.nonce, v, r, s)) - .to.emit(this.token, 'AuthorizationCanceled') - .withArgs(this.holder.address, this.nonce); - - await expect(this.token.authorizationState(this.holder, this.nonce)).to.eventually.be.true; - }); - - it('rejects reused nonce', async function () { - const { v, r, s } = await this.buildData(this.token, this.holder) - .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) - .then(ethers.Signature.from); - - await this.token.cancelAuthorization(this.holder, this.nonce, v, r, s); - - await expect(this.token.cancelAuthorization(this.holder, this.nonce, v, r, s)) - .to.be.revertedWithCustomError(this.token, 'InvalidAccountNonce') - .withArgs(this.holder.address, packNonce(this.key, 1n)); - }); - - it('rejects other signature', async function () { - const { v, r, s } = await this.buildData(this.token, this.holder) - .then(({ domain, types, message }) => this.other.signTypedData(domain, types, message)) - .then(ethers.Signature.from); - - await expect(this.token.cancelAuthorization(this.holder, this.nonce, v, r, s)).to.be.revertedWithCustomError( - this.token, - 'ERC3009InvalidSignature', - ); - }); - - it('prevents usage of canceled authorization in transferWithAuthorization', async function () { - const value = 42n; - const validAfter = 0n; - const validBefore = ethers.MaxUint256; - - // Cancel the authorization - const { - v: vCancel, - r: rCancel, - s: sCancel, - } = await this.buildData(this.token, this.holder) - .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) - .then(ethers.Signature.from); - - await this.token.cancelAuthorization(this.holder, this.nonce, vCancel, rCancel, sCancel); - - // Try to use the same nonce for transfer - const { v, r, s } = await getDomain(this.token) - .then(domain => - this.holder.signTypedData( - domain, - { TransferWithAuthorization }, - { - from: this.holder.address, - to: this.recipient.address, + describe('cancelAuthorization', function () { + beforeEach(async function () { + this.key = ethers.toBigInt(ethers.randomBytes(24)); + this.nonce = packNonce(this.key, 0n); + + this.buildData = (contract, authorizer, nonce = this.nonce) => + getDomain(contract).then(domain => ({ + domain, + types: { CancelAuthorization }, + message: { + authorizer: authorizer.address, + nonce, + }, + })); + }); + + it('accepts authorizer signature', async function () { + const { v, r, s } = await this.buildData(this.token, this.holder) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await expect(this.token.cancelAuthorization(this.holder, this.nonce, v, r, s)) + .to.emit(this.token, 'AuthorizationCanceled') + .withArgs(this.holder.address, this.nonce); + + await expect(this.token.authorizationState(this.holder, this.nonce)).to.eventually.be.true; + }); + + it('rejects reused nonce', async function () { + const { v, r, s } = await this.buildData(this.token, this.holder) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await this.token.cancelAuthorization(this.holder, this.nonce, v, r, s); + + await expect(this.token.cancelAuthorization(this.holder, this.nonce, v, r, s)) + .to.be.revertedWithCustomError(this.token, 'InvalidAccountNonce') + .withArgs(this.holder.address, packNonce(this.key, 1n)); + }); + + it('rejects other signature', async function () { + const { v, r, s } = await this.buildData(this.token, this.holder) + .then(({ domain, types, message }) => this.other.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await expect(this.token.cancelAuthorization(this.holder, this.nonce, v, r, s)).to.be.revertedWithCustomError( + this.token, + 'ERC3009InvalidSignature', + ); + }); + + it('prevents usage of canceled authorization in transferWithAuthorization', async function () { + const value = 42n; + const validAfter = 0n; + const validBefore = ethers.MaxUint256; + + // Cancel the authorization + const { + v: vCancel, + r: rCancel, + s: sCancel, + } = await this.buildData(this.token, this.holder) + .then(({ domain, types, message }) => this.holder.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await this.token.cancelAuthorization(this.holder, this.nonce, vCancel, rCancel, sCancel); + + // Try to use the same nonce for transfer + const { v, r, s } = await getDomain(this.token) + .then(domain => + this.holder.signTypedData( + domain, + { TransferWithAuthorization }, + { + from: this.holder.address, + to: this.recipient.address, + value, + validAfter, + validBefore, + nonce: this.nonce, + }, + ), + ) + .then(ethers.Signature.from); + + await expect( + this.token.transferWithAuthorization( + this.holder, + this.recipient, value, validAfter, validBefore, - nonce: this.nonce, - }, - ), - ) - .then(ethers.Signature.from); - - await expect( - this.token.transferWithAuthorization( - this.holder, - this.recipient, - value, - validAfter, - validBefore, - this.nonce, - v, - r, - s, - ), - ) - .to.be.revertedWithCustomError(this.token, 'InvalidAccountNonce') - .withArgs(this.holder.address, packNonce(this.key, 1n)); - }); - - describe('with bytes signature', function () { - it('accepts authorizer signature', async function () { - const signature = await this.buildData(this.token, this.holder).then(({ domain, types, message }) => - this.holder.signTypedData(domain, types, message), - ); - - await expect( - this.token.getFunction('cancelAuthorization(address,bytes32,bytes)')(this.holder, this.nonce, signature), - ) - .to.emit(this.token, 'AuthorizationCanceled') - .withArgs(this.holder.address, this.nonce); - - await expect(this.token.authorizationState(this.holder, this.nonce)).to.eventually.be.true; - }); - - it('accepts ERC1271 wallet signature', async function () { - const signature = await getDomain(this.token).then(domain => - this.holder.signTypedData( - domain, - { CancelAuthorization }, - { - authorizer: this.wallet.target, - nonce: this.nonce, - }, - ), - ); - - await expect( - this.token.getFunction('cancelAuthorization(address,bytes32,bytes)')(this.wallet, this.nonce, signature), - ) - .to.emit(this.token, 'AuthorizationCanceled') - .withArgs(this.wallet.target, this.nonce); - - await expect(this.token.authorizationState(this.wallet, this.nonce)).to.eventually.be.true; - }); - - it('rejects invalid ERC1271 signature', async function () { - const signature = await getDomain(this.token).then(domain => - this.other.signTypedData( - domain, - { CancelAuthorization }, - { - authorizer: this.wallet.target, - nonce: this.nonce, - }, - ), - ); - - await expect( - this.token.getFunction('cancelAuthorization(address,bytes32,bytes)')(this.wallet, this.nonce, signature), - ).to.be.revertedWithCustomError(this.token, 'ERC3009InvalidSignature'); + this.nonce, + v, + r, + s, + ), + ) + .to.be.revertedWithCustomError(this.token, 'InvalidAccountNonce') + .withArgs(this.holder.address, packNonce(this.key, 1n)); + }); + + describe('with bytes signature', function () { + it('accepts authorizer signature', async function () { + const signature = await this.buildData(this.token, this.holder).then(({ domain, types, message }) => + this.holder.signTypedData(domain, types, message), + ); + + await expect( + this.token.getFunction('cancelAuthorization(address,bytes32,bytes)')(this.holder, this.nonce, signature), + ) + .to.emit(this.token, 'AuthorizationCanceled') + .withArgs(this.holder.address, this.nonce); + + await expect(this.token.authorizationState(this.holder, this.nonce)).to.eventually.be.true; + }); + + it('accepts ERC1271 wallet signature', async function () { + const signature = await getDomain(this.token).then(domain => + this.holder.signTypedData( + domain, + { CancelAuthorization }, + { + authorizer: this.wallet.target, + nonce: this.nonce, + }, + ), + ); + + await expect( + this.token.getFunction('cancelAuthorization(address,bytes32,bytes)')(this.wallet, this.nonce, signature), + ) + .to.emit(this.token, 'AuthorizationCanceled') + .withArgs(this.wallet.target, this.nonce); + + await expect(this.token.authorizationState(this.wallet, this.nonce)).to.eventually.be.true; + }); + + it('rejects invalid ERC1271 signature', async function () { + const signature = await getDomain(this.token).then(domain => + this.other.signTypedData( + domain, + { CancelAuthorization }, + { + authorizer: this.wallet.target, + nonce: this.nonce, + }, + ), + ); + + await expect( + this.token.getFunction('cancelAuthorization(address,bytes32,bytes)')(this.wallet, this.nonce, signature), + ).to.be.revertedWithCustomError(this.token, 'ERC3009InvalidSignature'); + }); + }); }); }); - }); + } });