diff --git a/.changeset/violet-starfishes-visit.md b/.changeset/violet-starfishes-visit.md index bdd1b3c6c..808aa7a11 100644 --- a/.changeset/violet-starfishes-visit.md +++ b/.changeset/violet-starfishes-visit.md @@ -109,6 +109,15 @@ struct TokenCreationConfig { * new function `premintV1` - takes a `PremintConfig`, and premint v1 signature, and executes a premint, with added functionality of being able to specify mint referral and mint recipient * new function `premintV2` - takes a `PremintConfigV2` signature and executes a premint, with being able to specify mint referral and mint recipient * deprecated function `premint` - call `premintV1` instead -* new function `isValidSignatureV1` - takes an 1155 address, contract admin, premint v1 config and signature, and validates the signature. Can be used for 1155 contracts that were not created via the premint executor contract. -* new function `isValidSignatureV2` - takes an 1155 address, contract admin, premint v2 config and signature, and validates the signature. Can be used for 1155 contracts that were not created via the premint executor contract. +* new function + +```solidity +isAuthorizedToCreatePremint( + address signer, + address premintContractConfigContractAdmin, + address contractAddress +) public view returns (bool isAuthorized) +``` + +takes a signer, contractConfig.contractAdmin, and 1155 address, and determines if the signer is authorized to sign premints on the given contract. Replaces `isValidSignature` - by putting the burden on clients to first decode the signature, then pass the recovered signer to this function to determine if the signer has premint authorization on the contract. * deprecated function `isValidSignature` - call `isValidSignatureV1` instead diff --git a/.changeset/wicked-dolphins-remain.md b/.changeset/wicked-dolphins-remain.md new file mode 100644 index 000000000..d62c0c26c --- /dev/null +++ b/.changeset/wicked-dolphins-remain.md @@ -0,0 +1,5 @@ +--- +"@zoralabs/premint-sdk": patch +--- + +`preminter` exposes new function isValidSignatureV1 that recovers a signer from a signed premint and determines if that signer is authorized to sign diff --git a/packages/1155-contracts/src/delegation/ZoraCreator1155Attribution.sol b/packages/1155-contracts/src/delegation/ZoraCreator1155Attribution.sol index 6f24664b7..55a1b7cc6 100644 --- a/packages/1155-contracts/src/delegation/ZoraCreator1155Attribution.sol +++ b/packages/1155-contracts/src/delegation/ZoraCreator1155Attribution.sol @@ -234,25 +234,17 @@ library ZoraCreator1155Attribution { /// @dev copied from ZoraCreator1155Impl uint256 constant PERMISSION_BIT_MINTER = 2 ** 2; - function isValidSignature( - address originalPremintCreator, - address contractAddress, - bytes32 structHash, - bytes32 hashedVersion, - bytes calldata signature - ) internal view returns (bool isValid, address recoveredSigner) { - recoveredSigner = recoverSignerHashed(structHash, signature, contractAddress, hashedVersion, block.chainid); - - if (recoveredSigner == address(0)) { - return (false, address(0)); - } - - // if contract hasn't been created, signer must be the contract admin on the config + function isAuthorizedToCreatePremint( + address signer, + address premintContractConfigContractAdmin, + address contractAddress + ) internal view returns (bool authorized) { + // if contract hasn't been created, signer must be the contract admin on the premint config if (contractAddress.code.length == 0) { - isValid = recoveredSigner == originalPremintCreator; + return signer == premintContractConfigContractAdmin; } else { // if contract has been created, signer must have mint new token permission - isValid = IZoraCreator1155(contractAddress).isAdminOrRole(recoveredSigner, CONTRACT_BASE_ID, PERMISSION_BIT_MINTER); + authorized = IZoraCreator1155(contractAddress).isAdminOrRole(signer, CONTRACT_BASE_ID, PERMISSION_BIT_MINTER); } } } diff --git a/packages/1155-contracts/src/delegation/ZoraCreator1155PremintExecutorImpl.sol b/packages/1155-contracts/src/delegation/ZoraCreator1155PremintExecutorImpl.sol index 6f8332340..ec360e993 100644 --- a/packages/1155-contracts/src/delegation/ZoraCreator1155PremintExecutorImpl.sol +++ b/packages/1155-contracts/src/delegation/ZoraCreator1155PremintExecutorImpl.sol @@ -153,7 +153,7 @@ contract ZoraCreator1155PremintExecutorImpl is return (true, ERC1155DelegationStorageV1(contractAddress).delegatedTokenId(uid)); } - // @custom:deprecated use isValidSignatureV1 instead + // @custom:deprecated use isAuthorizedToCreatePremint instead function isValidSignature( ContractCreationConfig calldata contractConfig, PremintConfig calldata premintConfig, @@ -161,57 +161,34 @@ contract ZoraCreator1155PremintExecutorImpl is ) public view returns (bool isValid, address contractAddress, address recoveredSigner) { contractAddress = getContractAddress(contractConfig); - (isValid, recoveredSigner) = isValidSignatureV1(contractConfig.contractAdmin, contractAddress, premintConfig, signature); - } - - /// @notice Recovers the signer of a premint, and checks if the signer is authorized to sign the premint. - /// @dev for use with v1 of premint config, PremintConfig - /// @param premintContractConfigContractAdmin If this contract was created via premint, the original contractConfig.contractAdmin. Otherwise, set to address(0) - /// @param contractAddress The determinstic 1155 contract address the premint is for - /// @param premintConfig The premint config - /// @param signature The signature of the premint - /// @return isValid Whether the signature is valid - /// @return recoveredSigner The signer of the premint - function isValidSignatureV1( - address premintContractConfigContractAdmin, - address contractAddress, - PremintConfig calldata premintConfig, - bytes calldata signature - ) public view returns (bool isValid, address recoveredSigner) { - bytes32 hashedPremint = ZoraCreator1155Attribution.hashPremint(premintConfig); - - (isValid, recoveredSigner) = ZoraCreator1155Attribution.isValidSignature( - premintContractConfigContractAdmin, + recoveredSigner = ZoraCreator1155Attribution.recoverSignerHashed( + ZoraCreator1155Attribution.hashPremint(premintConfig), + signature, contractAddress, - hashedPremint, ZoraCreator1155Attribution.HASHED_VERSION_1, - signature + block.chainid ); + + if (recoveredSigner == address(0)) { + return (false, address(0), recoveredSigner); + } + + isValid = isAuthorizedToCreatePremint(recoveredSigner, contractConfig.contractAdmin, contractAddress); } - /// @notice Recovers the signer of a premint, and checks if the signer is authorized to sign the premint. - /// @dev for use with v2 of premint config, PremintConfig + /// @notice Checks if the signer of a premint is authorized to sign a premint for a given contract. If the contract hasn't been created yet, + /// then the signer is authorized if the signer's address matches contractConfig.contractAdmin. Otherwise, the signer must have the PERMISSION_BIT_MINTER + /// role on the contract + /// @param signer The signer of the premint /// @param premintContractConfigContractAdmin If this contract was created via premint, the original contractConfig.contractAdmin. Otherwise, set to address(0) /// @param contractAddress The determinstic 1155 contract address the premint is for - /// @param premintConfig The premint config - /// @param signature The signature of the premint - /// @return isValid Whether the signature is valid - /// @return recoveredSigner The signer of the premint - function isValidSignatureV2( + /// @return isAuthorized Whether the signer is authorized + function isAuthorizedToCreatePremint( + address signer, address premintContractConfigContractAdmin, - address contractAddress, - PremintConfigV2 calldata premintConfig, - bytes calldata signature - ) public view returns (bool isValid, address recoveredSigner) { - bytes32 hashedPremint = ZoraCreator1155Attribution.hashPremint(premintConfig); - - (isValid, recoveredSigner) = ZoraCreator1155Attribution.isValidSignature( - premintContractConfigContractAdmin, - contractAddress, - hashedPremint, - ZoraCreator1155Attribution.HASHED_VERSION_2, - signature - ); + address contractAddress + ) public view returns (bool isAuthorized) { + return ZoraCreator1155Attribution.isAuthorizedToCreatePremint(signer, premintContractConfigContractAdmin, contractAddress); } /// @notice Returns the versions of the premint signature that the contract supports diff --git a/packages/1155-contracts/src/interfaces/IZoraCreator1155PremintExecutor.sol b/packages/1155-contracts/src/interfaces/IZoraCreator1155PremintExecutor.sol index 8c4c92ef8..967166cfc 100644 --- a/packages/1155-contracts/src/interfaces/IZoraCreator1155PremintExecutor.sol +++ b/packages/1155-contracts/src/interfaces/IZoraCreator1155PremintExecutor.sol @@ -27,6 +27,12 @@ interface ILegacyZoraCreator1155PremintExecutor { uint256 quantityToMint, string calldata mintComment ) external payable returns (uint256 newTokenId); + + function isAuthorizedToCreatePremint( + address signer, + address premintContractConfigContractAdmin, + address contractAddress + ) external view returns (bool isAuthorized); } interface IZoraCreator1155PremintExecutorV1 { @@ -37,13 +43,6 @@ interface IZoraCreator1155PremintExecutorV1 { uint256 quantityToMint, IZoraCreator1155PremintExecutor.MintArguments calldata mintArguments ) external payable returns (IZoraCreator1155PremintExecutor.PremintResult memory); - - function isValidSignatureV1( - address originalContractAdmin, - address contractAddress, - PremintConfig calldata premintConfig, - bytes calldata signature - ) external view returns (bool isValid, address recoveredSigner); } interface IZoraCreator1155PremintExecutorV2 { @@ -54,13 +53,6 @@ interface IZoraCreator1155PremintExecutorV2 { uint256 quantityToMint, IZoraCreator1155PremintExecutor.MintArguments calldata mintArguments ) external payable returns (IZoraCreator1155PremintExecutor.PremintResult memory); - - function isValidSignatureV2( - address originalContractAdmin, - address contractAddress, - PremintConfigV2 calldata premintConfig, - bytes calldata signature - ) external view returns (bool isValid, address recoveredSigner); } interface IZoraCreator1155PremintExecutor is diff --git a/packages/1155-contracts/test/premint/ZoraCreator1155PremintExecutor.t.sol b/packages/1155-contracts/test/premint/ZoraCreator1155PremintExecutor.t.sol index 11e15b0fa..9ff66fc4e 100644 --- a/packages/1155-contracts/test/premint/ZoraCreator1155PremintExecutor.t.sol +++ b/packages/1155-contracts/test/premint/ZoraCreator1155PremintExecutor.t.sol @@ -132,7 +132,7 @@ contract ZoraCreator1155PreminterTest is Test { assertTrue(isValid); // now check using new method - (isValid, ) = preminter.isValidSignatureV1(contractConfig.contractAdmin, contractAddress, premintConfig, signature); + isValid = preminter.isAuthorizedToCreatePremint(creator, contractConfig.contractAdmin, contractAddress); assertTrue(isValid); // now call the premint function, using the same config that was used to generate the digest, and the signature @@ -667,18 +667,13 @@ contract ZoraCreator1155PreminterTest is Test { address contractAddress = preminter.getContractAddress(contractConfig); - // sign and execute premint - bytes memory signature = _signPremint(contractAddress, premintConfig, creatorPrivateKey, block.chainid); - - (bool isValidSignature, address recoveredSigner) = preminter.isValidSignatureV2( - contractConfig.contractAdmin, - contractAddress, - premintConfig, - signature - ); + bool isValidSignature = preminter.isAuthorizedToCreatePremint({ + signer: creator, + premintContractConfigContractAdmin: contractConfig.contractAdmin, + contractAddress: contractAddress + }); - assertEq(creator, recoveredSigner, "recovered the wrong signer"); - assertTrue(isValidSignature, "signature should be valid"); + assertTrue(isValidSignature, "creator should be allowed to create premint before contract created"); _signAndExecutePremint(contractConfig, premintConfig, creatorPrivateKey, block.chainid, premintExecutor, 1, "hi"); @@ -694,9 +689,13 @@ contract ZoraCreator1155PreminterTest is Test { bytes memory newCreatorSignature = _signPremint(contractAddress, premintConfig2, newCreatorPrivateKey, block.chainid); // it should not be considered a valid signature - (isValidSignature, ) = preminter.isValidSignatureV2(contractConfig.contractAdmin, contractAddress, premintConfig2, newCreatorSignature); + isValidSignature = preminter.isAuthorizedToCreatePremint({ + signer: newCreator, + premintContractConfigContractAdmin: contractConfig.contractAdmin, + contractAddress: contractAddress + }); - assertFalse(isValidSignature, "signature should not be valid"); + assertFalse(isValidSignature, "alternative creator should not be allowed to create a premint"); uint256 quantityToMint = 1; uint256 mintCost = mintFeeAmount * quantityToMint; @@ -712,7 +711,11 @@ contract ZoraCreator1155PreminterTest is Test { IZoraCreator1155(contractAddress).addPermission(CONTRACT_BASE_ID, newCreator, PERMISSION_BIT_MINTER); // should now be considered a valid signature - (isValidSignature, ) = preminter.isValidSignatureV2(contractConfig.contractAdmin, contractAddress, premintConfig2, newCreatorSignature); + isValidSignature = preminter.isAuthorizedToCreatePremint({ + signer: newCreator, + premintContractConfigContractAdmin: contractConfig.contractAdmin, + contractAddress: contractAddress + }); assertTrue(isValidSignature, "valid signature after granted permission"); vm.deal(premintExecutor, mintCost); diff --git a/packages/protocol-sdk/src/premint/preminter.test.ts b/packages/protocol-sdk/src/premint/preminter.test.ts index 16368ee4a..6f32ea5ed 100644 --- a/packages/protocol-sdk/src/premint/preminter.test.ts +++ b/packages/protocol-sdk/src/premint/preminter.test.ts @@ -22,6 +22,7 @@ import { PremintConfig, TokenCreationConfig, preminterTypedDataDefinition, + isValidSignatureV1, } from "./preminter"; import { AnvilViemClientsTest, @@ -182,15 +183,18 @@ describe("ZoraCreator1155Preminter", () => { account: creatorAccount, }); - const preminterAddress = zoraCreator1155PremintExecutorAddress[999]; // recover and verify address is correct - const [, , recoveredAddress] = - await viemClients.publicClient.readContract({ - abi: preminterAbi, - address: preminterAddress, - functionName: "isValidSignature", - args: [contractConfig, premintConfig, signedMessage], - }); + const { recoveredAddress, isAuthorized } = await isValidSignatureV1({ + contractAddress, + chainId: viemClients.publicClient.chain!.id, + originalContractAdmin: contractConfig.contractAdmin, + premintConfig, + publicClient: viemClients.publicClient, + signature: signedMessage, + }); + + expect(recoveredAddress).to.equal(creatorAccount); + expect(isAuthorized).toBe(true); expect(recoveredAddress).to.equal(creatorAccount); }, diff --git a/packages/protocol-sdk/src/premint/preminter.ts b/packages/protocol-sdk/src/premint/preminter.ts index 35e83161d..1f26b36dc 100644 --- a/packages/protocol-sdk/src/premint/preminter.ts +++ b/packages/protocol-sdk/src/premint/preminter.ts @@ -1,7 +1,15 @@ import { Address } from "abitype"; import { ExtractAbiFunction, AbiParametersToPrimitiveTypes } from "abitype"; -import { zoraCreator1155PremintExecutorImplABI as preminterAbi } from "@zoralabs/protocol-deployments"; -import { TypedDataDefinition } from "viem"; +import { + zoraCreator1155PremintExecutorImplABI as preminterAbi, + zoraCreator1155PremintExecutorImplAddress, +} from "@zoralabs/protocol-deployments"; +import { + TypedDataDefinition, + recoverTypedDataAddress, + Hex, + PublicClient, +} from "viem"; type PremintInputs = ExtractAbiFunction< typeof preminterAbi, @@ -68,5 +76,63 @@ export const preminterTypedDataDefinition = ({ primaryType: "CreatorAttribution", }; + // console.log({ result, deleted }); + return result; }; + +export async function isValidSignatureV1({ + contractAddress, + originalContractAdmin, + premintConfig, + signature, + chainId, + publicClient, +}: { + contractAddress: Address; + originalContractAdmin: Address; + premintConfig: PremintConfig; + signature: Hex; + chainId: number; + publicClient: PublicClient; +}): Promise<{ + isAuthorized: boolean; + recoveredAddress?: Address; +}> { + const typedData = preminterTypedDataDefinition({ + verifyingContract: contractAddress, + premintConfig, + chainId, + }); + + // recover the address from the signature + let recoveredAddress: Address; + + try { + recoveredAddress = await recoverTypedDataAddress({ + ...typedData, + signature, + }); + } catch (error) { + console.error(error); + + return { + isAuthorized: false, + }; + } + + // premint executor is same address on all chains + const premintExecutorAddress = zoraCreator1155PremintExecutorImplAddress[999]; + + const isAuthorized = await publicClient.readContract({ + abi: preminterAbi, + address: premintExecutorAddress, + functionName: "isAuthorizedToCreatePremint", + args: [recoveredAddress, originalContractAdmin, contractAddress], + }); + + return { + isAuthorized, + recoveredAddress, + }; +} diff --git a/packages/protocol-sdk/src/preminter.test.ts b/packages/protocol-sdk/src/preminter.test.ts new file mode 100644 index 000000000..8a0d5bff8 --- /dev/null +++ b/packages/protocol-sdk/src/preminter.test.ts @@ -0,0 +1,515 @@ +import { + createTestClient, + http, + createWalletClient, + createPublicClient, + keccak256, + Hex, + concat, + recoverAddress, + hashDomain, +} from "viem"; +import { foundry, zora } from "viem/chains"; +import { describe, it, beforeEach, expect, afterEach } from "vitest"; +import { parseEther } from "viem"; +import { + zoraCreator1155PremintExecutorImplABI as preminterAbi, + zoraCreator1155PremintExecutorImplAddress as zoraCreator1155PremintExecutorAddress, + zoraCreator1155ImplABI, + zoraCreator1155FactoryImplAddress, + zoraCreator1155FactoryImplConfig, +} from "@zoralabs/protocol-deployments"; + +import { + ContractCreationConfig, + PremintConfig, + TokenCreationConfig, + isValidSignatureV1, + preminterTypedDataDefinition, +} from "./preminter"; + +const walletClient = createWalletClient({ + chain: foundry, + transport: http(), +}); + +export const walletClientWithAccount = createWalletClient({ + chain: foundry, + transport: http(), +}); + +const testClient = createTestClient({ + chain: foundry, + mode: "anvil", + transport: http(), +}); + +const publicClient = createPublicClient({ + chain: foundry, + transport: http(), +}); + +type Address = `0x${string}`; + +// JSON-RPC Account +const [deployerAccount, creatorAccount, collectorAccount] = + (await walletClient.getAddresses()) as [Address, Address, Address, Address]; + +type TestContext = { + preminterAddress: `0x${string}`; + forkedChainId: keyof typeof zoraCreator1155FactoryImplAddress; + anvilChainId: number; + zoraMintFee: bigint; + fixedPriceMinterAddress: Address; +}; + +// create token and contract creation config: +const defaultContractConfig = ({ + contractAdmin, +}: { + contractAdmin: Address; +}): ContractCreationConfig => ({ + contractAdmin, + contractURI: "ipfs://asdfasdfasdfgh", + contractName: "My fun NFT", +}); + +const defaultTokenConfig = ( + fixedPriceMinterAddress: Address, +): TokenCreationConfig => ({ + tokenURI: "ipfs://tokenIpfsId0", + maxSupply: 100n, + maxTokensPerAddress: 10n, + pricePerToken: 0n, + mintStart: 0n, + mintDuration: 100n, + royaltyMintSchedule: 30, + royaltyBPS: 200, + royaltyRecipient: creatorAccount, + fixedPriceMinter: fixedPriceMinterAddress, +}); + +const defaultPremintConfig = (fixedPriceMinter: Address): PremintConfig => ({ + tokenConfig: defaultTokenConfig(fixedPriceMinter), + deleted: false, + uid: 105, + version: 0, +}); + +describe("ZoraCreator1155Preminter", () => { + beforeEach(async (ctx) => { + // deploy signature minter contract + await testClient.setBalance({ + address: deployerAccount, + value: parseEther("10"), + }); + + ctx.forkedChainId = zora.id; + ctx.anvilChainId = foundry.id; + + ctx.fixedPriceMinterAddress = await publicClient.readContract({ + abi: zoraCreator1155FactoryImplConfig.abi, + address: zoraCreator1155FactoryImplAddress[ctx.forkedChainId], + functionName: "fixedPriceMinter", + }); + ctx.zoraMintFee = parseEther("0.000777"); + + ctx.preminterAddress = + zoraCreator1155PremintExecutorAddress[ctx.forkedChainId]; + }, 20 * 1000); + + afterEach(() => { + testClient.reset(); + }, 4 * 1000); + + // skip for now - we need to make this work on zora testnet chain too + it( + "can sign on the forked premint contract", + async ({ fixedPriceMinterAddress, forkedChainId }) => { + const premintConfig = defaultPremintConfig(fixedPriceMinterAddress); + const contractConfig = defaultContractConfig({ + contractAdmin: creatorAccount, + }); + + const preminterAddress = zoraCreator1155PremintExecutorAddress[ + forkedChainId as keyof typeof zoraCreator1155PremintExecutorAddress + ] as Address; + + const contractAddress = await publicClient.readContract({ + abi: preminterAbi, + address: preminterAddress, + functionName: "getContractAddress", + args: [contractConfig], + }); + + const signedMessage = await walletClient.signTypedData({ + ...preminterTypedDataDefinition({ + verifyingContract: contractAddress, + chainId: 999, + premintConfig, + }), + account: creatorAccount, + }); + + console.log({ + creatorAccount, + signedMessage, + contractConfig, + premintConfig, + contractAddress, + }); + }, + 20 * 1000, + ); + it( + "can sign and recover a signature", + async ({ + preminterAddress: preminterAddress, + anvilChainId, + fixedPriceMinterAddress, + }) => { + const premintConfig = defaultPremintConfig(fixedPriceMinterAddress); + const contractConfig = defaultContractConfig({ + contractAdmin: creatorAccount, + }); + + const contractAddress = await publicClient.readContract({ + abi: preminterAbi, + address: preminterAddress, + functionName: "getContractAddress", + args: [contractConfig], + }); + + // sign message containing contract and token creation config and uid + const signedMessage = await walletClient.signTypedData({ + ...preminterTypedDataDefinition({ + verifyingContract: contractAddress, + // we need to sign here for the anvil chain, cause thats where it is run on + chainId: anvilChainId, + premintConfig, + }), + account: creatorAccount, + }); + + // recover and verify address is correct + const { recoveredAddress, isAuthorized } = await isValidSignatureV1({ + contractAddress, + chainId: anvilChainId, + originalContractAdmin: contractConfig.contractAdmin, + premintConfig, + publicClient, + signature: signedMessage, + }); + + expect(recoveredAddress).to.equal(creatorAccount); + expect(isAuthorized).toBe(true); + }, + + 20 * 1000, + ); + it( + "can sign and mint multiple tokens", + async ({ + zoraMintFee, + anvilChainId, + preminterAddress: preminterAddress, + fixedPriceMinterAddress, + }) => { + // setup contract and token creation parameters + const premintConfig = defaultPremintConfig(fixedPriceMinterAddress); + const contractConfig = defaultContractConfig({ + contractAdmin: creatorAccount, + }); + + // lets make it a random number to not break the existing tests that expect fresh data + premintConfig.uid = Math.round(Math.random() * 1000000); + + let contractAddress = await publicClient.readContract({ + abi: preminterAbi, + address: preminterAddress, + functionName: "getContractAddress", + args: [contractConfig], + }); + + // have creator sign the message to create the contract + // and the token + const signedMessage = await walletClient.signTypedData({ + ...preminterTypedDataDefinition({ + verifyingContract: contractAddress, + // we need to sign here for the anvil chain, cause thats where it is run on + chainId: anvilChainId, + premintConfig, + }), + account: creatorAccount, + }); + + const quantityToMint = 2n; + + const valueToSend = + (zoraMintFee + premintConfig.tokenConfig.pricePerToken) * + quantityToMint; + + const comment = "I love this!"; + + await testClient.setBalance({ + address: collectorAccount, + value: parseEther("10"), + }); + + // get the premint status - it should not be minted + let [contractCreated, tokenId] = await publicClient.readContract({ + abi: preminterAbi, + address: preminterAddress, + functionName: "premintStatus", + args: [contractAddress, premintConfig.uid], + }); + + expect(contractCreated).toBe(false); + expect(tokenId).toBe(0n); + + // now have the collector execute the first signed message; + // it should create the contract, the token, + // and min the quantity to mint tokens to the collector + // the signature along with contract + token creation + // parameters are required to call this function + const mintHash = await walletClient.writeContract({ + abi: preminterAbi, + functionName: "premint", + account: collectorAccount, + address: preminterAddress, + args: [ + contractConfig, + premintConfig, + signedMessage, + quantityToMint, + comment, + ], + value: valueToSend, + }); + + // ensure it succeeded + const receipt = await publicClient.waitForTransactionReceipt({ + hash: mintHash, + }); + + expect(receipt.status).toBe("success"); + + // fetch the premint token id + [contractCreated, tokenId] = await publicClient.readContract({ + abi: preminterAbi, + address: preminterAddress, + functionName: "premintStatus", + args: [contractAddress, premintConfig.uid], + }); + + expect(contractCreated).toBe(true); + expect(tokenId).not.toBe(0n); + + // now use what was created, to get the balance from the created contract + const tokenBalance = await publicClient.readContract({ + abi: zoraCreator1155ImplABI, + address: contractAddress, + functionName: "balanceOf", + args: [collectorAccount, tokenId], + }); + + // get token balance - should be amount that was created + expect(tokenBalance).toBe(quantityToMint); + + const premintConfig2 = { + ...premintConfig, + uid: premintConfig.uid + 1, + tokenConfig: { + ...premintConfig.tokenConfig, + tokenURI: "ipfs://tokenIpfsId2", + pricePerToken: parseEther("0.05"), + }, + }; + + // sign the message to create the second token + const signedMessage2 = await walletClient.signTypedData({ + ...preminterTypedDataDefinition({ + verifyingContract: contractAddress, + chainId: foundry.id, + premintConfig: premintConfig2, + }), + account: creatorAccount, + }); + + const quantityToMint2 = 4n; + + const valueToSend2 = + (zoraMintFee + premintConfig2.tokenConfig.pricePerToken) * + quantityToMint2; + + // now have the collector execute the second signed message. + // it should create a new token against the existing contract + const mintHash2 = await walletClient.writeContract({ + abi: preminterAbi, + functionName: "premint", + account: collectorAccount, + address: preminterAddress, + args: [ + contractConfig, + premintConfig2, + signedMessage2, + quantityToMint2, + comment, + ], + value: valueToSend2, + }); + + expect( + (await publicClient.waitForTransactionReceipt({ hash: mintHash2 })) + .status, + ).toBe("success"); + + // now premint status for the second mint, it should be minted + [, tokenId] = await publicClient.readContract({ + abi: preminterAbi, + address: preminterAddress, + functionName: "premintStatus", + args: [contractAddress, premintConfig2.uid], + }); + + expect(tokenId).not.toBe(0n); + + // get balance of second token + const tokenBalance2 = await publicClient.readContract({ + abi: zoraCreator1155ImplABI, + address: contractAddress, + functionName: "balanceOf", + args: [collectorAccount, tokenId], + }); + + expect(tokenBalance2).toBe(quantityToMint2); + }, + // 10 second timeout + 40 * 1000, + ); + + it("can decode the CreatorAttribution event", async ({ + zoraMintFee, + anvilChainId, + preminterAddress: preminterAddress, + fixedPriceMinterAddress, + }) => { + const premintConfig = defaultPremintConfig(fixedPriceMinterAddress); + const contractConfig = defaultContractConfig({ + contractAdmin: creatorAccount, + }); + + // lets make it a random number to not break the existing tests that expect fresh data + premintConfig.uid = Math.round(Math.random() * 1000000); + + let contractAddress = await publicClient.readContract({ + abi: preminterAbi, + address: preminterAddress, + functionName: "getContractAddress", + args: [contractConfig], + }); + + // have creator sign the message to create the contract + // and the token + const signedMessage = await walletClient.signTypedData({ + ...preminterTypedDataDefinition({ + verifyingContract: contractAddress, + // we need to sign here for the anvil chain, cause thats where it is run on + chainId: anvilChainId, + premintConfig, + }), + account: creatorAccount, + }); + + const quantityToMint = 2n; + + const valueToSend = + (zoraMintFee + premintConfig.tokenConfig.pricePerToken) * quantityToMint; + + const comment = "I love this!"; + + await testClient.setBalance({ + address: collectorAccount, + value: parseEther("10"), + }); + + // now have the collector execute the first signed message; + // it should create the contract, the token, + // and min the quantity to mint tokens to the collector + // the signature along with contract + token creation + // parameters are required to call this function + const mintHash = await walletClient.writeContract({ + abi: preminterAbi, + functionName: "premint", + account: collectorAccount, + address: preminterAddress, + args: [ + contractConfig, + premintConfig, + signedMessage, + quantityToMint, + comment, + ], + value: valueToSend, + }); + + // ensure it succeeded + const receipt = await publicClient.waitForTransactionReceipt({ + hash: mintHash, + }); + + expect(receipt.status).toBe("success"); + + // get the CreatorAttribution event from the erc1155 contract: + const topics = await publicClient.getContractEvents({ + abi: zoraCreator1155ImplABI, + address: contractAddress, + eventName: "CreatorAttribution", + }); + + expect(topics.length).toBe(1); + + const creatorAttributionEvent = topics[0]!; + + const { creator, domainName, signature, structHash, version } = + creatorAttributionEvent.args; + + const chainId = anvilChainId; + + // hash the eip712 domain based on the parameters emitted from the event: + const hashedDomain = hashDomain({ + domain: { + chainId, + name: domainName, + verifyingContract: contractAddress, + version, + }, + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { + name: "chainId", + type: "uint256", + }, + { + name: "verifyingContract", + type: "address", + }, + ], + }, + }); + + // re-build the eip-712 typed data hash, consisting of the hashed domain and the structHash emitted from the event: + const parts: Hex[] = ["0x1901", hashedDomain, structHash!]; + + const hashedTypedData = keccak256(concat(parts)); + + const recoveredSigner = await recoverAddress({ + hash: hashedTypedData, + signature: signature!, + }); + + expect(recoveredSigner).toBe(creator); + }); +}); diff --git a/packages/protocol-sdk/src/preminter.ts b/packages/protocol-sdk/src/preminter.ts new file mode 100644 index 000000000..ec46eb1c2 --- /dev/null +++ b/packages/protocol-sdk/src/preminter.ts @@ -0,0 +1,136 @@ +import { Address } from "abitype"; +import { ExtractAbiFunction, AbiParametersToPrimitiveTypes } from "abitype"; +import { + zoraCreator1155PremintExecutorImplABI as preminterAbi, + zoraCreator1155PremintExecutorImplAddress, +} from "@zoralabs/protocol-deployments"; +import { + TypedDataDefinition, + recoverTypedDataAddress, + Hex, + PublicClient, +} from "viem"; + +type PremintInputs = ExtractAbiFunction< + typeof preminterAbi, + "premint" +>["inputs"]; + +type PreminterHashDataTypes = AbiParametersToPrimitiveTypes; + +export type ContractCreationConfig = PreminterHashDataTypes[0]; +export type PremintConfig = PreminterHashDataTypes[1]; +export type TokenCreationConfig = PremintConfig["tokenConfig"]; + +// Convenience method to create the structured typed data +// needed to sign for a premint contract and token +export const preminterTypedDataDefinition = ({ + verifyingContract, + premintConfig, + chainId, +}: { + verifyingContract: Address; + premintConfig: PremintConfig; + chainId: number; +}) => { + const { tokenConfig, uid, version, deleted } = premintConfig; + const types = { + CreatorAttribution: [ + { name: "tokenConfig", type: "TokenCreationConfig" }, + // unique id scoped to the contract and token to create. + // ensure that a signature can be replaced, as long as the replacement + // has the same uid, and a newer version. + { name: "uid", type: "uint32" }, + { name: "version", type: "uint32" }, + // if this update should result in the signature being deleted. + { name: "deleted", type: "bool" }, + ], + TokenCreationConfig: [ + { name: "tokenURI", type: "string" }, + { name: "maxSupply", type: "uint256" }, + { name: "maxTokensPerAddress", type: "uint64" }, + { name: "pricePerToken", type: "uint96" }, + { name: "mintStart", type: "uint64" }, + { name: "mintDuration", type: "uint64" }, + { name: "royaltyMintSchedule", type: "uint32" }, + { name: "royaltyBPS", type: "uint32" }, + { name: "royaltyRecipient", type: "address" }, + { name: "fixedPriceMinter", type: "address" }, + ], + }; + + const result: TypedDataDefinition = { + domain: { + chainId, + name: "Preminter", + version: "1", + verifyingContract: verifyingContract, + }, + types, + message: { + tokenConfig, + uid, + version, + deleted, + }, + primaryType: "CreatorAttribution", + }; + + return result; +}; + +export async function isValidSignatureV1({ + contractAddress, + originalContractAdmin, + premintConfig, + signature, + chainId, + publicClient, +}: { + contractAddress: Address; + originalContractAdmin: Address; + premintConfig: PremintConfig; + signature: Hex; + chainId: number; + publicClient: PublicClient; +}): Promise<{ + isAuthorized: boolean; + recoveredAddress?: Address; +}> { + const typedData = preminterTypedDataDefinition({ + verifyingContract: contractAddress, + premintConfig, + chainId, + }); + + // recover the address from the signature + let recoveredAddress: Address; + + try { + recoveredAddress = await recoverTypedDataAddress({ + ...typedData, + signature, + }); + } catch (error) { + console.error(error); + + return { + isAuthorized: false, + }; + } + + // premint executor is same address on all chains + const premintExecutorAddress = zoraCreator1155PremintExecutorImplAddress[999]; + + const isAuthorized = await publicClient.readContract({ + abi: preminterAbi, + address: premintExecutorAddress, + functionName: "isAuthorizedToCreatePremint", + args: [recoveredAddress, originalContractAdmin, contractAddress], + }); + + return { + isAuthorized, + recoveredAddress, + }; +}