From e5f02bc6085662aeb557c33af19f432c31e90295 Mon Sep 17 00:00:00 2001 From: Vittorio Minacori Date: Wed, 24 Jan 2024 09:38:25 +0100 Subject: [PATCH] Add ERC1363 implementation (#4631) Co-authored-by: Hadrien Croubois Co-authored-by: ernestognw --- .changeset/friendly-nails-push.md | 5 + .changeset/nice-paws-pull.md | 5 + contracts/interfaces/IERC1363.sol | 86 ++-- contracts/interfaces/IERC1363Receiver.sol | 37 +- contracts/interfaces/IERC1363Spender.sol | 33 +- .../mocks/token/ERC1363ForceApproveMock.sol | 14 + contracts/mocks/token/ERC1363NoReturnMock.sol | 34 ++ contracts/mocks/token/ERC1363ReceiverMock.sol | 52 +++ .../mocks/token/ERC1363ReturnFalseMock.sol | 34 ++ contracts/mocks/token/ERC1363SpenderMock.sol | 47 +++ contracts/token/ERC20/README.adoc | 3 + contracts/token/ERC20/extensions/ERC1363.sol | 198 ++++++++++ contracts/token/ERC20/utils/SafeERC20.sol | 57 ++- test/token/ERC20/ERC20.behavior.js | 112 +++--- test/token/ERC20/ERC20.test.js | 40 +- test/token/ERC20/extensions/ERC1363.test.js | 370 ++++++++++++++++++ .../ERC20/extensions/ERC20FlashMint.test.js | 8 +- .../ERC20/extensions/ERC20Permit.test.js | 8 +- .../ERC20/extensions/ERC20Wrapper.test.js | 90 ++--- test/token/ERC20/utils/SafeERC20.test.js | 201 +++++++++- .../SupportsInterface.behavior.js | 8 + 21 files changed, 1227 insertions(+), 215 deletions(-) create mode 100644 .changeset/friendly-nails-push.md create mode 100644 .changeset/nice-paws-pull.md create mode 100644 contracts/mocks/token/ERC1363ForceApproveMock.sol create mode 100644 contracts/mocks/token/ERC1363NoReturnMock.sol create mode 100644 contracts/mocks/token/ERC1363ReceiverMock.sol create mode 100644 contracts/mocks/token/ERC1363ReturnFalseMock.sol create mode 100644 contracts/mocks/token/ERC1363SpenderMock.sol create mode 100644 contracts/token/ERC20/extensions/ERC1363.sol create mode 100644 test/token/ERC20/extensions/ERC1363.test.js diff --git a/.changeset/friendly-nails-push.md b/.changeset/friendly-nails-push.md new file mode 100644 index 000000000..157bf0556 --- /dev/null +++ b/.changeset/friendly-nails-push.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`ERC1363`: Add implementation of the token payable standard allowing execution of contract code after transfers and approvals. diff --git a/.changeset/nice-paws-pull.md b/.changeset/nice-paws-pull.md new file mode 100644 index 000000000..11f48d51f --- /dev/null +++ b/.changeset/nice-paws-pull.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`SafeERC20`: Add "relaxed" function for interacting with ERC-1363 functions in a way that is compatible with EOAs. diff --git a/contracts/interfaces/IERC1363.sol b/contracts/interfaces/IERC1363.sol index 4a655b80c..a5246da37 100644 --- a/contracts/interfaces/IERC1363.sol +++ b/contracts/interfaces/IERC1363.sol @@ -7,13 +7,13 @@ import {IERC20} from "./IERC20.sol"; import {IERC165} from "./IERC165.sol"; /** - * @dev Interface of an ERC-1363 compliant contract, as defined in the - * https://eips.ethereum.org/EIPS/eip-1363[ERC]. + * @title IERC1363 + * @dev Interface of the ERC-1363 standard as defined in the https://eips.ethereum.org/EIPS/eip-1363[ERC-1363]. * - * Defines a interface for ERC-20 tokens that supports executing recipient - * code after `transfer` or `transferFrom`, or spender code after `approve`. + * Defines an extension interface for ERC-20 tokens that supports executing code on a recipient contract + * after `transfer` or `transferFrom`, or code on a spender contract after `approve`, in a single transaction. */ -interface IERC1363 is IERC165, IERC20 { +interface IERC1363 is IERC20, IERC165 { /* * Note: the ERC-165 identifier for this interface is 0xb0202a11. * 0xb0202a11 === @@ -26,55 +26,61 @@ interface IERC1363 is IERC165, IERC20 { */ /** - * @dev Transfer tokens from `msg.sender` to another address and then call `onTransferReceived` on receiver - * @param to address The address which you want to transfer to - * @param amount uint256 The amount of tokens to be transferred - * @return true unless throwing + * @dev Moves a `value` amount of tokens from the caller's account to `to` + * and then calls {IERC1363Receiver-onTransferReceived} on `to`. + * @param to The address which you want to transfer to. + * @param value The amount of tokens to be transferred. + * @return A boolean value indicating whether the operation succeeded unless throwing. */ - function transferAndCall(address to, uint256 amount) external returns (bool); + function transferAndCall(address to, uint256 value) external returns (bool); /** - * @dev Transfer tokens from `msg.sender` to another address and then call `onTransferReceived` on receiver - * @param to address The address which you want to transfer to - * @param amount uint256 The amount of tokens to be transferred - * @param data bytes Additional data with no specified format, sent in call to `to` - * @return true unless throwing + * @dev Moves a `value` amount of tokens from the caller's account to `to` + * and then calls {IERC1363Receiver-onTransferReceived} on `to`. + * @param to The address which you want to transfer to. + * @param value The amount of tokens to be transferred. + * @param data Additional data with no specified format, sent in call to `to`. + * @return A boolean value indicating whether the operation succeeded unless throwing. */ - function transferAndCall(address to, uint256 amount, bytes memory data) external returns (bool); + function transferAndCall(address to, uint256 value, bytes calldata data) external returns (bool); /** - * @dev Transfer tokens from one address to another and then call `onTransferReceived` on receiver - * @param from address The address which you want to send tokens from - * @param to address The address which you want to transfer to - * @param amount uint256 The amount of tokens to be transferred - * @return true unless throwing + * @dev Moves a `value` amount of tokens from `from` to `to` using the allowance mechanism + * and then calls {IERC1363Receiver-onTransferReceived} on `to`. + * @param from The address which you want to send tokens from. + * @param to The address which you want to transfer to. + * @param value The amount of tokens to be transferred. + * @return A boolean value indicating whether the operation succeeded unless throwing. */ - function transferFromAndCall(address from, address to, uint256 amount) external returns (bool); + function transferFromAndCall(address from, address to, uint256 value) external returns (bool); /** - * @dev Transfer tokens from one address to another and then call `onTransferReceived` on receiver - * @param from address The address which you want to send tokens from - * @param to address The address which you want to transfer to - * @param amount uint256 The amount of tokens to be transferred - * @param data bytes Additional data with no specified format, sent in call to `to` - * @return true unless throwing + * @dev Moves a `value` amount of tokens from `from` to `to` using the allowance mechanism + * and then calls {IERC1363Receiver-onTransferReceived} on `to`. + * @param from The address which you want to send tokens from. + * @param to The address which you want to transfer to. + * @param value The amount of tokens to be transferred. + * @param data Additional data with no specified format, sent in call to `to`. + * @return A boolean value indicating whether the operation succeeded unless throwing. */ - function transferFromAndCall(address from, address to, uint256 amount, bytes memory data) external returns (bool); + function transferFromAndCall(address from, address to, uint256 value, bytes calldata data) external returns (bool); /** - * @dev Approve the passed address to spend the specified amount of tokens on behalf of msg.sender - * and then call `onApprovalReceived` on spender. - * @param spender address The address which will spend the funds - * @param amount uint256 The amount of tokens to be spent + * @dev Sets a `value` amount of tokens as the allowance of `spender` over the + * caller's tokens and then calls {IERC1363Spender-onApprovalReceived} on `spender`. + * @param spender The address which will spend the funds. + * @param value The amount of tokens to be spent. + * @return A boolean value indicating whether the operation succeeded unless throwing. */ - function approveAndCall(address spender, uint256 amount) external returns (bool); + function approveAndCall(address spender, uint256 value) external returns (bool); /** - * @dev Approve the passed address to spend the specified amount of tokens on behalf of msg.sender - * and then call `onApprovalReceived` on spender. - * @param spender address The address which will spend the funds - * @param amount uint256 The amount of tokens to be spent - * @param data bytes Additional data with no specified format, sent in call to `spender` + * @dev Sets a `value` amount of tokens as the allowance of `spender` over the + * caller's tokens and then calls {IERC1363Spender-onApprovalReceived} on `spender`. + * @param spender The address which will spend the funds. + * @param value The amount of tokens to be spent. + * @param data Additional data with no specified format, sent in call to `spender`. + * @return A boolean value indicating whether the operation succeeded unless throwing. */ - function approveAndCall(address spender, uint256 amount, bytes memory data) external returns (bool); + function approveAndCall(address spender, uint256 value, bytes calldata data) external returns (bool); } diff --git a/contracts/interfaces/IERC1363Receiver.sol b/contracts/interfaces/IERC1363Receiver.sol index 04e5dce8c..ebcffe24e 100644 --- a/contracts/interfaces/IERC1363Receiver.sol +++ b/contracts/interfaces/IERC1363Receiver.sol @@ -4,32 +4,29 @@ pragma solidity ^0.8.20; /** - * @dev Interface for any contract that wants to support {IERC1363-transferAndCall} - * or {IERC1363-transferFromAndCall} from {ERC1363} token contracts. + * @title IERC1363Receiver + * @dev Interface for any contract that wants to support `transferAndCall` or `transferFromAndCall` + * from ERC-1363 token contracts. */ interface IERC1363Receiver { - /* - * Note: the ERC-165 identifier for this interface is 0x88a7ca5c. - * 0x88a7ca5c === bytes4(keccak256("onTransferReceived(address,address,uint256,bytes)")) - */ - /** - * @notice Handle the receipt of ERC-1363 tokens - * @dev Any ERC-1363 smart contract calls this function on the recipient - * after a `transfer` or a `transferFrom`. This function MAY throw to revert and reject the - * transfer. Return of other than the magic value MUST result in the - * transaction being reverted. - * Note: the token contract address is always the message sender. - * @param operator address The address which called `transferAndCall` or `transferFromAndCall` function - * @param from address The address which are token transferred from - * @param amount uint256 The amount of tokens transferred - * @param data bytes Additional data with no specified format - * @return `bytes4(keccak256("onTransferReceived(address,address,uint256,bytes)"))` unless throwing + * @dev Whenever ERC-1363 tokens are transferred to this contract via `transferAndCall` or `transferFromAndCall` + * by `operator` from `from`, this function is called. + * + * NOTE: To accept the transfer, this must return + * `bytes4(keccak256("onTransferReceived(address,address,uint256,bytes)"))` + * (i.e. 0x88a7ca5c, or its own function selector). + * + * @param operator The address which called `transferAndCall` or `transferFromAndCall` function. + * @param from The address which are tokens transferred from. + * @param value The amount of tokens transferred. + * @param data Additional data with no specified format. + * @return `bytes4(keccak256("onTransferReceived(address,address,uint256,bytes)"))` if transfer is allowed unless throwing. */ function onTransferReceived( address operator, address from, - uint256 amount, - bytes memory data + uint256 value, + bytes calldata data ) external returns (bytes4); } diff --git a/contracts/interfaces/IERC1363Spender.sol b/contracts/interfaces/IERC1363Spender.sol index 069e4ff80..b4bf3f42c 100644 --- a/contracts/interfaces/IERC1363Spender.sol +++ b/contracts/interfaces/IERC1363Spender.sol @@ -4,26 +4,23 @@ pragma solidity ^0.8.20; /** - * @dev Interface for any contract that wants to support {IERC1363-approveAndCall} - * from {ERC1363} token contracts. + * @title ERC1363Spender + * @dev Interface for any contract that wants to support `approveAndCall` + * from ERC-1363 token contracts. */ interface IERC1363Spender { - /* - * Note: the ERC-165 identifier for this interface is 0x7b04a2d0. - * 0x7b04a2d0 === bytes4(keccak256("onApprovalReceived(address,uint256,bytes)")) - */ - /** - * @notice Handle the approval of ERC-1363 tokens - * @dev Any ERC-1363 smart contract calls this function on the recipient - * after an `approve`. This function MAY throw to revert and reject the - * approval. Return of other than the magic value MUST result in the - * transaction being reverted. - * Note: the token contract address is always the message sender. - * @param owner address The address which called `approveAndCall` function - * @param amount uint256 The amount of tokens to be spent - * @param data bytes Additional data with no specified format - * @return `bytes4(keccak256("onApprovalReceived(address,uint256,bytes)"))`unless throwing + * @dev Whenever an ERC-1363 token `owner` approves this contract via `approveAndCall` + * to spend their tokens, this function is called. + * + * NOTE: To accept the approval, this must return + * `bytes4(keccak256("onApprovalReceived(address,uint256,bytes)"))` + * (i.e. 0x7b04a2d0, or its own function selector). + * + * @param owner The address which called `approveAndCall` function and previously owned the tokens. + * @param value The amount of tokens to be spent. + * @param data Additional data with no specified format. + * @return `bytes4(keccak256("onApprovalReceived(address,uint256,bytes)"))` if approval is allowed unless throwing. */ - function onApprovalReceived(address owner, uint256 amount, bytes memory data) external returns (bytes4); + function onApprovalReceived(address owner, uint256 value, bytes calldata data) external returns (bytes4); } diff --git a/contracts/mocks/token/ERC1363ForceApproveMock.sol b/contracts/mocks/token/ERC1363ForceApproveMock.sol new file mode 100644 index 000000000..d911a0ace --- /dev/null +++ b/contracts/mocks/token/ERC1363ForceApproveMock.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IERC20} from "../../interfaces/IERC20.sol"; +import {ERC20, ERC1363} from "../../token/ERC20/extensions/ERC1363.sol"; + +// contract that replicate USDT approval behavior in approveAndCall +abstract contract ERC1363ForceApproveMock is ERC1363 { + function approveAndCall(address spender, uint256 amount, bytes memory data) public virtual override returns (bool) { + require(amount == 0 || allowance(msg.sender, spender) == 0, "USDT approval failure"); + return super.approveAndCall(spender, amount, data); + } +} diff --git a/contracts/mocks/token/ERC1363NoReturnMock.sol b/contracts/mocks/token/ERC1363NoReturnMock.sol new file mode 100644 index 000000000..748d23413 --- /dev/null +++ b/contracts/mocks/token/ERC1363NoReturnMock.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IERC20, ERC20} from "../../token/ERC20/ERC20.sol"; +import {ERC1363} from "../../token/ERC20/extensions/ERC1363.sol"; + +abstract contract ERC1363NoReturnMock is ERC1363 { + function transferAndCall(address to, uint256 value, bytes memory data) public override returns (bool) { + super.transferAndCall(to, value, data); + assembly { + return(0, 0) + } + } + + function transferFromAndCall( + address from, + address to, + uint256 value, + bytes memory data + ) public override returns (bool) { + super.transferFromAndCall(from, to, value, data); + assembly { + return(0, 0) + } + } + + function approveAndCall(address spender, uint256 value, bytes memory data) public override returns (bool) { + super.approveAndCall(spender, value, data); + assembly { + return(0, 0) + } + } +} diff --git a/contracts/mocks/token/ERC1363ReceiverMock.sol b/contracts/mocks/token/ERC1363ReceiverMock.sol new file mode 100644 index 000000000..d33e05e42 --- /dev/null +++ b/contracts/mocks/token/ERC1363ReceiverMock.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IERC1363Receiver} from "../../interfaces/IERC1363Receiver.sol"; + +contract ERC1363ReceiverMock is IERC1363Receiver { + enum RevertType { + None, + RevertWithoutMessage, + RevertWithMessage, + RevertWithCustomError, + Panic + } + + bytes4 private _retval; + RevertType private _error; + + event Received(address operator, address from, uint256 value, bytes data); + error CustomError(bytes4); + + constructor() { + _retval = IERC1363Receiver.onTransferReceived.selector; + _error = RevertType.None; + } + + function setUp(bytes4 retval, RevertType error) public { + _retval = retval; + _error = error; + } + + function onTransferReceived( + address operator, + address from, + uint256 value, + bytes calldata data + ) external override returns (bytes4) { + if (_error == RevertType.RevertWithoutMessage) { + revert(); + } else if (_error == RevertType.RevertWithMessage) { + revert("ERC1363ReceiverMock: reverting"); + } else if (_error == RevertType.RevertWithCustomError) { + revert CustomError(_retval); + } else if (_error == RevertType.Panic) { + uint256 a = uint256(0) / uint256(0); + a; + } + + emit Received(operator, from, value, data); + return _retval; + } +} diff --git a/contracts/mocks/token/ERC1363ReturnFalseMock.sol b/contracts/mocks/token/ERC1363ReturnFalseMock.sol new file mode 100644 index 000000000..afdd01f3e --- /dev/null +++ b/contracts/mocks/token/ERC1363ReturnFalseMock.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IERC20, ERC20} from "../../token/ERC20/ERC20.sol"; +import {ERC1363} from "../../token/ERC20/extensions/ERC1363.sol"; + +abstract contract ERC1363ReturnFalseOnERC20Mock is ERC1363 { + function transfer(address, uint256) public pure override(IERC20, ERC20) returns (bool) { + return false; + } + + function transferFrom(address, address, uint256) public pure override(IERC20, ERC20) returns (bool) { + return false; + } + + function approve(address, uint256) public pure override(IERC20, ERC20) returns (bool) { + return false; + } +} + +abstract contract ERC1363ReturnFalseMock is ERC1363 { + function transferAndCall(address, uint256, bytes memory) public pure override returns (bool) { + return false; + } + + function transferFromAndCall(address, address, uint256, bytes memory) public pure override returns (bool) { + return false; + } + + function approveAndCall(address, uint256, bytes memory) public pure override returns (bool) { + return false; + } +} diff --git a/contracts/mocks/token/ERC1363SpenderMock.sol b/contracts/mocks/token/ERC1363SpenderMock.sol new file mode 100644 index 000000000..b12c4c1d9 --- /dev/null +++ b/contracts/mocks/token/ERC1363SpenderMock.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IERC1363Spender} from "../../interfaces/IERC1363Spender.sol"; + +contract ERC1363SpenderMock is IERC1363Spender { + enum RevertType { + None, + RevertWithoutMessage, + RevertWithMessage, + RevertWithCustomError, + Panic + } + + bytes4 private _retval; + RevertType private _error; + + event Approved(address owner, uint256 value, bytes data); + error CustomError(bytes4); + + constructor() { + _retval = IERC1363Spender.onApprovalReceived.selector; + _error = RevertType.None; + } + + function setUp(bytes4 retval, RevertType error) public { + _retval = retval; + _error = error; + } + + function onApprovalReceived(address owner, uint256 value, bytes calldata data) external override returns (bytes4) { + if (_error == RevertType.RevertWithoutMessage) { + revert(); + } else if (_error == RevertType.RevertWithMessage) { + revert("ERC1363SpenderMock: reverting"); + } else if (_error == RevertType.RevertWithCustomError) { + revert CustomError(_retval); + } else if (_error == RevertType.Panic) { + uint256 a = uint256(0) / uint256(0); + a; + } + + emit Approved(owner, value, data); + return _retval; + } +} diff --git a/contracts/token/ERC20/README.adoc b/contracts/token/ERC20/README.adoc index 6113b08e6..938784ff9 100644 --- a/contracts/token/ERC20/README.adoc +++ b/contracts/token/ERC20/README.adoc @@ -22,6 +22,7 @@ Additionally there are multiple custom extensions, including: * {ERC20FlashMint}: token level support for flash loans through the minting and burning of ephemeral tokens (standardized as ERC-3156). * {ERC20Votes}: support for voting and vote delegation. * {ERC20Wrapper}: wrapper to create an ERC-20 backed by another ERC-20, with deposit and withdraw methods. Useful in conjunction with {ERC20Votes}. +* {ERC1363}: support for calling the target of a transfer or approval, enabling code execution on the receiver within a single transaction. * {ERC4626}: tokenized vault that manages shares (represented as ERC-20) that are backed by assets (another ERC-20). Finally, there are some utilities to interact with ERC-20 contracts in various ways: @@ -60,6 +61,8 @@ NOTE: This core set of contracts is designed to be unopinionated, allowing devel {{ERC20FlashMint}} +{{ERC1363}} + {{ERC4626}} == Utilities diff --git a/contracts/token/ERC20/extensions/ERC1363.sol b/contracts/token/ERC20/extensions/ERC1363.sol new file mode 100644 index 000000000..1d9bbddcd --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC1363.sol @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ERC20} from "../ERC20.sol"; +import {IERC165, ERC165} from "../../../utils/introspection/ERC165.sol"; + +import {IERC1363} from "../../../interfaces/IERC1363.sol"; +import {IERC1363Receiver} from "../../../interfaces/IERC1363Receiver.sol"; +import {IERC1363Spender} from "../../../interfaces/IERC1363Spender.sol"; + +/** + * @title ERC1363 + * @dev Extension of {ERC20} tokens that adds support for code execution after transfers and approvals + * on recipient contracts. Calls after transfers are enabled through the {ERC1363-transferAndCall} and + * {ERC1363-transferFromAndCall} methods while calls after approvals can be made with {ERC1363-approveAndCall} + */ +abstract contract ERC1363 is ERC20, ERC165, IERC1363 { + /** + * @dev Indicates a failure with the token `receiver`. Used in transfers. + * @param receiver Address to which tokens are being transferred. + */ + error ERC1363InvalidReceiver(address receiver); + + /** + * @dev Indicates a failure with the token `spender`. Used in approvals. + * @param spender Address that may be allowed to operate on tokens without being their owner. + */ + error ERC1363InvalidSpender(address spender); + + /** + * @dev Indicates a failure within the {transfer} part of a transferAndCall operation. + */ + error ERC1363TransferFailed(address to, uint256 value); + + /** + * @dev Indicates a failure within the {transferFrom} part of a transferFromAndCall operation. + */ + error ERC1363TransferFromFailed(address from, address to, uint256 value); + + /** + * @dev Indicates a failure within the {approve} part of a approveAndCall operation. + */ + error ERC1363ApproveFailed(address spender, uint256 value); + + /** + * @inheritdoc IERC165 + */ + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) { + return interfaceId == type(IERC1363).interfaceId || super.supportsInterface(interfaceId); + } + + /** + * @dev Moves a `value` amount of tokens from the caller's account to `to` + * and then calls {IERC1363Receiver-onTransferReceived} on `to`. + * + * Requirements: + * + * - The target has code (i.e. is a contract). + * - The target `to` must implement the {IERC1363Receiver} interface. + * - The target should return the {IERC1363Receiver} interface id. + * - The internal {transfer} must succeed (returned `true`). + */ + function transferAndCall(address to, uint256 value) public returns (bool) { + return transferAndCall(to, value, ""); + } + + /** + * @dev Variant of {transferAndCall} that accepts an additional `data` parameter with + * no specified format. + */ + function transferAndCall(address to, uint256 value, bytes memory data) public virtual returns (bool) { + if (!transfer(to, value)) { + revert ERC1363TransferFailed(to, value); + } + _checkOnTransferReceived(_msgSender(), to, value, data); + return true; + } + + /** + * @dev Moves a `value` amount of tokens from `from` to `to` using the allowance mechanism + * and then calls {IERC1363Receiver-onTransferReceived} on `to`. + * + * Requirements: + * + * - The target has code (i.e. is a contract). + * - The target `to` must implement the {IERC1363Receiver} interface. + * - The target should return the {IERC1363Receiver} interface id. + * - The internal {transferFrom} must succeed (returned `true`). + */ + function transferFromAndCall(address from, address to, uint256 value) public returns (bool) { + return transferFromAndCall(from, to, value, ""); + } + + /** + * @dev Variant of {transferFromAndCall} that accepts an additional `data` parameter with + * no specified format. + */ + function transferFromAndCall( + address from, + address to, + uint256 value, + bytes memory data + ) public virtual returns (bool) { + if (!transferFrom(from, to, value)) { + revert ERC1363TransferFromFailed(from, to, value); + } + _checkOnTransferReceived(from, to, value, data); + return true; + } + + /** + * @dev Sets a `value` amount of tokens as the allowance of `spender` over the + * caller's tokens and then calls {IERC1363Spender-onApprovalReceived} on `spender`. + * + * Requirements: + * + * - The target has code (i.e. is a contract). + * - The target `to` must implement the {IERC1363Spender} interface. + * - The target should return the {IERC1363Spender} interface id. + * - The internal {approve} must succeed (returned `true`). + */ + function approveAndCall(address spender, uint256 value) public returns (bool) { + return approveAndCall(spender, value, ""); + } + + /** + * @dev Variant of {approveAndCall} that accepts an additional `data` parameter with + * no specified format. + */ + function approveAndCall(address spender, uint256 value, bytes memory data) public virtual returns (bool) { + if (!approve(spender, value)) { + revert ERC1363ApproveFailed(spender, value); + } + _checkOnApprovalReceived(spender, value, data); + return true; + } + + /** + * @dev Performs a call to {IERC1363Receiver-onTransferReceived} on a target address. + * + * Requirements: + * + * - The target has code (i.e. is a contract). + * - The target `to` must implement the {IERC1363Receiver} interface. + * - The target should return the {IERC1363Receiver} interface id. + */ + function _checkOnTransferReceived(address from, address to, uint256 value, bytes memory data) private { + if (to.code.length == 0) { + revert ERC1363InvalidReceiver(to); + } + + try IERC1363Receiver(to).onTransferReceived(_msgSender(), from, value, data) returns (bytes4 retval) { + if (retval != IERC1363Receiver.onTransferReceived.selector) { + revert ERC1363InvalidReceiver(to); + } + } catch (bytes memory reason) { + if (reason.length == 0) { + revert ERC1363InvalidReceiver(to); + } else { + /// @solidity memory-safe-assembly + assembly { + revert(add(32, reason), mload(reason)) + } + } + } + } + + /** + * @dev Performs a call to {IERC1363Spender-onApprovalReceived} on a target address. + * + * Requirements: + * + * - The target has code (i.e. is a contract). + * - The target `to` must implement the {IERC1363Spender} interface. + * - The target should return the {IERC1363Spender} interface id. + */ + function _checkOnApprovalReceived(address spender, uint256 value, bytes memory data) private { + if (spender.code.length == 0) { + revert ERC1363InvalidSpender(spender); + } + + try IERC1363Spender(spender).onApprovalReceived(_msgSender(), value, data) returns (bytes4 retval) { + if (retval != IERC1363Spender.onApprovalReceived.selector) { + revert ERC1363InvalidSpender(spender); + } + } catch (bytes memory reason) { + if (reason.length == 0) { + revert ERC1363InvalidSpender(spender); + } else { + /// @solidity memory-safe-assembly + assembly { + revert(add(32, reason), mload(reason)) + } + } + } + } +} diff --git a/contracts/token/ERC20/utils/SafeERC20.sol b/contracts/token/ERC20/utils/SafeERC20.sol index 67fabf4cc..58f9fcf4d 100644 --- a/contracts/token/ERC20/utils/SafeERC20.sol +++ b/contracts/token/ERC20/utils/SafeERC20.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.20; import {IERC20} from "../IERC20.sol"; -import {IERC20Permit} from "../extensions/IERC20Permit.sol"; +import {IERC1363} from "../../../interfaces/IERC1363.sol"; import {Address} from "../../../utils/Address.sol"; /** @@ -82,6 +82,61 @@ library SafeERC20 { } } + /** + * @dev Performs an {ERC1363} transferAndCall, with a fallback to the simple {ERC20} transfer if the target has no + * code. This can be used to implement an {ERC721}-like safe transfer that rely on {ERC1363} checks when + * targeting contracts. + * + * Reverts if the returned value is other than `true`. + */ + function transferAndCallRelaxed(IERC1363 token, address to, uint256 value, bytes memory data) internal { + if (to.code.length == 0) { + safeTransfer(token, to, value); + } else if (!token.transferAndCall(to, value, data)) { + revert SafeERC20FailedOperation(address(token)); + } + } + + /** + * @dev Performs an {ERC1363} transferFromAndCall, with a fallback to the simple {ERC20} transferFrom if the target + * has no code. This can be used to implement an {ERC721}-like safe transfer that rely on {ERC1363} checks when + * targeting contracts. + * + * Reverts if the returned value is other than `true`. + */ + function transferFromAndCallRelaxed( + IERC1363 token, + address from, + address to, + uint256 value, + bytes memory data + ) internal { + if (to.code.length == 0) { + safeTransferFrom(token, from, to, value); + } else if (!token.transferFromAndCall(from, to, value, data)) { + revert SafeERC20FailedOperation(address(token)); + } + } + + /** + * @dev Performs an {ERC1363} approveAndCall, with a fallback to the simple {ERC20} approve if the target has no + * code. This can be used to implement an {ERC721}-like safe transfer that rely on {ERC1363} checks when + * targeting contracts. + * + * NOTE: When the recipient address (`to`) has no code (i.e. is an EOA), this function behaves as {forceApprove}. + * Opposedly, when the recipient address (`to`) has code, this function only attempts to call {ERC1363-approveAndCall} + * once without retrying, and relies on the returned value to be true. + * + * Reverts if the returned value is other than `true`. + */ + function approveAndCallRelaxed(IERC1363 token, address to, uint256 value, bytes memory data) internal { + if (to.code.length == 0) { + forceApprove(token, to, value); + } else if (!token.approveAndCall(to, value, data)) { + revert SafeERC20FailedOperation(address(token)); + } + } + /** * @dev Imitates a Solidity high-level call (i.e. a regular function call to a contract), relaxing the requirement * on the return value: the return value is optional (but if data is returned, it must not be false). diff --git a/test/token/ERC20/ERC20.behavior.js b/test/token/ERC20/ERC20.behavior.js index 2e77f32a1..6754bff33 100644 --- a/test/token/ERC20/ERC20.behavior.js +++ b/test/token/ERC20/ERC20.behavior.js @@ -4,17 +4,21 @@ const { expect } = require('chai'); function shouldBehaveLikeERC20(initialSupply, opts = {}) { const { forcedApproval } = opts; + beforeEach(async function () { + [this.holder, this.recipient, this.other] = this.accounts; + }); + it('total supply: returns the total token value', async function () { expect(await this.token.totalSupply()).to.equal(initialSupply); }); describe('balanceOf', function () { it('returns zero when the requested account has no tokens', async function () { - expect(await this.token.balanceOf(this.anotherAccount)).to.equal(0n); + expect(await this.token.balanceOf(this.other)).to.equal(0n); }); it('returns the total token value when the requested account has some tokens', async function () { - expect(await this.token.balanceOf(this.initialHolder)).to.equal(initialSupply); + expect(await this.token.balanceOf(this.holder)).to.equal(initialSupply); }); }); @@ -31,34 +35,26 @@ function shouldBehaveLikeERC20(initialSupply, opts = {}) { describe('when the recipient is not the zero address', function () { describe('when the spender has enough allowance', function () { beforeEach(async function () { - await this.token.connect(this.initialHolder).approve(this.recipient, initialSupply); + await this.token.connect(this.holder).approve(this.recipient, initialSupply); }); describe('when the token owner has enough balance', function () { const value = initialSupply; beforeEach(async function () { - this.tx = await this.token - .connect(this.recipient) - .transferFrom(this.initialHolder, this.anotherAccount, value); + this.tx = await this.token.connect(this.recipient).transferFrom(this.holder, this.other, value); }); it('transfers the requested value', async function () { - await expect(this.tx).to.changeTokenBalances( - this.token, - [this.initialHolder, this.anotherAccount], - [-value, value], - ); + await expect(this.tx).to.changeTokenBalances(this.token, [this.holder, this.other], [-value, value]); }); it('decreases the spender allowance', async function () { - expect(await this.token.allowance(this.initialHolder, this.recipient)).to.equal(0n); + expect(await this.token.allowance(this.holder, this.recipient)).to.equal(0n); }); it('emits a transfer event', async function () { - await expect(this.tx) - .to.emit(this.token, 'Transfer') - .withArgs(this.initialHolder, this.anotherAccount, value); + await expect(this.tx).to.emit(this.token, 'Transfer').withArgs(this.holder, this.other, value); }); if (forcedApproval) { @@ -66,9 +62,9 @@ function shouldBehaveLikeERC20(initialSupply, opts = {}) { await expect(this.tx) .to.emit(this.token, 'Approval') .withArgs( - this.initialHolder.address, + this.holder.address, this.recipient.address, - await this.token.allowance(this.initialHolder, this.recipient), + await this.token.allowance(this.holder, this.recipient), ); }); } else { @@ -80,12 +76,10 @@ function shouldBehaveLikeERC20(initialSupply, opts = {}) { it('reverts when the token owner does not have enough balance', async function () { const value = initialSupply; - await this.token.connect(this.initialHolder).transfer(this.anotherAccount, 1n); - await expect( - this.token.connect(this.recipient).transferFrom(this.initialHolder, this.anotherAccount, value), - ) + await this.token.connect(this.holder).transfer(this.other, 1n); + await expect(this.token.connect(this.recipient).transferFrom(this.holder, this.other, value)) .to.revertedWithCustomError(this.token, 'ERC20InsufficientBalance') - .withArgs(this.initialHolder, value - 1n, value); + .withArgs(this.holder, value - 1n, value); }); }); @@ -93,39 +87,33 @@ function shouldBehaveLikeERC20(initialSupply, opts = {}) { const allowance = initialSupply - 1n; beforeEach(async function () { - await this.token.connect(this.initialHolder).approve(this.recipient, allowance); + await this.token.connect(this.holder).approve(this.recipient, allowance); }); it('reverts when the token owner has enough balance', async function () { const value = initialSupply; - await expect( - this.token.connect(this.recipient).transferFrom(this.initialHolder, this.anotherAccount, value), - ) + await expect(this.token.connect(this.recipient).transferFrom(this.holder, this.other, value)) .to.be.revertedWithCustomError(this.token, 'ERC20InsufficientAllowance') .withArgs(this.recipient, allowance, value); }); it('reverts when the token owner does not have enough balance', async function () { const value = allowance; - await this.token.connect(this.initialHolder).transfer(this.anotherAccount, 2); - await expect( - this.token.connect(this.recipient).transferFrom(this.initialHolder, this.anotherAccount, value), - ) + await this.token.connect(this.holder).transfer(this.other, 2); + await expect(this.token.connect(this.recipient).transferFrom(this.holder, this.other, value)) .to.be.revertedWithCustomError(this.token, 'ERC20InsufficientBalance') - .withArgs(this.initialHolder, value - 1n, value); + .withArgs(this.holder, value - 1n, value); }); }); describe('when the spender has unlimited allowance', function () { beforeEach(async function () { - await this.token.connect(this.initialHolder).approve(this.recipient, ethers.MaxUint256); - this.tx = await this.token - .connect(this.recipient) - .transferFrom(this.initialHolder, this.anotherAccount, 1n); + await this.token.connect(this.holder).approve(this.recipient, ethers.MaxUint256); + this.tx = await this.token.connect(this.recipient).transferFrom(this.holder, this.other, 1n); }); it('does not decrease the spender allowance', async function () { - expect(await this.token.allowance(this.initialHolder, this.recipient)).to.equal(ethers.MaxUint256); + expect(await this.token.allowance(this.holder, this.recipient)).to.equal(ethers.MaxUint256); }); it('does not emit an approval event', async function () { @@ -136,8 +124,8 @@ function shouldBehaveLikeERC20(initialSupply, opts = {}) { it('reverts when the recipient is the zero address', async function () { const value = initialSupply; - await this.token.connect(this.initialHolder).approve(this.recipient, value); - await expect(this.token.connect(this.recipient).transferFrom(this.initialHolder, ethers.ZeroAddress, value)) + await this.token.connect(this.holder).approve(this.recipient, value); + await expect(this.token.connect(this.recipient).transferFrom(this.holder, ethers.ZeroAddress, value)) .to.be.revertedWithCustomError(this.token, 'ERC20InvalidReceiver') .withArgs(ethers.ZeroAddress); }); @@ -164,24 +152,24 @@ function shouldBehaveLikeERC20Transfer(balance) { describe('when the recipient is not the zero address', function () { it('reverts when the sender does not have enough balance', async function () { const value = balance + 1n; - await expect(this.transfer(this.initialHolder, this.recipient, value)) + await expect(this.transfer(this.holder, this.recipient, value)) .to.be.revertedWithCustomError(this.token, 'ERC20InsufficientBalance') - .withArgs(this.initialHolder, balance, value); + .withArgs(this.holder, balance, value); }); describe('when the sender transfers all balance', function () { const value = balance; beforeEach(async function () { - this.tx = await this.transfer(this.initialHolder, this.recipient, value); + this.tx = await this.transfer(this.holder, this.recipient, value); }); it('transfers the requested value', async function () { - await expect(this.tx).to.changeTokenBalances(this.token, [this.initialHolder, this.recipient], [-value, value]); + await expect(this.tx).to.changeTokenBalances(this.token, [this.holder, this.recipient], [-value, value]); }); it('emits a transfer event', async function () { - await expect(this.tx).to.emit(this.token, 'Transfer').withArgs(this.initialHolder, this.recipient, value); + await expect(this.tx).to.emit(this.token, 'Transfer').withArgs(this.holder, this.recipient, value); }); }); @@ -189,21 +177,21 @@ function shouldBehaveLikeERC20Transfer(balance) { const value = 0n; beforeEach(async function () { - this.tx = await this.transfer(this.initialHolder, this.recipient, value); + this.tx = await this.transfer(this.holder, this.recipient, value); }); it('transfers the requested value', async function () { - await expect(this.tx).to.changeTokenBalances(this.token, [this.initialHolder, this.recipient], [0n, 0n]); + await expect(this.tx).to.changeTokenBalances(this.token, [this.holder, this.recipient], [0n, 0n]); }); it('emits a transfer event', async function () { - await expect(this.tx).to.emit(this.token, 'Transfer').withArgs(this.initialHolder, this.recipient, value); + await expect(this.tx).to.emit(this.token, 'Transfer').withArgs(this.holder, this.recipient, value); }); }); }); it('reverts when the recipient is the zero address', async function () { - await expect(this.transfer(this.initialHolder, ethers.ZeroAddress, balance)) + await expect(this.transfer(this.holder, ethers.ZeroAddress, balance)) .to.be.revertedWithCustomError(this.token, 'ERC20InvalidReceiver') .withArgs(ethers.ZeroAddress); }); @@ -215,22 +203,22 @@ function shouldBehaveLikeERC20Approve(supply) { const value = supply; it('emits an approval event', async function () { - await expect(this.approve(this.initialHolder, this.recipient, value)) + await expect(this.approve(this.holder, this.recipient, value)) .to.emit(this.token, 'Approval') - .withArgs(this.initialHolder, this.recipient, value); + .withArgs(this.holder, this.recipient, value); }); it('approves the requested value when there was no approved value before', async function () { - await this.approve(this.initialHolder, this.recipient, value); + await this.approve(this.holder, this.recipient, value); - expect(await this.token.allowance(this.initialHolder, this.recipient)).to.equal(value); + expect(await this.token.allowance(this.holder, this.recipient)).to.equal(value); }); it('approves the requested value and replaces the previous one when the spender had an approved value', async function () { - await this.approve(this.initialHolder, this.recipient, 1n); - await this.approve(this.initialHolder, this.recipient, value); + await this.approve(this.holder, this.recipient, 1n); + await this.approve(this.holder, this.recipient, value); - expect(await this.token.allowance(this.initialHolder, this.recipient)).to.equal(value); + expect(await this.token.allowance(this.holder, this.recipient)).to.equal(value); }); }); @@ -238,28 +226,28 @@ function shouldBehaveLikeERC20Approve(supply) { const value = supply + 1n; it('emits an approval event', async function () { - await expect(this.approve(this.initialHolder, this.recipient, value)) + await expect(this.approve(this.holder, this.recipient, value)) .to.emit(this.token, 'Approval') - .withArgs(this.initialHolder, this.recipient, value); + .withArgs(this.holder, this.recipient, value); }); it('approves the requested value when there was no approved value before', async function () { - await this.approve(this.initialHolder, this.recipient, value); + await this.approve(this.holder, this.recipient, value); - expect(await this.token.allowance(this.initialHolder, this.recipient)).to.equal(value); + expect(await this.token.allowance(this.holder, this.recipient)).to.equal(value); }); it('approves the requested value and replaces the previous one when the spender had an approved value', async function () { - await this.approve(this.initialHolder, this.recipient, 1n); - await this.approve(this.initialHolder, this.recipient, value); + await this.approve(this.holder, this.recipient, 1n); + await this.approve(this.holder, this.recipient, value); - expect(await this.token.allowance(this.initialHolder, this.recipient)).to.equal(value); + expect(await this.token.allowance(this.holder, this.recipient)).to.equal(value); }); }); }); it('reverts when the spender is the zero address', async function () { - await expect(this.approve(this.initialHolder, ethers.ZeroAddress, supply)) + await expect(this.approve(this.holder, ethers.ZeroAddress, supply)) .to.be.revertedWithCustomError(this.token, `ERC20InvalidSpender`) .withArgs(ethers.ZeroAddress); }); diff --git a/test/token/ERC20/ERC20.test.js b/test/token/ERC20/ERC20.test.js index 7d584ce7b..2d9eefe1c 100644 --- a/test/token/ERC20/ERC20.test.js +++ b/test/token/ERC20/ERC20.test.js @@ -19,12 +19,14 @@ describe('ERC20', function () { for (const { Token, forcedApproval } of TOKENS) { describe(Token, function () { const fixture = async () => { - const [initialHolder, recipient, anotherAccount] = await ethers.getSigners(); + // this.accounts is used by shouldBehaveLikeERC20 + const accounts = await ethers.getSigners(); + const [holder, recipient] = accounts; const token = await ethers.deployContract(Token, [name, symbol]); - await token.$_mint(initialHolder, initialSupply); + await token.$_mint(holder, initialSupply); - return { initialHolder, recipient, anotherAccount, token }; + return { accounts, holder, recipient, token }; }; beforeEach(async function () { @@ -87,29 +89,27 @@ describe('ERC20', function () { describe('for a non zero account', function () { it('rejects burning more than balance', async function () { - await expect(this.token.$_burn(this.initialHolder, initialSupply + 1n)) + await expect(this.token.$_burn(this.holder, initialSupply + 1n)) .to.be.revertedWithCustomError(this.token, 'ERC20InsufficientBalance') - .withArgs(this.initialHolder, initialSupply, initialSupply + 1n); + .withArgs(this.holder, initialSupply, initialSupply + 1n); }); const describeBurn = function (description, value) { describe(description, function () { beforeEach('burning', async function () { - this.tx = await this.token.$_burn(this.initialHolder, value); + this.tx = await this.token.$_burn(this.holder, value); }); it('decrements totalSupply', async function () { expect(await this.token.totalSupply()).to.equal(initialSupply - value); }); - it('decrements initialHolder balance', async function () { - await expect(this.tx).to.changeTokenBalance(this.token, this.initialHolder, -value); + it('decrements holder balance', async function () { + await expect(this.tx).to.changeTokenBalance(this.token, this.holder, -value); }); it('emits Transfer event', async function () { - await expect(this.tx) - .to.emit(this.token, 'Transfer') - .withArgs(this.initialHolder, ethers.ZeroAddress, value); + await expect(this.tx).to.emit(this.token, 'Transfer').withArgs(this.holder, ethers.ZeroAddress, value); }); }); }; @@ -127,19 +127,19 @@ describe('ERC20', function () { }); it('from is the zero address', async function () { - const tx = await this.token.$_update(ethers.ZeroAddress, this.initialHolder, value); - await expect(tx).to.emit(this.token, 'Transfer').withArgs(ethers.ZeroAddress, this.initialHolder, value); + const tx = await this.token.$_update(ethers.ZeroAddress, this.holder, value); + await expect(tx).to.emit(this.token, 'Transfer').withArgs(ethers.ZeroAddress, this.holder, value); expect(await this.token.totalSupply()).to.equal(this.totalSupply + value); - await expect(tx).to.changeTokenBalance(this.token, this.initialHolder, value); + await expect(tx).to.changeTokenBalance(this.token, this.holder, value); }); it('to is the zero address', async function () { - const tx = await this.token.$_update(this.initialHolder, ethers.ZeroAddress, value); - await expect(tx).to.emit(this.token, 'Transfer').withArgs(this.initialHolder, ethers.ZeroAddress, value); + const tx = await this.token.$_update(this.holder, ethers.ZeroAddress, value); + await expect(tx).to.emit(this.token, 'Transfer').withArgs(this.holder, ethers.ZeroAddress, value); expect(await this.token.totalSupply()).to.equal(this.totalSupply - value); - await expect(tx).to.changeTokenBalance(this.token, this.initialHolder, -value); + await expect(tx).to.changeTokenBalance(this.token, this.holder, -value); }); describe('from and to are the same address', function () { @@ -159,9 +159,9 @@ describe('ERC20', function () { }); it('executes with balance', async function () { - const tx = await this.token.$_update(this.initialHolder, this.initialHolder, value); - await expect(tx).to.changeTokenBalance(this.token, this.initialHolder, 0n); - await expect(tx).to.emit(this.token, 'Transfer').withArgs(this.initialHolder, this.initialHolder, value); + const tx = await this.token.$_update(this.holder, this.holder, value); + await expect(tx).to.changeTokenBalance(this.token, this.holder, 0n); + await expect(tx).to.emit(this.token, 'Transfer').withArgs(this.holder, this.holder, value); }); }); }); diff --git a/test/token/ERC20/extensions/ERC1363.test.js b/test/token/ERC20/extensions/ERC1363.test.js new file mode 100644 index 000000000..3d1f4e58f --- /dev/null +++ b/test/token/ERC20/extensions/ERC1363.test.js @@ -0,0 +1,370 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { + shouldBehaveLikeERC20, + shouldBehaveLikeERC20Transfer, + shouldBehaveLikeERC20Approve, +} = require('../ERC20.behavior.js'); +const { shouldSupportInterfaces } = require('../../../utils/introspection/SupportsInterface.behavior'); +const { RevertType } = require('../../../helpers/enums.js'); + +const name = 'My Token'; +const symbol = 'MTKN'; +const value = 1000n; +const data = '0x123456'; + +async function fixture() { + // this.accounts is used by shouldBehaveLikeERC20 + const accounts = await ethers.getSigners(); + const [holder, other] = accounts; + + const receiver = await ethers.deployContract('ERC1363ReceiverMock'); + const spender = await ethers.deployContract('ERC1363SpenderMock'); + const token = await ethers.deployContract('$ERC1363', [name, symbol]); + + await token.$_mint(holder, value); + + return { + accounts, + holder, + other, + token, + receiver, + spender, + selectors: { + onTransferReceived: receiver.interface.getFunction('onTransferReceived(address,address,uint256,bytes)').selector, + onApprovalReceived: spender.interface.getFunction('onApprovalReceived(address,uint256,bytes)').selector, + }, + }; +} + +describe('ERC1363', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldSupportInterfaces(['ERC165', 'ERC1363']); + shouldBehaveLikeERC20(value); + + describe('transferAndCall', function () { + describe('as a transfer', function () { + beforeEach(async function () { + this.recipient = this.receiver; + this.transfer = (holder, ...rest) => + this.token.connect(holder).getFunction('transferAndCall(address,uint256)')(...rest); + }); + + shouldBehaveLikeERC20Transfer(value); + }); + + it('reverts transferring to an EOA', async function () { + await expect(this.token.connect(this.holder).getFunction('transferAndCall(address,uint256)')(this.other, value)) + .to.be.revertedWithCustomError(this.token, 'ERC1363InvalidReceiver') + .withArgs(this.other.address); + }); + + it('succeeds without data', async function () { + await expect( + this.token.connect(this.holder).getFunction('transferAndCall(address,uint256)')(this.receiver, value), + ) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder.address, this.receiver.target, value) + .to.emit(this.receiver, 'Received') + .withArgs(this.holder.address, this.holder.address, value, '0x'); + }); + + it('succeeds with data', async function () { + await expect( + this.token.connect(this.holder).getFunction('transferAndCall(address,uint256,bytes)')( + this.receiver, + value, + data, + ), + ) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder.address, this.receiver.target, value) + .to.emit(this.receiver, 'Received') + .withArgs(this.holder.address, this.holder.address, value, data); + }); + + it('reverts with reverting hook (without reason)', async function () { + await this.receiver.setUp(this.selectors.onTransferReceived, RevertType.RevertWithoutMessage); + + await expect( + this.token.connect(this.holder).getFunction('transferAndCall(address,uint256,bytes)')( + this.receiver, + value, + data, + ), + ) + .to.be.revertedWithCustomError(this.token, 'ERC1363InvalidReceiver') + .withArgs(this.receiver.target); + }); + + it('reverts with reverting hook (with reason)', async function () { + await this.receiver.setUp(this.selectors.onTransferReceived, RevertType.RevertWithMessage); + + await expect( + this.token.connect(this.holder).getFunction('transferAndCall(address,uint256,bytes)')( + this.receiver, + value, + data, + ), + ).to.be.revertedWith('ERC1363ReceiverMock: reverting'); + }); + + it('reverts with reverting hook (with custom error)', async function () { + const reason = '0x12345678'; + await this.receiver.setUp(reason, RevertType.RevertWithCustomError); + + await expect( + this.token.connect(this.holder).getFunction('transferAndCall(address,uint256,bytes)')( + this.receiver, + value, + data, + ), + ) + .to.be.revertedWithCustomError(this.receiver, 'CustomError') + .withArgs(reason); + }); + + it('panics with reverting hook (with panic)', async function () { + await this.receiver.setUp(this.selectors.onTransferReceived, RevertType.Panic); + + await expect( + this.token.connect(this.holder).getFunction('transferAndCall(address,uint256,bytes)')( + this.receiver, + value, + data, + ), + ).to.be.revertedWithPanic(); + }); + + it('reverts with bad return value', async function () { + await this.receiver.setUp('0x12345678', RevertType.None); + + await expect( + this.token.connect(this.holder).getFunction('transferAndCall(address,uint256,bytes)')( + this.receiver, + value, + data, + ), + ) + .to.be.revertedWithCustomError(this.token, 'ERC1363InvalidReceiver') + .withArgs(this.receiver.target); + }); + }); + + describe('transferFromAndCall', function () { + beforeEach(async function () { + await this.token.connect(this.holder).approve(this.other, ethers.MaxUint256); + }); + + describe('as a transfer', function () { + beforeEach(async function () { + this.recipient = this.receiver; + this.transfer = this.token.connect(this.other).getFunction('transferFromAndCall(address,address,uint256)'); + }); + + shouldBehaveLikeERC20Transfer(value); + }); + + it('reverts transferring to an EOA', async function () { + await expect( + this.token.connect(this.other).getFunction('transferFromAndCall(address,address,uint256)')( + this.holder, + this.other, + value, + ), + ) + .to.be.revertedWithCustomError(this.token, 'ERC1363InvalidReceiver') + .withArgs(this.other.address); + }); + + it('succeeds without data', async function () { + await expect( + this.token.connect(this.other).getFunction('transferFromAndCall(address,address,uint256)')( + this.holder, + this.receiver, + value, + ), + ) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder.address, this.receiver.target, value) + .to.emit(this.receiver, 'Received') + .withArgs(this.other.address, this.holder.address, value, '0x'); + }); + + it('succeeds with data', async function () { + await expect( + this.token.connect(this.other).getFunction('transferFromAndCall(address,address,uint256,bytes)')( + this.holder, + this.receiver, + value, + data, + ), + ) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder.address, this.receiver.target, value) + .to.emit(this.receiver, 'Received') + .withArgs(this.other.address, this.holder.address, value, data); + }); + + it('reverts with reverting hook (without reason)', async function () { + await this.receiver.setUp(this.selectors.onTransferReceived, RevertType.RevertWithoutMessage); + + await expect( + this.token.connect(this.other).getFunction('transferFromAndCall(address,address,uint256,bytes)')( + this.holder, + this.receiver, + value, + data, + ), + ) + .to.be.revertedWithCustomError(this.token, 'ERC1363InvalidReceiver') + .withArgs(this.receiver.target); + }); + + it('reverts with reverting hook (with reason)', async function () { + await this.receiver.setUp(this.selectors.onTransferReceived, RevertType.RevertWithMessage); + + await expect( + this.token.connect(this.other).getFunction('transferFromAndCall(address,address,uint256,bytes)')( + this.holder, + this.receiver, + value, + data, + ), + ).to.be.revertedWith('ERC1363ReceiverMock: reverting'); + }); + + it('reverts with reverting hook (with custom error)', async function () { + const reason = '0x12345678'; + await this.receiver.setUp(reason, RevertType.RevertWithCustomError); + + await expect( + this.token.connect(this.other).getFunction('transferFromAndCall(address,address,uint256,bytes)')( + this.holder, + this.receiver, + value, + data, + ), + ) + .to.be.revertedWithCustomError(this.receiver, 'CustomError') + .withArgs(reason); + }); + + it('panics with reverting hook (with panic)', async function () { + await this.receiver.setUp(this.selectors.onTransferReceived, RevertType.Panic); + + await expect( + this.token.connect(this.other).getFunction('transferFromAndCall(address,address,uint256,bytes)')( + this.holder, + this.receiver, + value, + data, + ), + ).to.be.revertedWithPanic(); + }); + + it('reverts with bad return value', async function () { + await this.receiver.setUp('0x12345678', RevertType.None); + + await expect( + this.token.connect(this.other).getFunction('transferFromAndCall(address,address,uint256,bytes)')( + this.holder, + this.receiver, + value, + data, + ), + ) + .to.be.revertedWithCustomError(this.token, 'ERC1363InvalidReceiver') + .withArgs(this.receiver.target); + }); + }); + + describe('approveAndCall', function () { + describe('as an approval', function () { + beforeEach(async function () { + this.recipient = this.spender; + this.approve = (holder, ...rest) => + this.token.connect(holder).getFunction('approveAndCall(address,uint256)')(...rest); + }); + + shouldBehaveLikeERC20Approve(value); + }); + + it('reverts approving an EOA', async function () { + await expect(this.token.connect(this.holder).getFunction('approveAndCall(address,uint256)')(this.other, value)) + .to.be.revertedWithCustomError(this.token, 'ERC1363InvalidSpender') + .withArgs(this.other.address); + }); + + it('succeeds without data', async function () { + await expect(this.token.connect(this.holder).getFunction('approveAndCall(address,uint256)')(this.spender, value)) + .to.emit(this.token, 'Approval') + .withArgs(this.holder.address, this.spender.target, value) + .to.emit(this.spender, 'Approved') + .withArgs(this.holder.address, value, '0x'); + }); + + it('succeeds with data', async function () { + await expect( + this.token.connect(this.holder).getFunction('approveAndCall(address,uint256,bytes)')(this.spender, value, data), + ) + .to.emit(this.token, 'Approval') + .withArgs(this.holder.address, this.spender.target, value) + .to.emit(this.spender, 'Approved') + .withArgs(this.holder.address, value, data); + }); + + it('with reverting hook (without reason)', async function () { + await this.spender.setUp(this.selectors.onApprovalReceived, RevertType.RevertWithoutMessage); + + await expect( + this.token.connect(this.holder).getFunction('approveAndCall(address,uint256,bytes)')(this.spender, value, data), + ) + .to.be.revertedWithCustomError(this.token, 'ERC1363InvalidSpender') + .withArgs(this.spender.target); + }); + + it('reverts with reverting hook (with reason)', async function () { + await this.spender.setUp(this.selectors.onApprovalReceived, RevertType.RevertWithMessage); + + await expect( + this.token.connect(this.holder).getFunction('approveAndCall(address,uint256,bytes)')(this.spender, value, data), + ).to.be.revertedWith('ERC1363SpenderMock: reverting'); + }); + + it('reverts with reverting hook (with custom error)', async function () { + const reason = '0x12345678'; + await this.spender.setUp(reason, RevertType.RevertWithCustomError); + + await expect( + this.token.connect(this.holder).getFunction('approveAndCall(address,uint256,bytes)')(this.spender, value, data), + ) + .to.be.revertedWithCustomError(this.spender, 'CustomError') + .withArgs(reason); + }); + + it('panics with reverting hook (with panic)', async function () { + await this.spender.setUp(this.selectors.onApprovalReceived, RevertType.Panic); + + await expect( + this.token.connect(this.holder).getFunction('approveAndCall(address,uint256,bytes)')(this.spender, value, data), + ).to.be.revertedWithPanic(); + }); + + it('reverts with bad return value', async function () { + await this.spender.setUp('0x12345678', RevertType.None); + + await expect( + this.token.connect(this.holder).getFunction('approveAndCall(address,uint256,bytes)')(this.spender, value, data), + ) + .to.be.revertedWithCustomError(this.token, 'ERC1363InvalidSpender') + .withArgs(this.spender.target); + }); + }); +}); diff --git a/test/token/ERC20/extensions/ERC20FlashMint.test.js b/test/token/ERC20/extensions/ERC20FlashMint.test.js index 745cd11a9..1c751f74c 100644 --- a/test/token/ERC20/extensions/ERC20FlashMint.test.js +++ b/test/token/ERC20/extensions/ERC20FlashMint.test.js @@ -8,12 +8,12 @@ const initialSupply = 100n; const loanValue = 10_000_000_000_000n; async function fixture() { - const [initialHolder, other, anotherAccount] = await ethers.getSigners(); + const [holder, other] = await ethers.getSigners(); const token = await ethers.deployContract('$ERC20FlashMintMock', [name, symbol]); - await token.$_mint(initialHolder, initialSupply); + await token.$_mint(holder, initialSupply); - return { initialHolder, other, anotherAccount, token }; + return { holder, other, token }; } describe('ERC20FlashMint', function () { @@ -134,7 +134,7 @@ describe('ERC20FlashMint', function () { }); it('custom flash fee receiver', async function () { - const flashFeeReceiverAddress = this.anotherAccount; + const flashFeeReceiverAddress = this.other; await this.token.setFlashFeeReceiver(flashFeeReceiverAddress); expect(await this.token.$_flashFeeReceiver()).to.equal(flashFeeReceiverAddress); diff --git a/test/token/ERC20/extensions/ERC20Permit.test.js b/test/token/ERC20/extensions/ERC20Permit.test.js index 8336af2d9..c3c80d7bb 100644 --- a/test/token/ERC20/extensions/ERC20Permit.test.js +++ b/test/token/ERC20/extensions/ERC20Permit.test.js @@ -10,13 +10,13 @@ const symbol = 'MTKN'; const initialSupply = 100n; async function fixture() { - const [initialHolder, spender, owner, other] = await ethers.getSigners(); + const [holder, spender, owner, other] = await ethers.getSigners(); const token = await ethers.deployContract('$ERC20Permit', [name, symbol, name]); - await token.$_mint(initialHolder, initialSupply); + await token.$_mint(holder, initialSupply); return { - initialHolder, + holder, spender, owner, other, @@ -30,7 +30,7 @@ describe('ERC20Permit', function () { }); it('initial nonce is 0', async function () { - expect(await this.token.nonces(this.initialHolder)).to.equal(0n); + expect(await this.token.nonces(this.holder)).to.equal(0n); }); it('domain separator', async function () { diff --git a/test/token/ERC20/extensions/ERC20Wrapper.test.js b/test/token/ERC20/extensions/ERC20Wrapper.test.js index 14d8d9a64..9e72e1a92 100644 --- a/test/token/ERC20/extensions/ERC20Wrapper.test.js +++ b/test/token/ERC20/extensions/ERC20Wrapper.test.js @@ -10,14 +10,16 @@ const decimals = 9n; const initialSupply = 100n; async function fixture() { - const [initialHolder, recipient, anotherAccount] = await ethers.getSigners(); + // this.accounts is used by shouldBehaveLikeERC20 + const accounts = await ethers.getSigners(); + const [holder, recipient, other] = accounts; const underlying = await ethers.deployContract('$ERC20DecimalsMock', [name, symbol, decimals]); - await underlying.$_mint(initialHolder, initialSupply); + await underlying.$_mint(holder, initialSupply); const token = await ethers.deployContract('$ERC20Wrapper', [`Wrapped ${name}`, `W${symbol}`, underlying]); - return { initialHolder, recipient, anotherAccount, underlying, token }; + return { accounts, holder, recipient, other, underlying, token }; } describe('ERC20Wrapper', function () { @@ -53,57 +55,57 @@ describe('ERC20Wrapper', function () { describe('deposit', function () { it('executes with approval', async function () { - await this.underlying.connect(this.initialHolder).approve(this.token, initialSupply); + await this.underlying.connect(this.holder).approve(this.token, initialSupply); - const tx = await this.token.connect(this.initialHolder).depositFor(this.initialHolder, initialSupply); + const tx = await this.token.connect(this.holder).depositFor(this.holder, initialSupply); await expect(tx) .to.emit(this.underlying, 'Transfer') - .withArgs(this.initialHolder, this.token, initialSupply) + .withArgs(this.holder, this.token, initialSupply) .to.emit(this.token, 'Transfer') - .withArgs(ethers.ZeroAddress, this.initialHolder, initialSupply); + .withArgs(ethers.ZeroAddress, this.holder, initialSupply); await expect(tx).to.changeTokenBalances( this.underlying, - [this.initialHolder, this.token], + [this.holder, this.token], [-initialSupply, initialSupply], ); - await expect(tx).to.changeTokenBalance(this.token, this.initialHolder, initialSupply); + await expect(tx).to.changeTokenBalance(this.token, this.holder, initialSupply); }); it('reverts when missing approval', async function () { - await expect(this.token.connect(this.initialHolder).depositFor(this.initialHolder, initialSupply)) + await expect(this.token.connect(this.holder).depositFor(this.holder, initialSupply)) .to.be.revertedWithCustomError(this.underlying, 'ERC20InsufficientAllowance') .withArgs(this.token, 0, initialSupply); }); it('reverts when inssuficient balance', async function () { - await this.underlying.connect(this.initialHolder).approve(this.token, ethers.MaxUint256); + await this.underlying.connect(this.holder).approve(this.token, ethers.MaxUint256); - await expect(this.token.connect(this.initialHolder).depositFor(this.initialHolder, ethers.MaxUint256)) + await expect(this.token.connect(this.holder).depositFor(this.holder, ethers.MaxUint256)) .to.be.revertedWithCustomError(this.underlying, 'ERC20InsufficientBalance') - .withArgs(this.initialHolder, initialSupply, ethers.MaxUint256); + .withArgs(this.holder, initialSupply, ethers.MaxUint256); }); it('deposits to other account', async function () { - await this.underlying.connect(this.initialHolder).approve(this.token, initialSupply); + await this.underlying.connect(this.holder).approve(this.token, initialSupply); - const tx = await this.token.connect(this.initialHolder).depositFor(this.recipient, initialSupply); + const tx = await this.token.connect(this.holder).depositFor(this.recipient, initialSupply); await expect(tx) .to.emit(this.underlying, 'Transfer') - .withArgs(this.initialHolder, this.token, initialSupply) + .withArgs(this.holder, this.token.target, initialSupply) .to.emit(this.token, 'Transfer') .withArgs(ethers.ZeroAddress, this.recipient, initialSupply); await expect(tx).to.changeTokenBalances( this.underlying, - [this.initialHolder, this.token], + [this.holder, this.token], [-initialSupply, initialSupply], ); - await expect(tx).to.changeTokenBalances(this.token, [this.initialHolder, this.recipient], [0, initialSupply]); + await expect(tx).to.changeTokenBalances(this.token, [this.holder, this.recipient], [0, initialSupply]); }); it('reverts minting to the wrapper contract', async function () { - await this.underlying.connect(this.initialHolder).approve(this.token, ethers.MaxUint256); + await this.underlying.connect(this.holder).approve(this.token, ethers.MaxUint256); - await expect(this.token.connect(this.initialHolder).depositFor(this.token, ethers.MaxUint256)) + await expect(this.token.connect(this.holder).depositFor(this.token, ethers.MaxUint256)) .to.be.revertedWithCustomError(this.token, 'ERC20InvalidReceiver') .withArgs(this.token); }); @@ -111,61 +113,61 @@ describe('ERC20Wrapper', function () { describe('withdraw', function () { beforeEach(async function () { - await this.underlying.connect(this.initialHolder).approve(this.token, initialSupply); - await this.token.connect(this.initialHolder).depositFor(this.initialHolder, initialSupply); + await this.underlying.connect(this.holder).approve(this.token, initialSupply); + await this.token.connect(this.holder).depositFor(this.holder, initialSupply); }); it('reverts when inssuficient balance', async function () { - await expect(this.token.connect(this.initialHolder).withdrawTo(this.initialHolder, ethers.MaxInt256)) + await expect(this.token.connect(this.holder).withdrawTo(this.holder, ethers.MaxInt256)) .to.be.revertedWithCustomError(this.token, 'ERC20InsufficientBalance') - .withArgs(this.initialHolder, initialSupply, ethers.MaxInt256); + .withArgs(this.holder, initialSupply, ethers.MaxInt256); }); it('executes when operation is valid', async function () { const value = 42n; - const tx = await this.token.connect(this.initialHolder).withdrawTo(this.initialHolder, value); + const tx = await this.token.connect(this.holder).withdrawTo(this.holder, value); await expect(tx) .to.emit(this.underlying, 'Transfer') - .withArgs(this.token, this.initialHolder, value) + .withArgs(this.token.target, this.holder, value) .to.emit(this.token, 'Transfer') - .withArgs(this.initialHolder, ethers.ZeroAddress, value); - await expect(tx).to.changeTokenBalances(this.underlying, [this.token, this.initialHolder], [-value, value]); - await expect(tx).to.changeTokenBalance(this.token, this.initialHolder, -value); + .withArgs(this.holder, ethers.ZeroAddress, value); + await expect(tx).to.changeTokenBalances(this.underlying, [this.token, this.holder], [-value, value]); + await expect(tx).to.changeTokenBalance(this.token, this.holder, -value); }); it('entire balance', async function () { - const tx = await this.token.connect(this.initialHolder).withdrawTo(this.initialHolder, initialSupply); + const tx = await this.token.connect(this.holder).withdrawTo(this.holder, initialSupply); await expect(tx) .to.emit(this.underlying, 'Transfer') - .withArgs(this.token, this.initialHolder, initialSupply) + .withArgs(this.token.target, this.holder, initialSupply) .to.emit(this.token, 'Transfer') - .withArgs(this.initialHolder, ethers.ZeroAddress, initialSupply); + .withArgs(this.holder, ethers.ZeroAddress, initialSupply); await expect(tx).to.changeTokenBalances( this.underlying, - [this.token, this.initialHolder], + [this.token, this.holder], [-initialSupply, initialSupply], ); - await expect(tx).to.changeTokenBalance(this.token, this.initialHolder, -initialSupply); + await expect(tx).to.changeTokenBalance(this.token, this.holder, -initialSupply); }); it('to other account', async function () { - const tx = await this.token.connect(this.initialHolder).withdrawTo(this.recipient, initialSupply); + const tx = await this.token.connect(this.holder).withdrawTo(this.recipient, initialSupply); await expect(tx) .to.emit(this.underlying, 'Transfer') .withArgs(this.token, this.recipient, initialSupply) .to.emit(this.token, 'Transfer') - .withArgs(this.initialHolder, ethers.ZeroAddress, initialSupply); + .withArgs(this.holder, ethers.ZeroAddress, initialSupply); await expect(tx).to.changeTokenBalances( this.underlying, - [this.token, this.initialHolder, this.recipient], + [this.token, this.holder, this.recipient], [-initialSupply, 0, initialSupply], ); - await expect(tx).to.changeTokenBalance(this.token, this.initialHolder, -initialSupply); + await expect(tx).to.changeTokenBalance(this.token, this.holder, -initialSupply); }); it('reverts withdrawing to the wrapper contract', async function () { - await expect(this.token.connect(this.initialHolder).withdrawTo(this.token, initialSupply)) + await expect(this.token.connect(this.holder).withdrawTo(this.token, initialSupply)) .to.be.revertedWithCustomError(this.token, 'ERC20InvalidReceiver') .withArgs(this.token); }); @@ -173,8 +175,8 @@ describe('ERC20Wrapper', function () { describe('recover', function () { it('nothing to recover', async function () { - await this.underlying.connect(this.initialHolder).approve(this.token, initialSupply); - await this.token.connect(this.initialHolder).depositFor(this.initialHolder, initialSupply); + await this.underlying.connect(this.holder).approve(this.token, initialSupply); + await this.token.connect(this.holder).depositFor(this.holder, initialSupply); const tx = await this.token.$_recover(this.recipient); await expect(tx).to.emit(this.token, 'Transfer').withArgs(ethers.ZeroAddress, this.recipient, 0n); @@ -182,7 +184,7 @@ describe('ERC20Wrapper', function () { }); it('something to recover', async function () { - await this.underlying.connect(this.initialHolder).transfer(this.token, initialSupply); + await this.underlying.connect(this.holder).transfer(this.token, initialSupply); const tx = await this.token.$_recover(this.recipient); await expect(tx).to.emit(this.token, 'Transfer').withArgs(ethers.ZeroAddress, this.recipient, initialSupply); @@ -192,8 +194,8 @@ describe('ERC20Wrapper', function () { describe('erc20 behaviour', function () { beforeEach(async function () { - await this.underlying.connect(this.initialHolder).approve(this.token, initialSupply); - await this.token.connect(this.initialHolder).depositFor(this.initialHolder, initialSupply); + await this.underlying.connect(this.holder).approve(this.token, initialSupply); + await this.token.connect(this.holder).depositFor(this.holder, initialSupply); }); shouldBehaveLikeERC20(initialSupply); diff --git a/test/token/ERC20/utils/SafeERC20.test.js b/test/token/ERC20/utils/SafeERC20.test.js index 703fcd57b..60bcc5546 100644 --- a/test/token/ERC20/utils/SafeERC20.test.js +++ b/test/token/ERC20/utils/SafeERC20.test.js @@ -4,26 +4,43 @@ const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const name = 'ERC20Mock'; const symbol = 'ERC20Mock'; +const value = 100n; +const data = '0x12345678'; async function fixture() { - const [hasNoCode, owner, receiver, spender] = await ethers.getSigners(); + const [hasNoCode, owner, receiver, spender, other] = await ethers.getSigners(); const mock = await ethers.deployContract('$SafeERC20'); const erc20ReturnFalseMock = await ethers.deployContract('$ERC20ReturnFalseMock', [name, symbol]); const erc20ReturnTrueMock = await ethers.deployContract('$ERC20', [name, symbol]); // default implementation returns true const erc20NoReturnMock = await ethers.deployContract('$ERC20NoReturnMock', [name, symbol]); const erc20ForceApproveMock = await ethers.deployContract('$ERC20ForceApproveMock', [name, symbol]); + const erc1363Mock = await ethers.deployContract('$ERC1363', [name, symbol]); + const erc1363ReturnFalseOnErc20Mock = await ethers.deployContract('$ERC1363ReturnFalseOnERC20Mock', [name, symbol]); + const erc1363ReturnFalseMock = await ethers.deployContract('$ERC1363ReturnFalseMock', [name, symbol]); + const erc1363NoReturnMock = await ethers.deployContract('$ERC1363NoReturnMock', [name, symbol]); + const erc1363ForceApproveMock = await ethers.deployContract('$ERC1363ForceApproveMock', [name, symbol]); + const erc1363Receiver = await ethers.deployContract('$ERC1363ReceiverMock'); + const erc1363Spender = await ethers.deployContract('$ERC1363SpenderMock'); return { hasNoCode, owner, receiver, spender, + other, mock, erc20ReturnFalseMock, erc20ReturnTrueMock, erc20NoReturnMock, erc20ForceApproveMock, + erc1363Mock, + erc1363ReturnFalseOnErc20Mock, + erc1363ReturnFalseMock, + erc1363NoReturnMock, + erc1363ForceApproveMock, + erc1363Receiver, + erc1363Spender, }; } @@ -118,7 +135,7 @@ describe('SafeERC20', function () { shouldOnlyRevertOnErrors(); }); - describe('with usdt approval beaviour', function () { + describe('with usdt approval behaviour', function () { beforeEach(async function () { this.token = this.erc20ForceApproveMock; }); @@ -144,6 +161,186 @@ describe('SafeERC20', function () { }); }); }); + + describe('with standard ERC1363', function () { + beforeEach(async function () { + this.token = this.erc1363Mock; + }); + + shouldOnlyRevertOnErrors(); + + describe('transferAndCall', function () { + it('cannot transferAndCall to an EOA directly', async function () { + await this.token.$_mint(this.owner, 100n); + + await expect(this.token.connect(this.owner).transferAndCall(this.receiver, value, ethers.Typed.bytes(data))) + .to.be.revertedWithCustomError(this.token, 'ERC1363InvalidReceiver') + .withArgs(this.receiver); + }); + + it('can transferAndCall to an EOA using helper', async function () { + await this.token.$_mint(this.mock, value); + + await expect(this.mock.$transferAndCallRelaxed(this.token, this.receiver, value, data)) + .to.emit(this.token, 'Transfer') + .withArgs(this.mock, this.receiver, value); + }); + + it('can transferAndCall to an ERC1363Receiver using helper', async function () { + await this.token.$_mint(this.mock, value); + + await expect(this.mock.$transferAndCallRelaxed(this.token, this.erc1363Receiver, value, data)) + .to.emit(this.token, 'Transfer') + .withArgs(this.mock, this.erc1363Receiver, value) + .to.emit(this.erc1363Receiver, 'Received') + .withArgs(this.mock, this.mock, value, data); + }); + }); + + describe('transferFromAndCall', function () { + it('can transferFromAndCall to an EOA using helper', async function () { + await this.token.$_mint(this.owner, value); + await this.token.$_approve(this.owner, this.mock, ethers.MaxUint256); + + await expect(this.mock.$transferFromAndCallRelaxed(this.token, this.owner, this.receiver, value, data)) + .to.emit(this.token, 'Transfer') + .withArgs(this.owner, this.receiver, value); + }); + + it('can transferFromAndCall to an ERC1363Receiver using helper', async function () { + await this.token.$_mint(this.owner, value); + await this.token.$_approve(this.owner, this.mock, ethers.MaxUint256); + + await expect(this.mock.$transferFromAndCallRelaxed(this.token, this.owner, this.erc1363Receiver, value, data)) + .to.emit(this.token, 'Transfer') + .withArgs(this.owner, this.erc1363Receiver, value) + .to.emit(this.erc1363Receiver, 'Received') + .withArgs(this.mock, this.owner, value, data); + }); + }); + + describe('approveAndCall', function () { + it('can approveAndCall to an EOA using helper', async function () { + await expect(this.mock.$approveAndCallRelaxed(this.token, this.receiver, value, data)) + .to.emit(this.token, 'Approval') + .withArgs(this.mock, this.receiver, value); + }); + + it('can approveAndCall to an ERC1363Spender using helper', async function () { + await expect(this.mock.$approveAndCallRelaxed(this.token, this.erc1363Spender, value, data)) + .to.emit(this.token, 'Approval') + .withArgs(this.mock, this.erc1363Spender, value) + .to.emit(this.erc1363Spender, 'Approved') + .withArgs(this.mock, value, data); + }); + }); + }); + + describe('with ERC1363 that returns false on all ERC20 calls', function () { + beforeEach(async function () { + this.token = this.erc1363ReturnFalseOnErc20Mock; + }); + + it('reverts on transferAndCallRelaxed', async function () { + await expect(this.mock.$transferAndCallRelaxed(this.token, this.erc1363Receiver, 0n, data)) + .to.be.revertedWithCustomError(this.token, 'ERC1363TransferFailed') + .withArgs(this.erc1363Receiver, 0n); + }); + + it('reverts on transferFromAndCallRelaxed', async function () { + await expect(this.mock.$transferFromAndCallRelaxed(this.token, this.mock, this.erc1363Receiver, 0n, data)) + .to.be.revertedWithCustomError(this.token, 'ERC1363TransferFromFailed') + .withArgs(this.mock, this.erc1363Receiver, 0n); + }); + + it('reverts on approveAndCallRelaxed', async function () { + await expect(this.mock.$approveAndCallRelaxed(this.token, this.erc1363Spender, 0n, data)) + .to.be.revertedWithCustomError(this.token, 'ERC1363ApproveFailed') + .withArgs(this.erc1363Spender, 0n); + }); + }); + + describe('with ERC1363 that returns false on all ERC1363 calls', function () { + beforeEach(async function () { + this.token = this.erc1363ReturnFalseMock; + }); + + it('reverts on transferAndCallRelaxed', async function () { + await expect(this.mock.$transferAndCallRelaxed(this.token, this.erc1363Receiver, 0n, data)) + .to.be.revertedWithCustomError(this.mock, 'SafeERC20FailedOperation') + .withArgs(this.token); + }); + + it('reverts on transferFromAndCallRelaxed', async function () { + await expect(this.mock.$transferFromAndCallRelaxed(this.token, this.mock, this.erc1363Receiver, 0n, data)) + .to.be.revertedWithCustomError(this.mock, 'SafeERC20FailedOperation') + .withArgs(this.token); + }); + + it('reverts on approveAndCallRelaxed', async function () { + await expect(this.mock.$approveAndCallRelaxed(this.token, this.erc1363Spender, 0n, data)) + .to.be.revertedWithCustomError(this.mock, 'SafeERC20FailedOperation') + .withArgs(this.token); + }); + }); + + describe('with ERC1363 that returns no boolean values', function () { + beforeEach(async function () { + this.token = this.erc1363NoReturnMock; + }); + + it('reverts on transferAndCallRelaxed', async function () { + await expect( + this.mock.$transferAndCallRelaxed(this.token, this.erc1363Receiver, 0n, data), + ).to.be.revertedWithoutReason(); + }); + + it('reverts on transferFromAndCallRelaxed', async function () { + await expect( + this.mock.$transferFromAndCallRelaxed(this.token, this.mock, this.erc1363Receiver, 0n, data), + ).to.be.revertedWithoutReason(); + }); + + it('reverts on approveAndCallRelaxed', async function () { + await expect( + this.mock.$approveAndCallRelaxed(this.token, this.erc1363Spender, 0n, data), + ).to.be.revertedWithoutReason(); + }); + }); + + describe('with ERC1363 with usdt approval behaviour', function () { + beforeEach(async function () { + this.token = this.erc1363ForceApproveMock; + }); + + describe('without initial approval', function () { + it('approveAndCallRelaxed works when recipient is an EOA', async function () { + await this.mock.$approveAndCallRelaxed(this.token, this.spender, 10n, data); + expect(await this.token.allowance(this.mock, this.spender)).to.equal(10n); + }); + + it('approveAndCallRelaxed works when recipient is a contract', async function () { + await this.mock.$approveAndCallRelaxed(this.token, this.erc1363Spender, 10n, data); + expect(await this.token.allowance(this.mock, this.erc1363Spender)).to.equal(10n); + }); + }); + + describe('with initial approval', function () { + it('approveAndCallRelaxed works when recipient is an EOA', async function () { + await this.token.$_approve(this.mock, this.spender, 100n); + + await this.mock.$approveAndCallRelaxed(this.token, this.spender, 10n, data); + expect(await this.token.allowance(this.mock, this.spender)).to.equal(10n); + }); + + it('approveAndCallRelaxed reverts when recipient is a contract', async function () { + await this.token.$_approve(this.mock, this.erc1363Spender, 100n); + await expect(this.mock.$approveAndCallRelaxed(this.token, this.erc1363Spender, 10n, data)).to.be.revertedWith( + 'USDT approval failure', + ); + }); + }); + }); }); function shouldOnlyRevertOnErrors() { diff --git a/test/utils/introspection/SupportsInterface.behavior.js b/test/utils/introspection/SupportsInterface.behavior.js index 5b52a7946..c2bd1a479 100644 --- a/test/utils/introspection/SupportsInterface.behavior.js +++ b/test/utils/introspection/SupportsInterface.behavior.js @@ -31,6 +31,14 @@ const SIGNATURES = { 'onERC1155Received(address,address,uint256,uint256,bytes)', 'onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)', ], + ERC1363: [ + 'transferAndCall(address,uint256)', + 'transferAndCall(address,uint256,bytes)', + 'transferFromAndCall(address,address,uint256)', + 'transferFromAndCall(address,address,uint256,bytes)', + 'approveAndCall(address,uint256)', + 'approveAndCall(address,uint256,bytes)', + ], AccessControl: [ 'hasRole(bytes32,address)', 'getRoleAdmin(bytes32)',