diff --git a/.gitignore b/.gitignore index 7f3a0e5d..36313eb3 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ artifacts dist .DS_Store out/ +zkout/ lcov.info lcov.info.pruned coverage/ diff --git a/contracts/factory/zksync/MagicDropCloneFactory.sol b/contracts/factory/zksync/MagicDropCloneFactory.sol new file mode 100644 index 00000000..53ee9844 --- /dev/null +++ b/contracts/factory/zksync/MagicDropCloneFactory.sol @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {Ownable} from "solady/src/auth/Ownable.sol"; +import {TokenStandard} from "../../common/Structs.sol"; +import {MagicDropTokenImplRegistry} from "../../registry/MagicDropTokenImplRegistry.sol"; +import {ZKProxy} from "./ZKProxy.sol"; + +/// @title MagicDropCloneFactory +/// @notice A zksync compatible factory contract for creating and managing clones of MagicDrop contracts +contract MagicDropCloneFactory is Ownable { + /*============================================================== + = CONSTANTS = + ==============================================================*/ + + MagicDropTokenImplRegistry private _registry; + bytes4 private constant INITIALIZE_SELECTOR = bytes4(keccak256("initialize(string,string,address)")); + + /*============================================================== + = EVENTS = + ==============================================================*/ + + event MagicDropFactoryInitialized(); + event NewContractInitialized( + address contractAddress, address initialOwner, uint32 implId, TokenStandard standard, string name, string symbol + ); + event Withdrawal(address to, uint256 amount); + + /*============================================================== + = ERRORS = + ==============================================================*/ + + error InitializationFailed(); + error RegistryAddressCannotBeZero(); + error InsufficientDeploymentFee(); + error WithdrawalFailed(); + error InitialOwnerCannotBeZero(); + + /*============================================================== + = CONSTRUCTOR = + ==============================================================*/ + + /// @param initialOwner The address of the initial owner + /// @param registry The address of the registry contract + constructor(address initialOwner, address registry) public { + if (registry == address(0)) { + revert RegistryAddressCannotBeZero(); + } + + _registry = MagicDropTokenImplRegistry(registry); + _initializeOwner(initialOwner); + + emit MagicDropFactoryInitialized(); + } + + /*============================================================== + = PUBLIC WRITE METHODS = + ==============================================================*/ + + /// @notice Creates a new deterministic clone of a MagicDrop contract + /// @param name The name of the new contract + /// @param symbol The symbol of the new contract + /// @param standard The token standard of the new contract + /// @param initialOwner The initial owner of the new contract + /// @param implId The implementation ID + /// @param salt A unique salt for deterministic address generation + /// @return The address of the newly created contract + function createContractDeterministic( + string calldata name, + string calldata symbol, + TokenStandard standard, + address payable initialOwner, + uint32 implId, + bytes32 salt + ) external payable returns (address) { + address impl; + // Retrieve the implementation address from the registry + if (implId == 0) { + impl = _registry.getDefaultImplementation(standard); + } else { + impl = _registry.getImplementation(standard, implId); + } + + if (initialOwner == address(0)) { + revert InitialOwnerCannotBeZero(); + } + + // Retrieve the deployment fee for the implementation and ensure the caller has sent the correct amount + uint256 deploymentFee = _registry.getDeploymentFee(standard, implId); + if (msg.value < deploymentFee) { + revert InsufficientDeploymentFee(); + } + + // Create a deterministic clone of the implementation contract + address instance = address(new ZKProxy{salt: salt}(impl)); + + // Initialize the newly created contract + (bool success,) = instance.call(abi.encodeWithSelector(INITIALIZE_SELECTOR, name, symbol, initialOwner)); + if (!success) { + revert InitializationFailed(); + } + + emit NewContractInitialized({ + contractAddress: instance, + initialOwner: initialOwner, + implId: implId, + standard: standard, + name: name, + symbol: symbol + }); + + return instance; + } + + /// @notice Creates a new clone of a MagicDrop contract + /// @param name The name of the new contract + /// @param symbol The symbol of the new contract + /// @param standard The token standard of the new contract + /// @param initialOwner The initial owner of the new contract + /// @param implId The implementation ID + /// @return The address of the newly created contract + function createContract( + string calldata name, + string calldata symbol, + TokenStandard standard, + address payable initialOwner, + uint32 implId + ) external payable returns (address) { + address impl; + // Retrieve the implementation address from the registry + if (implId == 0) { + impl = _registry.getDefaultImplementation(standard); + } else { + impl = _registry.getImplementation(standard, implId); + } + + if (initialOwner == address(0)) { + revert InitialOwnerCannotBeZero(); + } + + // Retrieve the deployment fee for the implementation and ensure the caller has sent the correct amount + uint256 deploymentFee = _registry.getDeploymentFee(standard, implId); + if (msg.value < deploymentFee) { + revert InsufficientDeploymentFee(); + } + + // Create a non-deterministic clone of the implementation contract + address instance = address(new ZKProxy(impl)); + + // Initialize the newly created contract + (bool success,) = instance.call(abi.encodeWithSelector(INITIALIZE_SELECTOR, name, symbol, initialOwner)); + if (!success) { + revert InitializationFailed(); + } + + emit NewContractInitialized({ + contractAddress: instance, + initialOwner: initialOwner, + implId: implId, + standard: standard, + name: name, + symbol: symbol + }); + + return instance; + } + + /*============================================================== + = PUBLIC VIEW METHODS = + ==============================================================*/ + + /// @notice Retrieves the address of the registry contract + /// @return The address of the registry contract + function getRegistry() external view returns (address) { + return address(_registry); + } + + /*============================================================== + = ADMIN OPERATIONS = + ==============================================================*/ + + /// @notice Withdraws the contract's balance + function withdraw(address to) external onlyOwner { + (bool success,) = to.call{value: address(this).balance}(""); + if (!success) { + revert WithdrawalFailed(); + } + + emit Withdrawal(to, address(this).balance); + } + + /// @dev Overriden to prevent double-initialization of the owner. + function _guardInitializeOwner() internal pure virtual override returns (bool) { + return true; + } + + /// @notice Receives ETH + receive() external payable {} + + /// @notice Fallback function to receive ETH + fallback() external payable {} +} diff --git a/contracts/factory/zksync/ZKProxy.sol b/contracts/factory/zksync/ZKProxy.sol new file mode 100644 index 00000000..e7b57f2b --- /dev/null +++ b/contracts/factory/zksync/ZKProxy.sol @@ -0,0 +1,36 @@ +pragma solidity ^0.8.20; + +// This is a ZKSync compatible proxy and a replacement for OZ Clones +contract ZKProxy { + address immutable implementation; + + constructor(address _implementation) { + implementation = _implementation; + } + + fallback() external payable { + address impl = implementation; + assembly { + // The pointer to the free memory slot + let ptr := mload(0x40) + // Copy function signature and arguments from calldata at zero position into memory at pointer position + calldatacopy(ptr, 0, calldatasize()) + // Delegatecall method of the implementation contract returns 0 on error + let result := delegatecall(gas(), impl, ptr, calldatasize(), 0, 0) + // Get the size of the last return data + let size := returndatasize() + // Copy the size length of bytes from return data at zero position to pointer position + returndatacopy(ptr, 0, size) + // Depending on the result value + switch result + case 0 { + // End execution and revert state changes + revert(ptr, size) + } + default { + // Return data with length of size at pointers position + return(ptr, size) + } + } + } +} \ No newline at end of file diff --git a/test/factory/zksync/MagicDropCloneFactoryTest.t.sol b/test/factory/zksync/MagicDropCloneFactoryTest.t.sol new file mode 100644 index 00000000..32a182bd --- /dev/null +++ b/test/factory/zksync/MagicDropCloneFactoryTest.t.sol @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {console} from "forge-std/console.sol"; +import {Test} from "forge-std/Test.sol"; +import {MockERC721} from "solady/test/utils/mocks/MockERC721.sol"; +import {MockERC1155} from "solady/test/utils/mocks/MockERC1155.sol"; +import {LibClone} from "solady/src/utils/LibClone.sol"; + +import {MagicDropCloneFactory} from "../../contracts/factory/zksync/MagicDropCloneFactory.sol"; +import {MagicDropTokenImplRegistry} from "../../contracts/registry/MagicDropTokenImplRegistry.sol"; +import {TokenStandard} from "../../contracts/common/Structs.sol"; + +contract MockERC721Initializable is MockERC721 { + function initialize(string memory, string memory, address) public {} +} + +contract MockERC1155Initializable is MockERC1155 { + function initialize(string memory, string memory, address) public {} +} + +contract InvalidImplementation is MockERC721 { + function initialize(string memory) public {} // Missing name and symbol parameters +} + +contract MagicDropCloneFactoryTest is Test { + MagicDropCloneFactory internal factory; + MagicDropTokenImplRegistry internal registry; + + MockERC721Initializable internal erc721Impl; + MockERC1155Initializable internal erc1155Impl; + address internal owner = payable(address(0x1)); + address internal user = payable(address(0x2)); + + uint32 internal erc721ImplId; + uint32 internal erc1155ImplId; + + function setUp() public { + vm.startPrank(owner); + + // Deploy and initialize registry + MagicDropTokenImplRegistry registryImpl = new MagicDropTokenImplRegistry(owner); + registry = MagicDropTokenImplRegistry(address(registryImpl)); + + // Deploy factory + MagicDropCloneFactory factoryImpl = new MagicDropCloneFactory(owner, address(registry)); + factory = MagicDropCloneFactory(payable(address(factoryImpl))); + + // Deploy implementations + erc721Impl = new MockERC721Initializable(); + erc1155Impl = new MockERC1155Initializable(); + + // Register implementations + erc721ImplId = registry.registerImplementation(TokenStandard.ERC721, address(erc721Impl), true, 0.01 ether); + erc1155ImplId = registry.registerImplementation(TokenStandard.ERC1155, address(erc1155Impl), true, 0.01 ether); + + // Fund user + vm.deal(user, 100 ether); + + vm.stopPrank(); + } + + function testCreateERC721Contract() public { + vm.startPrank(user); + + address newContract = factory.createContract{value: 0.01 ether}( + "TestNFT", "TNFT", TokenStandard.ERC721, payable(user), erc721ImplId + ); + + MockERC721Initializable nft = MockERC721Initializable(newContract); + + // Test minting + nft.mint(user, 1); + assertEq(nft.ownerOf(1), user); + + vm.stopPrank(); + } + + function testCreateERC721ContractWithDefaultImplementation() public { + vm.startPrank(user); + + address newContract = + factory.createContract{value: 0.01 ether}("TestNFT", "TNFT", TokenStandard.ERC721, payable(user), 0); + + MockERC721Initializable nft = MockERC721Initializable(newContract); + // Test minting + nft.mint(user, 1); + assertEq(nft.ownerOf(1), user); + + vm.stopPrank(); + } + + function testCreateERC1155Contract() public { + vm.startPrank(user); + + address newContract = factory.createContract{value: 0.01 ether}( + "TestMultiToken", "TMT", TokenStandard.ERC1155, payable(user), erc1155ImplId + ); + + MockERC1155Initializable nft = MockERC1155Initializable(newContract); + + // Test minting + nft.mint(user, 1, 100, ""); + assertEq(nft.balanceOf(user, 1), 100); + + vm.stopPrank(); + } + + function testCreateERC1155ContractWithDefaultImplementation() public { + vm.startPrank(user); + + address newContract = + factory.createContract{value: 0.01 ether}("TestMultiToken", "TMT", TokenStandard.ERC1155, payable(user), 0); + + MockERC1155Initializable nft = MockERC1155Initializable(newContract); + + // Test minting + nft.mint(user, 1, 100, ""); + assertEq(nft.balanceOf(user, 1), 100); + + vm.stopPrank(); + } + + function testFailCreateContractWithInvalidImplementation() public { + uint32 invalidImplId = 999; + + vm.prank(user); + factory.createContract("TestNFT", "TNFT", TokenStandard.ERC721, payable(user), invalidImplId); + } + + function testFailCreateDeterministicContractWithSameSalt() public { + vm.startPrank(user); + + factory.createContractDeterministic{value: 0.01 ether}( + "TestNFT1", "TNFT1", TokenStandard.ERC721, payable(user), erc721ImplId, bytes32(0) + ); + + factory.createContractDeterministic{value: 0.01 ether}( + "TestNFT2", "TNFT2", TokenStandard.ERC721, payable(user), erc721ImplId, bytes32(0) + ); + } + + function testInitializationFailed() public { + TokenStandard standard = TokenStandard.ERC721; + + vm.startPrank(owner); + InvalidImplementation impl = new InvalidImplementation(); + uint32 implId = registry.registerImplementation(standard, address(impl), false, 0.01 ether); + vm.stopPrank(); + + vm.expectRevert(MagicDropCloneFactory.InitializationFailed.selector); + factory.createContractDeterministic{value: 0.01 ether}( + "TestNFT", "TNFT", standard, payable(user), implId, bytes32(0) + ); + } + + function testInsufficientDeploymentFee() public { + vm.startPrank(user); + vm.expectRevert(MagicDropCloneFactory.InsufficientDeploymentFee.selector); + factory.createContractDeterministic{value: 0.005 ether}( + "TestNFT", "TNFT", TokenStandard.ERC721, payable(user), erc721ImplId, bytes32(0) + ); + } + + function testGetRegistry() public view { + assertEq(factory.getRegistry(), address(registry)); + } + + function testWithdraw() public { + vm.startPrank(user); + factory.createContract{value: 0.01 ether}("TestMultiToken", "TMT", TokenStandard.ERC1155, payable(user), 0); + vm.stopPrank(); + + vm.startPrank(owner); + uint256 userBalanceBefore = user.balance; + assertEq(address(factory).balance, 0.01 ether); + factory.withdraw(user); + assertEq(address(factory).balance, 0); + assertEq(user.balance, userBalanceBefore + 0.01 ether); + vm.stopPrank(); + } + + function testFailWithdrawToNonOwner() public { + vm.startPrank(user); + factory.withdraw(user); + } +}