diff --git a/.changeset/tasty-apples-serve.md b/.changeset/tasty-apples-serve.md new file mode 100644 index 00000000000..83d242c40fa --- /dev/null +++ b/.changeset/tasty-apples-serve.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`ERC1363`: Add an extension to `ERC20` for performing transferAndCall & approveAndCall operations following ERC-1363. diff --git a/contracts/interfaces/IERC1363.sol b/contracts/interfaces/IERC1363.sol index 1a8dc79f438..0211857da81 100644 --- a/contracts/interfaces/IERC1363.sol +++ b/contracts/interfaces/IERC1363.sol @@ -6,19 +6,14 @@ pragma solidity ^0.8.0; import "./IERC20.sol"; import "./IERC165.sol"; -interface IERC1363 is IERC165, IERC20 { +interface IERC1363 is IERC20, IERC165 { /* - * Note: the ERC-165 identifier for this interface is 0x4bbee2df. - * 0x4bbee2df === + * Note: the ERC-165 identifier for this interface is 0xb0202a11. + * 0xb0202a11 === * bytes4(keccak256('transferAndCall(address,uint256)')) ^ * bytes4(keccak256('transferAndCall(address,uint256,bytes)')) ^ * bytes4(keccak256('transferFromAndCall(address,address,uint256)')) ^ - * bytes4(keccak256('transferFromAndCall(address,address,uint256,bytes)')) - */ - - /* - * Note: the ERC-165 identifier for this interface is 0xfb9ec8ce. - * 0xfb9ec8ce === + * bytes4(keccak256('transferFromAndCall(address,address,uint256,bytes)')) ^ * bytes4(keccak256('approveAndCall(address,uint256)')) ^ * bytes4(keccak256('approveAndCall(address,uint256,bytes)')) */ diff --git a/contracts/mocks/token/ERC1363ReceiverMock.sol b/contracts/mocks/token/ERC1363ReceiverMock.sol new file mode 100644 index 00000000000..f20b44b375e --- /dev/null +++ b/contracts/mocks/token/ERC1363ReceiverMock.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "../../interfaces/IERC1363Receiver.sol"; +import "../../interfaces/IERC1363Spender.sol"; + +contract ERC1363ReceiverMock is IERC1363Receiver, IERC1363Spender { + event TransferReceived(address operator, address from, uint256 value, bytes data); + event ApprovalReceived(address owner, uint256 value, bytes data); + + function onTransferReceived( + address operator, + address from, + uint256 value, + bytes memory data + ) external override returns (bytes4) { + if (data.length == 1) { + if (data[0] == 0x00) return bytes4(0); + if (data[0] == 0x01) revert("onTransferReceived revert"); + if (data[0] == 0x02) revert(); + if (data[0] == 0x03) assert(false); + } + emit TransferReceived(operator, from, value, data); + return this.onTransferReceived.selector; + } + + function onApprovalReceived(address owner, uint256 value, bytes memory data) external override returns (bytes4) { + if (data.length == 1) { + if (data[0] == 0x00) return bytes4(0); + if (data[0] == 0x01) revert("onApprovalReceived revert"); + if (data[0] == 0x02) revert(); + if (data[0] == 0x03) assert(false); + } + emit ApprovalReceived(owner, value, data); + return this.onApprovalReceived.selector; + } +} diff --git a/contracts/token/ERC20/extensions/ERC1363.sol b/contracts/token/ERC20/extensions/ERC1363.sol new file mode 100644 index 00000000000..1a50208b5c3 --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC1363.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "../ERC20.sol"; +import "../../../interfaces/IERC1363.sol"; +import "../../../interfaces/IERC1363Receiver.sol"; +import "../../../interfaces/IERC1363Spender.sol"; +import "../../../utils/introspection/ERC165.sol"; +import "../../../utils/Address.sol"; + +abstract contract ERC1363 is IERC1363, ERC20, ERC165 { + using Address for address; + + /** + * @dev See {IERC165-supportsInterface}. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override(IERC165, ERC165) returns (bool) { + return interfaceId == type(IERC1363).interfaceId || super.supportsInterface(interfaceId); + } + + /** + * @dev See {IERC1363-transferAndCall}. + */ + function transferAndCall(address to, uint256 value) public override returns (bool) { + return transferAndCall(to, value, bytes("")); + } + + /** + * @dev See {IERC1363-transferAndCall}. + */ + function transferAndCall(address to, uint256 value, bytes memory data) public override returns (bool) { + require(transfer(to, value)); + require( + _checkOnTransferReceived(_msgSender(), _msgSender(), to, value, data), + "ERC1363: transfer to non ERC1363Receiver implementer" + ); + return true; + } + + /** + * @dev See {IERC1363-transferFromAndCall}. + */ + function transferFromAndCall(address from, address to, uint256 value) public override returns (bool) { + return transferFromAndCall(from, to, value, bytes("")); + } + + /** + * @dev See {IERC1363-transferFromAndCall}. + */ + function transferFromAndCall( + address from, + address to, + uint256 value, + bytes memory data + ) public override returns (bool) { + require(transferFrom(from, to, value)); + require( + _checkOnTransferReceived(_msgSender(), from, to, value, data), + "ERC1363: transfer to non ERC1363Receiver implementer" + ); + return true; + } + + /** + * @dev See {IERC1363-approveAndCall}. + */ + function approveAndCall(address spender, uint256 value) public override returns (bool) { + return approveAndCall(spender, value, bytes("")); + } + + /** + * @dev See {IERC1363-approveAndCall}. + */ + function approveAndCall(address spender, uint256 value, bytes memory data) public override returns (bool) { + require(approve(spender, value)); + require( + _checkOnApprovalReceived(_msgSender(), spender, value, data), + "ERC1363: transfer to non ERC1363Spender implementer" + ); + return true; + } + + /** + * @dev Internal function to invoke {IERC1363Receiver-onTransferReceived} on a target address. + * The call is not executed if the target address is not a contract. + */ + function _checkOnTransferReceived( + address operator, + address from, + address to, + uint256 value, + bytes memory data + ) private returns (bool) { + try IERC1363Receiver(to).onTransferReceived(operator, from, value, data) returns (bytes4 retval) { + return retval == IERC1363Receiver.onTransferReceived.selector; + } catch (bytes memory reason) { + if (reason.length == 0) { + revert("ERC1363: transfer to non ERC1363Receiver implementer"); + } else { + /// @solidity memory-safe-assembly + assembly { + revert(add(32, reason), mload(reason)) + } + } + } + } + + /** + * @dev Internal function to invoke {IERC1363Spender-onApprovalReceived} on a target address. + * The call is not executed if the target address is not a contract. + */ + function _checkOnApprovalReceived( + address owner, + address spender, + uint256 value, + bytes memory data + ) private returns (bool) { + try IERC1363Spender(spender).onApprovalReceived(owner, value, data) returns (bytes4 retval) { + return retval == IERC1363Spender.onApprovalReceived.selector; + } catch (bytes memory reason) { + if (reason.length == 0) { + revert("ERC1363: transfer to non ERC1363Spender implementer"); + } else { + /// @solidity memory-safe-assembly + assembly { + revert(add(32, reason), mload(reason)) + } + } + } + } +} diff --git a/scripts/checks/inheritance-ordering.js b/scripts/checks/inheritance-ordering.js index 45c707e6fcd..620c322e3df 100755 --- a/scripts/checks/inheritance-ordering.js +++ b/scripts/checks/inheritance-ordering.js @@ -13,7 +13,7 @@ for (const artifact of artifacts) { const linearized = []; for (const source in solcOutput.contracts) { - if (source.includes('/mocks/')) { + if (['/mocks/', '/presets/'].some(skip => source.includes(skip))) { continue; } diff --git a/test/token/ERC20/extensions/ERC1363.test.js b/test/token/ERC20/extensions/ERC1363.test.js new file mode 100644 index 00000000000..6d8c1d8df43 --- /dev/null +++ b/test/token/ERC20/extensions/ERC1363.test.js @@ -0,0 +1,252 @@ +const { expectEvent, expectRevert } = require('@openzeppelin/test-helpers'); +const { shouldSupportInterfaces } = require('../../../utils/introspection/SupportsInterface.behavior'); + +const ERC1363Mock = artifacts.require('$ERC1363'); +const ERC1363ReceiverMock = artifacts.require('ERC1363ReceiverMock'); + +contract('ERC1363', function (accounts) { + const [holder, operator, other] = accounts; + + const name = 'My Token'; + const symbol = 'MTKN'; + const supply = web3.utils.toBN(100); + const value = web3.utils.toBN(10); + + beforeEach(async function () { + this.token = await ERC1363Mock.new(name, symbol); + this.receiver = await ERC1363ReceiverMock.new(); + + await this.token.$_mint(holder, supply); + }); + + shouldSupportInterfaces(['ERC165', 'ERC1363']); + + describe('transferAndCall', function () { + it('to EOA', async function () { + await expectRevert( + this.token.methods['transferAndCall(address,uint256)'](other, value, { from: holder }), + 'function returned an unexpected amount of data', + ); + }); + + describe('to receiver', function () { + it('without data', async function () { + this.function = 'transferAndCall(address,uint256)'; + this.operator = holder; + }); + + it('with data', async function () { + this.function = 'transferAndCall(address,uint256,bytes)'; + this.data = '0x123456'; + this.operator = holder; + }); + + it('invalid return value', async function () { + this.function = 'transferAndCall(address,uint256,bytes)'; + this.data = '0x00'; + this.operator = holder; + this.revert = 'ERC1363: transfer to non ERC1363Receiver implementer'; + }); + + it('hook reverts with message', async function () { + this.function = 'transferAndCall(address,uint256,bytes)'; + this.data = '0x01'; + this.operator = holder; + this.revert = 'onTransferReceived revert'; + }); + + it('hook reverts without message', async function () { + this.function = 'transferAndCall(address,uint256,bytes)'; + this.data = '0x02'; + this.operator = holder; + this.revert = 'ERC1363: transfer to non ERC1363Receiver implementer'; + }); + + it('hook reverts with assert(false)', async function () { + this.function = 'transferAndCall(address,uint256,bytes)'; + this.data = '0x03'; + this.operator = holder; + this.revert = 'reverted with panic code 0x1 (Assertion error)'; + }); + + afterEach(async function () { + const txPromise = this.token.methods[this.function]( + ...[this.receiver.address, value, this.data, { from: this.operator }].filter(Boolean), + ); + + if (this.revert === undefined) { + const { tx } = await txPromise; + await expectEvent.inTransaction(tx, this.token, 'Transfer', { + from: this.from || this.operator, + to: this.receiver.address, + value, + }); + await expectEvent.inTransaction(tx, this.receiver, 'TransferReceived', { + operator: this.operator, + from: this.from || this.operator, + value, + data: this.data || null, + }); + } else { + await expectRevert(txPromise, this.revert); + } + }); + }); + }); + + describe('transferFromAndCall', function () { + beforeEach(async function () { + await this.token.approve(operator, supply, { from: holder }); + }); + + it('to EOA', async function () { + await expectRevert( + this.token.methods['transferFromAndCall(address,address,uint256)'](holder, other, value, { from: operator }), + 'function returned an unexpected amount of data', + ); + }); + + describe('to receiver', function () { + it('without data', async function () { + this.function = 'transferFromAndCall(address,address,uint256)'; + this.from = holder; + this.operator = operator; + }); + + it('with data', async function () { + this.function = 'transferFromAndCall(address,address,uint256,bytes)'; + this.data = '0x123456'; + this.from = holder; + this.operator = operator; + }); + + it('invalid return value', async function () { + this.function = 'transferFromAndCall(address,address,uint256,bytes)'; + this.data = '0x00'; + this.from = holder; + this.operator = operator; + this.revert = 'ERC1363: transfer to non ERC1363Receiver implementer'; + }); + + it('hook reverts with message', async function () { + this.function = 'transferFromAndCall(address,address,uint256,bytes)'; + this.data = '0x01'; + this.from = holder; + this.operator = operator; + this.revert = 'onTransferReceived revert'; + }); + + it('hook reverts without message', async function () { + this.function = 'transferFromAndCall(address,address,uint256,bytes)'; + this.data = '0x02'; + this.from = holder; + this.operator = operator; + this.revert = 'ERC1363: transfer to non ERC1363Receiver implementer'; + }); + + it('hook reverts with assert(false)', async function () { + this.function = 'transferFromAndCall(address,address,uint256,bytes)'; + this.data = '0x03'; + this.operator = holder; + this.operator = operator; + this.revert = 'reverted with panic code 0x1 (Assertion error)'; + }); + + afterEach(async function () { + const txPromise = this.token.methods[this.function]( + ...[this.from, this.receiver.address, value, this.data, { from: this.operator }].filter(Boolean), + ); + + if (this.revert === undefined) { + const { tx } = await txPromise; + await expectEvent.inTransaction(tx, this.token, 'Transfer', { + from: this.from || this.operator, + to: this.receiver.address, + value, + }); + await expectEvent.inTransaction(tx, this.receiver, 'TransferReceived', { + operator: this.operator, + from: this.from || this.operator, + value, + data: this.data || null, + }); + } else { + await expectRevert(txPromise, this.revert); + } + }); + }); + }); + + describe('approveAndCall', function () { + it('to EOA', async function () { + await expectRevert( + this.token.methods['approveAndCall(address,uint256)'](other, value, { from: holder }), + 'function returned an unexpected amount of data', + ); + }); + + describe('to receiver', function () { + it('without data', async function () { + this.function = 'approveAndCall(address,uint256)'; + this.owner = holder; + }); + + it('with data', async function () { + this.function = 'approveAndCall(address,uint256,bytes)'; + this.data = '0x123456'; + this.owner = holder; + }); + + it('invalid return value', async function () { + this.function = 'approveAndCall(address,uint256,bytes)'; + this.data = '0x00'; + this.owner = holder; + this.revert = 'ERC1363: transfer to non ERC1363Spender implementer'; + }); + + it('hook reverts with message', async function () { + this.function = 'approveAndCall(address,uint256,bytes)'; + this.data = '0x01'; + this.owner = holder; + this.revert = 'onApprovalReceived revert'; + }); + + it('hook reverts without message', async function () { + this.function = 'approveAndCall(address,uint256,bytes)'; + this.data = '0x02'; + this.owner = holder; + this.revert = 'ERC1363: transfer to non ERC1363Spender implementer'; + }); + + it('hook reverts with assert(false)', async function () { + this.function = 'approveAndCall(address,uint256,bytes)'; + this.data = '0x03'; + this.operator = holder; + this.revert = 'reverted with panic code 0x1 (Assertion error)'; + }); + + afterEach(async function () { + const txPromise = this.token.methods[this.function]( + ...[this.receiver.address, value, this.data, { from: this.owner }].filter(Boolean), + ); + + if (this.revert === undefined) { + const { tx } = await txPromise; + + await expectEvent.inTransaction(tx, this.token, 'Approval', { + owner: this.owner, + spender: this.receiver.address, + value, + }); + await expectEvent.inTransaction(tx, this.receiver, 'ApprovalReceived', { + owner: this.owner, + value, + data: this.data || null, + }); + } else { + await expectRevert(txPromise, this.revert); + } + }); + }); + }); +}); diff --git a/test/utils/introspection/SupportsInterface.behavior.js b/test/utils/introspection/SupportsInterface.behavior.js index 02d147884e4..a9ae99c5cfc 100644 --- a/test/utils/introspection/SupportsInterface.behavior.js +++ b/test/utils/introspection/SupportsInterface.behavior.js @@ -29,6 +29,14 @@ const INTERFACES = { '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)',