diff --git a/contracts/crafting/Commands.sol b/contracts/crafting/Commands.sol new file mode 100644 index 00000000..2771b45f --- /dev/null +++ b/contracts/crafting/Commands.sol @@ -0,0 +1,22 @@ +// Copyright Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +library Commands { + enum CommandType { + ERC721Mint, + ERC721Burn, + ERC721Transfer, + ERC20Mint, + ERC20Transfer, + ERC1155Mint, + ERC1155Burn, + ERC1155Transfer + } + + struct Command { + address token; + CommandType commandType; + bytes data; + } +} diff --git a/contracts/crafting/Crafting.sol b/contracts/crafting/Crafting.sol new file mode 100644 index 00000000..e797b284 --- /dev/null +++ b/contracts/crafting/Crafting.sol @@ -0,0 +1,33 @@ +// Copyright Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Commands} from "./Commands.sol"; +import {IERC721MintableBurnable} from "./IERC721MintableBurnable.sol"; + +contract Crafting { + event Crafted(bytes32 _craftId, address _sender, Commands.Command[] _commands, address _signer, uint256 _deadline); + + function execute( + // bytes32 _craftId, + Commands.Command[] memory _commands + // address _signer, + // uint256 _deadline, + // bytes calldata _signature + ) external + { + for (uint256 i = 0; i < _commands.length; i++) { + Commands.Command memory command = _commands[i]; + if (command.commandType == Commands.CommandType.ERC721Burn) { + uint256 tokenId = abi.decode(command.data, (uint256)); + IERC721MintableBurnable(command.token).safeBurn(msg.sender, tokenId); + } else if (command.commandType == Commands.CommandType.ERC721Transfer) { + (address to, uint256 tokenId) = abi.decode(command.data, (address, uint256)); + IERC721MintableBurnable(command.token).safeTransferFrom(msg.sender, to, tokenId); + } else if (command.commandType == Commands.CommandType.ERC721Mint) { + uint256 tokenId = abi.decode(command.data, (uint256)); + IERC721MintableBurnable(command.token).safeMint(msg.sender, tokenId); + } + } + } +} diff --git a/contracts/crafting/IERC721MintableBurnable.sol b/contracts/crafting/IERC721MintableBurnable.sol new file mode 100644 index 00000000..20e1346e --- /dev/null +++ b/contracts/crafting/IERC721MintableBurnable.sol @@ -0,0 +1,10 @@ +// Copyright Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +interface IERC721MintableBurnable { + function safeMint(address to, uint256 tokenId) external; + function burn(uint256 tokenId) external; + function safeBurn(address owner, uint256 tokenId) external; + function safeTransferFrom(address from, address to, uint256 tokenId) external; +} diff --git a/contracts/mocks/MockERC721MintableBurnable.sol b/contracts/mocks/MockERC721MintableBurnable.sol new file mode 100644 index 00000000..d1be31fc --- /dev/null +++ b/contracts/mocks/MockERC721MintableBurnable.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +// Compatible with OpenZeppelin Contracts ^5.0.0 +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol"; +import "@openzeppelin/contracts/access/AccessControl.sol"; + +contract MockERC721MintableBurnable is ERC721, ERC721Burnable, AccessControl { + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + + error MismatchedTokenOwner(); + + constructor(address defaultAdmin, address minter) ERC721("MyToken", "MTK") { + _grantRole(DEFAULT_ADMIN_ROLE, defaultAdmin); + _grantRole(MINTER_ROLE, minter); + } + + function safeMint(address to, uint256 tokenId) public onlyRole(MINTER_ROLE) { + _safeMint(to, tokenId); + } + + function safeBurn(address owner, uint256 tokenId) external { + if (ownerOf(tokenId) != owner) { + revert MismatchedTokenOwner(); + } + burn(tokenId); + } + + // The following functions are overrides required by Solidity. + + function supportsInterface(bytes4 interfaceId) public view override(ERC721, AccessControl) returns (bool) { + return super.supportsInterface(interfaceId); + } +} diff --git a/test/crafting/Crafting.test.ts b/test/crafting/Crafting.test.ts new file mode 100644 index 00000000..575df384 --- /dev/null +++ b/test/crafting/Crafting.test.ts @@ -0,0 +1,117 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { Crafting, MockERC721MintableBurnable } from "../../typechain-types"; + +const commandTypeERC721Mint = 0; +const commandTypeERC721Burn = 1; +const commandTypeERC721Transfer = 2; + +describe("Crafting", () => { + let owner: SignerWithAddress; + let user: SignerWithAddress; + let user2: SignerWithAddress; + let crafting: Crafting; + let erc721: MockERC721MintableBurnable; + + beforeEach(async () => { + // Retrieve accounts + [owner, user, user2] = await ethers.getSigners(); + + crafting = await (await ethers.getContractFactory("Crafting")).deploy(); + erc721 = await (await ethers.getContractFactory("MockERC721MintableBurnable")) + .connect(owner) + .deploy(owner.address, owner.address); + }); + + describe("Contract Deployment", () => { + it("Should deploy the contract", async () => { + expect(crafting.address).to.not.be.undefined; + }); + }); + + describe("Execute", () => { + describe("ERC721", () => { + it("Should burn item if approved", async () => { + await erc721.connect(owner).safeMint(user.address, 1); + await erc721.connect(user).approve(crafting.address, 1); + const commands = [ + { + token: erc721.address, + commandType: commandTypeERC721Burn, + data: ethers.utils.defaultAbiCoder.encode(["uint256"], [1]), + }, + ]; + expect(await crafting.connect(user).execute(commands)).to.not.reverted; + await expect(erc721.ownerOf(1)).to.be.reverted; + }); + + it("Should revert burn if not approved", async () => { + await erc721.connect(owner).safeMint(user.address, 1); + const commands = [ + { + token: erc721.address, + commandType: commandTypeERC721Burn, + data: ethers.utils.defaultAbiCoder.encode(["uint256"], [1]), + }, + ]; + await expect(crafting.connect(user).execute(commands)).to.be.revertedWith( + "ERC721: caller is not token owner or approved", + ); + }); + + it("Should mint item if role is granted", async () => { + erc721.grantRole(await erc721.MINTER_ROLE(), crafting.address); + const commands = [ + { + token: erc721.address, + commandType: commandTypeERC721Mint, + data: ethers.utils.defaultAbiCoder.encode(["uint256"], [1]), + }, + ]; + expect(await crafting.connect(user).execute(commands)).to.not.reverted; + expect(await erc721.connect(user).ownerOf(1)).to.be.equal(user.address); + }); + + it("Should revert mint if role is not granted", async () => { + const commands = [ + { + token: erc721.address, + commandType: commandTypeERC721Mint, + data: ethers.utils.defaultAbiCoder.encode(["uint256"], [1]), + }, + ]; + await expect(crafting.connect(user).execute(commands)).to.be.reverted; + }); + + it("Should run multiple commands", async () => { + await erc721.connect(owner).safeMint(user.address, 1); + await erc721.connect(user).approve(crafting.address, 1); + await erc721.connect(owner).safeMint(user.address, 2); + await erc721.connect(user).approve(crafting.address, 2); + erc721.grantRole(await erc721.MINTER_ROLE(), crafting.address); + const commands = [ + { + token: erc721.address, + commandType: commandTypeERC721Burn, + data: ethers.utils.defaultAbiCoder.encode(["uint256"], [1]), + }, + { + token: erc721.address, + commandType: commandTypeERC721Transfer, + data: ethers.utils.defaultAbiCoder.encode(["address", "uint256"], [user2.address, 2]), + }, + { + token: erc721.address, + commandType: commandTypeERC721Mint, + data: ethers.utils.defaultAbiCoder.encode(["uint256"], [3]), + }, + ]; + expect(await crafting.connect(user).execute(commands)).to.not.reverted; + await expect(erc721.ownerOf(1)).to.be.reverted; + expect(await erc721.ownerOf(2)).to.be.equal(user2.address); + expect(await erc721.ownerOf(3)).to.be.equal(user.address); + }); + }); + }); +});