diff --git a/.changeset/metal-chicken-dress.md b/.changeset/metal-chicken-dress.md new file mode 100644 index 00000000000..c94cabbc963 --- /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 parallel nonces. diff --git a/contracts/interfaces/draft-IERC3009.sol b/contracts/interfaces/draft-IERC3009.sol new file mode 100644 index 00000000000..fcde71bdef8 --- /dev/null +++ b/contracts/interfaces/draft-IERC3009.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT + +pragma solidity >=0.4.16; + +/** + * @dev Interface of the ERC-3009 standard as defined in https://eips.ethereum.org/EIPS/eip-3009[ERC-3009]. + */ +interface IERC3009 { + /// @dev Emitted when an authorization is used. + event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce); + + /** + * @dev Returns the state of an authorization. + * + * Nonces are randomly generated 32-byte values unique to the authorizer's address. + */ + function authorizationState(address authorizer, bytes32 nonce) external view returns (bool); + + /** + * @dev Executes a transfer with a signed authorization. + * + * Requirements: + * + * * `validAfter` must be less than the current block timestamp. + * * `validBefore` must be greater than the current block timestamp. + * * `nonce` must not have been used by the `from` account. + * * the signature must be valid for the authorization. + */ + function transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external; + + /** + * @dev Receives a transfer with a signed authorization from the payer. + * + * Includes an additional check to ensure that the payee's address (`to`) matches the caller + * to prevent front-running attacks. + * + * Requirements: + * + * * `to` must be the caller of this function. + * * `validAfter` must be less than the current block timestamp. + * * `validBefore` must be greater than the current block timestamp. + * * `nonce` must not have been used by the `from` account. + * * the signature must be valid for the authorization. + */ + function receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external; +} + +/** + * @dev Extension of {IERC3009} that adds the ability to cancel authorizations. + */ +interface IERC3009Cancel { + /// @dev Emitted when an authorization is canceled. + event AuthorizationCanceled(address indexed authorizer, bytes32 indexed nonce); + + /** + * @dev Cancels an authorization. + * + * Requirements: + * + * * `nonce` must not have been used by the `authorizer` account. + * * the signature must be valid for the cancellation. + */ + function cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) external; +} 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 new file mode 100644 index 00000000000..2fd7d7bc871 --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC20TransferAuthorization.sol @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {ERC20} from "../ERC20.sol"; +import {EIP712} from "../../../utils/cryptography/EIP712.sol"; +import {SignatureChecker} from "../../../utils/cryptography/SignatureChecker.sol"; +import {ECDSA} from "../../../utils/cryptography/ECDSA.sol"; +import {IERC3009, IERC3009Cancel} from "../../../interfaces/draft-IERC3009.sol"; +import {NoncesKeyed} from "../../../utils/NoncesKeyed.sol"; +import {IERC6372} from "../../../interfaces/IERC6372.sol"; +import {Time} from "../../../utils/types/Time.sol"; + +/** + * @dev Implementation of the ERC-3009 Transfer With Authorization extension allowing + * transfers to be made via signatures, as defined in https://eips.ethereum.org/EIPS/eip-3009[ERC-3009]. + * + * Adds the {transferWithAuthorization} and {receiveWithAuthorization} methods, which + * can be used to change an account's ERC-20 balance by presenting a message signed + * by the account. By not relying on {IERC20-approve} and {IERC20-transferFrom}, the + * token holder account doesn't need to send a transaction, and thus is not required + * to hold native currency (e.g. ETH) at all. + * + * NOTE: This extension uses keyed sequential nonces following the + * https://eips.ethereum.org/EIPS/eip-4337#semi-abstracted-nonce-support[ERC-4337 semi-abstracted nonce system]. + * The {bytes32} nonce field is interpreted as a 192-bit key packed with a 64-bit sequence. Nonces with + * different keys are independent and can be submitted in parallel without ordering constraints, while nonces + * sharing the same key must be used sequentially. This is unlike {ERC20Permit} which uses a single global + * sequential nonce. + */ +abstract contract ERC20TransferAuthorization is ERC20, EIP712, NoncesKeyed, IERC6372, IERC3009, IERC3009Cancel { + /// @dev The signature is invalid + error ERC3009InvalidSignature(); + + /// @dev The authorization is not valid at the given time + error ERC3009InvalidAuthorizationTime(uint256 validAfter, uint256 validBefore); + + /// @dev The clock was incorrectly modified. + error ERC3009InconsistentClock(); + + bytes32 private constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = + keccak256( + "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" + ); + bytes32 private constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = + keccak256( + "ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" + ); + bytes32 private constant CANCEL_AUTHORIZATION_TYPEHASH = + keccak256("CancelAuthorization(address authorizer,bytes32 nonce)"); + + /** + * @dev Initializes the {EIP712} domain separator using the `name` parameter, and setting `version` to `"1"`. + * + * It's a good idea to use the same `name` that is defined as the ERC-20 token name. + */ + constructor(string memory name) EIP712(name, "1") {} + + /** + * @dev Clock used for validating authorization time windows ({transferWithAuthorization}, + * {receiveWithAuthorization}). Defaults to {Time-timestamp}. Can be overridden to implement + * block-number based validation, in which case {CLOCK_MODE} should be overridden as well to match. + */ + function clock() public view virtual returns (uint48) { + return Time.timestamp(); + } + + /// @dev Machine-readable description of the clock as specified in ERC-6372. + // solhint-disable-next-line func-name-mixedcase + function CLOCK_MODE() public view virtual returns (string memory) { + // Check that the clock was not modified + if (clock() != Time.timestamp()) { + revert ERC3009InconsistentClock(); + } + return "mode=timestamp"; + } + + /** + * @dev See {IERC3009-authorizationState}. + * + * NOTE: Returning `false` does not guarantee that the authorization is currently executable. + * With keyed sequential nonces, a nonce may be blocked by a predecessor in the same key's sequence + * that has not yet been consumed. + */ + function authorizationState(address authorizer, bytes32 nonce) public view virtual returns (bool) { + return uint64(nonces(authorizer, uint192(uint256(nonce) >> 64))) > uint64(uint256(nonce)); + } + + /** + * @dev See {IERC3009-transferWithAuthorization}. + * + * NOTE: A signed authorization will only succeed if its nonce is the next expected sequence + * for the given key. Authorizations sharing a key must be submitted in order. + */ + function transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) public virtual { + bytes32 hash = _hashTypedDataV4( + keccak256(abi.encode(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce)) + ); + (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(hash, v, r, s); + require(err == ECDSA.RecoverError.NoError && recovered == from, ERC3009InvalidSignature()); + _transferWithAuthorization(from, to, value, validAfter, validBefore, nonce); + } + + /// @dev Same as {transferWithAuthorization} but with a bytes signature. + function transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + bytes memory signature + ) public virtual { + bytes32 hash = _hashTypedDataV4( + keccak256(abi.encode(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce)) + ); + require(SignatureChecker.isValidSignatureNow(from, hash, signature), ERC3009InvalidSignature()); + _transferWithAuthorization(from, to, value, validAfter, validBefore, nonce); + } + + /** + * @dev See {IERC3009-receiveWithAuthorization}. + * + * NOTE: A signed authorization will only succeed if its nonce is the next expected sequence + * for the given key. Authorizations sharing a key must be submitted in order. + */ + function receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) public virtual { + bytes32 hash = _hashTypedDataV4( + keccak256(abi.encode(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce)) + ); + (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(hash, v, r, s); + require(err == ECDSA.RecoverError.NoError && recovered == from, ERC3009InvalidSignature()); + _receiveWithAuthorization(from, to, value, validAfter, validBefore, nonce); + } + + /// @dev Same as {receiveWithAuthorization} but with a bytes signature. + function receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + bytes memory signature + ) public virtual { + bytes32 hash = _hashTypedDataV4( + keccak256(abi.encode(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce)) + ); + require(SignatureChecker.isValidSignatureNow(from, hash, signature), ERC3009InvalidSignature()); + _receiveWithAuthorization(from, to, value, validAfter, validBefore, nonce); + } + + /** + * @dev See {IERC3009Cancel-cancelAuthorization}. + * + * NOTE: Due to the keyed sequential nonce model, only the next nonce in a given key's sequence + * can be cancelled. It is not possible to directly cancel a future nonce whose predecessors in the + * same key have not yet been consumed or cancelled. To invalidate a future authorization, all + * preceding nonces in the same key must first be consumed or cancelled in order. + */ + function cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) public virtual { + bytes32 hash = _hashTypedDataV4(keccak256(abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce))); + (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(hash, v, r, s); + require(err == ECDSA.RecoverError.NoError && recovered == authorizer, ERC3009InvalidSignature()); + _cancelAuthorization(authorizer, nonce); + } + + /// @dev Same as {cancelAuthorization} but with a bytes signature. + function cancelAuthorization(address authorizer, bytes32 nonce, bytes memory signature) public virtual { + bytes32 hash = _hashTypedDataV4(keccak256(abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce))); + require(SignatureChecker.isValidSignatureNow(authorizer, hash, signature), ERC3009InvalidSignature()); + _cancelAuthorization(authorizer, nonce); + } + + /// @dev Internal version of {transferWithAuthorization} that accepts a bytes signature. + function _transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce + ) internal virtual { + require( + clock() > validAfter && clock() < validBefore, + ERC3009InvalidAuthorizationTime(validAfter, validBefore) + ); + _useCheckedNonce(from, uint256(nonce)); + emit AuthorizationUsed(from, nonce); + _transfer(from, to, value); + } + + /// @dev Internal version of {receiveWithAuthorization} that accepts a bytes signature. + function _receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce + ) internal virtual { + require(to == _msgSender(), ERC20InvalidReceiver(to)); + require( + clock() > validAfter && clock() < validBefore, + ERC3009InvalidAuthorizationTime(validAfter, validBefore) + ); + _useCheckedNonce(from, uint256(nonce)); + emit AuthorizationUsed(from, nonce); + _transfer(from, to, value); + } + + /// @dev Internal version of {cancelAuthorization} that accepts a bytes signature. + function _cancelAuthorization(address authorizer, bytes32 nonce) internal virtual { + _useCheckedNonce(authorizer, uint256(nonce)); + emit AuthorizationCanceled(authorizer, nonce); + } +} 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..5b874e94ef4 --- /dev/null +++ b/test/token/ERC20/extensions/ERC20TransferAuthorization.test.js @@ -0,0 +1,834 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { + getDomain, + TransferWithAuthorization, + ReceiveWithAuthorization, + CancelAuthorization, +} = 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); + +describe('ERC20TransferAuthorization', function () { + 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)); + }); + + 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[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, + 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, + 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; + }); + + 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.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) + .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, + 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'); + }); + }); + }); + }); + } +});