diff --git a/contracts/crafting/AbstractCraftingRecipe.sol b/contracts/crafting/AbstractCraftingRecipe.sol new file mode 100644 index 00000000..232e86fd --- /dev/null +++ b/contracts/crafting/AbstractCraftingRecipe.sol @@ -0,0 +1,20 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache2 +pragma solidity 0.8.19; + +import {ICraftingRecipe} from "./ICraftingRecipe.sol"; + +abstract contract AbstractCraftingRecipe is ICraftingRecipe { + modifier onlyCraftingFactory() { + if (msg.sender != craftingFactory) { + revert OnlyCraftingFactory(msg.sender); + } + _; + } + + address public craftingFactory; + + constructor(address _craftingFactory) { + craftingFactory = _craftingFactory; + } +} diff --git a/contracts/crafting/CraftingFactory.sol b/contracts/crafting/CraftingFactory.sol new file mode 100644 index 00000000..3733a8f1 --- /dev/null +++ b/contracts/crafting/CraftingFactory.sol @@ -0,0 +1,59 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity 0.8.19; + +import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; +import {ICraftingRecipe, ERC1155Input, ERC721Input, ERC20Input, ERC1155Asset} from "./ICraftingRecipe.sol"; + +contract CraftingFactory { + event CraftComplete(uint256 indexed craftID, ICraftingRecipe indexed recipe); + + uint256 public craftCounter; + + /** + * @notice Transfer some tokens and then execute an arbitrary action. + * @dev The set of allowed token actions can be controlled by the a crafting recipe. + * @dev The crafting receipt also defines the arbitrary action. + * @dev The msg.sender is assumed to be the game player's EOA or Passport wallet contract. + * @param recipe Contract that defines the allowable set of inputs to crafting and the actions to do post transfers. + * @param erc20Inputs Tokens to be transferred. + * @param erc721Inputs Tokens to be transferred. + * @param erc1155Inputs Tokens to be transferred. + * @param data ABI encoded parameters to the crafting logic post transfer. + */ + function craft( + ICraftingRecipe recipe, + ERC20Input[] calldata erc20Inputs, + ERC721Input[] calldata erc721Inputs, + ERC1155Input[] calldata erc1155Inputs, + bytes calldata data + ) external nonReentrant() { + uint256 craftID = craftCounter++; + + recipe.beforeTransfers(craftID, msg.sender, erc20Inputs, erc721Inputs, erc1155Inputs, data); + + for (uint256 i = 0; i < erc20Inputs.length; i++) { + ERC20Input memory input = erc20Inputs[i]; + input.erc20.transferFrom(msg.sender, input.destination, input.amount); + } + + for (uint256 i = 0; i < erc721Inputs.length; i++) { + ERC721Input memory input = erc721Inputs[i]; + for (uint256 j = 0; j < input.tokenIDs.length; j++) { + input.erc721.safeTransferFrom(msg.sender, input.destination, input.tokenIDs[j]); + } + } + + for (uint256 i = 0; i < erc1155Inputs.length; i++) { + ERC1155Input memory input = erc1155Inputs[i]; + for (uint256 j = 0; j < input.assets.length; j++) { + ERC1155Asset memory asset = input.assets[j]; + input.erc1155.safeTransferFrom(msg.sender, input.destination, asset.tokenID, asset.amount, "0x0"); + } + } + + recipe.afterTransfers(craftID, msg.sender, data); + + emit CraftComplete(craftID, recipe); + } +} diff --git a/contracts/crafting/ICraftingRecipe.sol b/contracts/crafting/ICraftingRecipe.sol new file mode 100644 index 00000000..b2126439 --- /dev/null +++ b/contracts/crafting/ICraftingRecipe.sol @@ -0,0 +1,45 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity 0.8.19; + +import {IERC1155} from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +struct ERC1155Asset { + uint256 tokenID; + uint256 amount; +} + +struct ERC1155Input { + IERC1155 erc1155; + ERC1155Asset[] assets; + address destination; +} + +struct ERC721Input { + IERC721 erc721; + uint256[] tokenIDs; + address destination; +} + +struct ERC20Input { + IERC20 erc20; + uint256 amount; + address destination; +} + +interface ICraftingRecipe { + error OnlyCraftingFactory(address _caller); + + function beforeTransfers( + uint256 craftID, + address _player, + ERC20Input[] calldata erc20s, + ERC721Input[] calldata erc721s, + ERC1155Input[] calldata erc1155s, + bytes calldata data + ) external; + + function afterTransfers(uint256 craftID, address _player, bytes calldata data) external; +} diff --git a/contracts/crafting/examples/ExampleRecipeERC1155.sol b/contracts/crafting/examples/ExampleRecipeERC1155.sol new file mode 100644 index 00000000..53a8d555 --- /dev/null +++ b/contracts/crafting/examples/ExampleRecipeERC1155.sol @@ -0,0 +1,143 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {IERC1155} from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; + +import {ERC20Input, ERC721Input, ERC1155Input, ERC1155Asset} from "../ICraftingRecipe.sol"; +import {AbstractCraftingRecipe} from "../AbstractCraftingRecipe.sol"; + +contract MockRecipeERC1155 is AbstractCraftingRecipe { + error NoERC20sAccepted(); + error NoERC721sAccepted(); + error IncorrectNumberOfERC1155s(); + error WrongDeviceToken(address _token); + error WrongInputToken(address _token); + error WrongOutputToken(address _token); + error IncorrectDeviceDestination(address _destination); + error IncorrectInputDestination(address _destination); + error IncorrectOutputDestination(address _destination); + error OnlyUseOneCraftingDeviceAtATime(uint256 _len); + error ZeroCraftingDevices(uint256 _craftingDevice); + error NoInputAssetsProvided(); + error OnlyOneOutputAssetAllowed(uint256 _numProvided); + error GoldenSwordCraftingWrongNumberAssets(uint256 _numProvided); + error GoldSwordCraftingWrongAssets(); + error UnknownCraftingCombination(); + + uint256 private constant GOLDEN_SWORD = 2; + uint256 private constant DIAMOND_SWORD = 2; + uint256 private constant DIAMONDS = 5; + uint256 private constant ANVIL = 100; + + address public gameTokens; + address public gameContract; + + constructor( + address _craftingFactory, + address _gameTokens, + address _gameContract + ) AbstractCraftingRecipe(_craftingFactory) { + gameTokens = _gameTokens; + gameContract = _gameContract; + } + + /** + * @notice Expect two ERC 1155 objects: + * First: crafting device. + * Second: inputs to crafting. + * Third: output of crafting + */ + function beforeTransfers( + uint256 /* craftID */, + address _player, + ERC20Input[] calldata erc20s, + ERC721Input[] calldata erc721s, + ERC1155Input[] calldata erc1155s, + bytes calldata /* data */ + ) external view onlyCraftingFactory { + if (erc20s.length != 0) { + revert NoERC20sAccepted(); + } + if (erc721s.length != 0) { + revert NoERC721sAccepted(); + } + if (erc1155s.length != 3) { + revert IncorrectNumberOfERC1155s(); + } + + ERC1155Input memory erc1155Device = erc1155s[0]; + ERC1155Input memory erc1155Input = erc1155s[1]; + ERC1155Input memory erc1155Output = erc1155s[2]; + + // Check crafting device. + IERC1155 tokenContract = erc1155Device.erc1155; + if (address(tokenContract) != gameTokens) { + revert WrongDeviceToken(address(tokenContract)); + } + // Crafting device should stay with the player. + if (erc1155Device.destination != _player) { + revert IncorrectDeviceDestination(erc1155Output.destination); + } + if (erc1155Device.assets.length != 1) { + revert OnlyUseOneCraftingDeviceAtATime(erc1155Device.assets.length); + } + uint256 craftingDevice = erc1155Device.assets[0].tokenID; + if (erc1155Device.assets[0].amount == 0) { + revert ZeroCraftingDevices(craftingDevice); + } + + // Check inputs. + tokenContract = erc1155Input.erc1155; + if (address(tokenContract) != gameTokens) { + revert WrongInputToken(address(tokenContract)); + } + if (erc1155Input.destination != gameContract) { + revert IncorrectInputDestination(erc1155Input.destination); + } + ERC1155Asset[] memory inputAssets = erc1155Input.assets; + if (inputAssets.length == 0) { + revert NoInputAssetsProvided(); + } + + // Check output. + tokenContract = erc1155Output.erc1155; + if (address(tokenContract) != gameTokens) { + revert WrongOutputToken(address(tokenContract)); + } + if (erc1155Output.destination != _player) { + revert IncorrectOutputDestination(erc1155Output.destination); + } + ERC1155Asset[] memory outputAssets = erc1155Output.assets; + if (outputAssets.length != 1) { + revert OnlyOneOutputAssetAllowed(outputAssets.length); + } + ERC1155Asset memory outputAsset = outputAssets[0]; + + // Check for valid combinations of input assets and resulting output asset. + if ((craftingDevice == ANVIL) && (inputAssets[0].tokenID == GOLDEN_SWORD)) { + if (inputAssets.length == 2) { + revert GoldenSwordCraftingWrongNumberAssets(inputAssets.length); + } + if ( + (inputAssets[0].amount != 1) || + (inputAssets[1].tokenID != DIAMONDS) || + (inputAssets[1].amount != 5) || + (outputAsset.tokenID != DIAMOND_SWORD) || + (outputAsset.amount != 1) + ) { + revert GoldSwordCraftingWrongAssets(); + } + } else { + revert UnknownCraftingCombination(); + } + } + + function afterTransfers( + uint256 /* _craftID */, + address /* _player */, + bytes calldata /* _data */ + ) external onlyCraftingFactory { + // Nothing to do. + } +} diff --git a/contracts/crafting/examples/ExampleRecipeERC721.sol b/contracts/crafting/examples/ExampleRecipeERC721.sol new file mode 100644 index 00000000..b131a947 --- /dev/null +++ b/contracts/crafting/examples/ExampleRecipeERC721.sol @@ -0,0 +1,43 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {ERC20Input, ERC721Input, ERC1155Input} from "../ICraftingRecipe.sol"; +import {AbstractCraftingRecipe} from "../AbstractCraftingRecipe.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; + +contract ExampleRecipeERC721 is AbstractCraftingRecipe { + IERC721 public token; + + constructor(address _craftingFactory, IERC721 _token) AbstractCraftingRecipe(_craftingFactory) { + token = _token; + } + + function beforeTransfers( + uint256 /* craftId */, + address /* _player */, + ERC20Input[] calldata erc20s, + ERC721Input[] calldata erc721s, + ERC1155Input[] calldata erc1155s, + bytes calldata /* data */ + ) external view onlyCraftingFactory { + require(erc20s.length == 0, "No ERC20s allowed."); + require(erc1155s.length == 0, "No ERC1155s allowed."); + require(erc721s.length == 1, "Must be only one ERC721 input."); + + ERC721Input memory input = erc721s[0]; + require(input.erc721 == token, "Must be crafting game assets."); + require(input.destination == address(0), "Only allowed destination is 0x0."); + + // No need to check that the 5 assets are unique as transferring them will fail in the Factory. + + // Can log any events you want + } + + function afterTransfers(uint256 _craftID, address _player, bytes calldata _data) external onlyCraftingFactory { + // TODO + // (address nft, uint256 tokenId) = abi.decode(_data, (address, uint256)); + // IERC721(nft).mint(_player, tokenId); + // Can log any events you want + } +} diff --git a/test/crafting/CraftingFactory.t.sol b/test/crafting/CraftingFactory.t.sol new file mode 100644 index 00000000..d04621ed --- /dev/null +++ b/test/crafting/CraftingFactory.t.sol @@ -0,0 +1,37 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import "forge-std/Test.sol"; + + +import { ERC20Input, ERC721Input, ERC1155Input } from "contracts/crafting/ICraftingRecipe.sol"; +import {CraftingFactory} from "contracts/crafting/CraftingFactory.sol"; + + +contract CraftingFactoryTest is Test { + + CraftingFactory public craftingFactory; + + + address public bank; + address public player; + address public player2; + + function setUp() public virtual { + bank = makeAddr("bank"); + player = makeAddr("player"); + player2 = makeAddr("player2"); + + craftingFactory = new CraftingFactory(); + + // IERC20 erc20 = new ERC20PresetFixedSupply("TOKEN", "TOK", 1000, bank); + // bank.transfer(player, 100); + } + + function testHappyPath() public { + + // TODO + + } +}