Explorar o código

Add ERC1363 implementation (#4631)

Co-authored-by: Hadrien Croubois <hadrien.croubois@gmail.com>
Co-authored-by: ernestognw <ernestognw@gmail.com>
Vittorio Minacori hai 1 ano
pai
achega
e5f02bc608

+ 5 - 0
.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.

+ 5 - 0
.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.

+ 46 - 40
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);
 }

+ 17 - 20
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);
 }

+ 15 - 18
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);
 }

+ 14 - 0
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);
+    }
+}

+ 34 - 0
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)
+        }
+    }
+}

+ 52 - 0
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;
+    }
+}

+ 34 - 0
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;
+    }
+}

+ 47 - 0
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;
+    }
+}

+ 3 - 0
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

+ 198 - 0
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))
+                }
+            }
+        }
+    }
+}

+ 56 - 1
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).

+ 50 - 62
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);
   });

+ 20 - 20
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);
             });
           });
         });

+ 370 - 0
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);
+    });
+  });
+});

+ 4 - 4
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);
 

+ 4 - 4
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 () {

+ 46 - 44
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);

+ 199 - 2
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() {

+ 8 - 0
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)',