diff --git a/.changeset/rare-wolves-cheat.md b/.changeset/rare-wolves-cheat.md new file mode 100644 index 000000000..824881640 --- /dev/null +++ b/.changeset/rare-wolves-cheat.md @@ -0,0 +1,18 @@ +--- +"@zoralabs/protocol-sdk": patch +--- + +### Changes to `preminter` + +* `isValidSignature` now takes either v1 or v2 of a premint config, along with the premint config version. and both recovers the signer address and validates if the signer can create a premint on the given contract. + +### Changes to PremintClient + +sdk now supports creating and signing premints for v2 of Premint Config: + +* `preminter.isValidSignature` +* new function `preminter.supportsPremintVersion` which checks if a given token contract supports a given premint config version +* new function `preminter.recoverCreatorFromCreatorAttribution` which recovers the creator address from a `CreatorAttribution` event +* `preminter.premintTypedDataDefinition` now takes a premint config version, and returns the correct typed data definition for that version + +* premint client methods now work with both v1 and v2 of the premint config, and takes an additional premint config version parameter \ No newline at end of file diff --git a/packages/protocol-sdk/src/anvil.ts b/packages/protocol-sdk/src/anvil.ts index 69531e944..78a78ca7c 100644 --- a/packages/protocol-sdk/src/anvil.ts +++ b/packages/protocol-sdk/src/anvil.ts @@ -28,13 +28,15 @@ async function waitForAnvilInit(anvil: any) { }); } +export type AnvilTestForkSettings = { + forkUrl: string; + forkBlockNumber: number; +}; + export const makeAnvilTest = ({ forkUrl, forkBlockNumber, -}: { - forkUrl: string; - forkBlockNumber: number; -}) => +}: AnvilTestForkSettings) => test.extend({ viemClients: async ({ task }, use) => { console.log("setting up clients for ", task.name); @@ -93,6 +95,7 @@ export const makeAnvilTest = ({ export const forkUrls = { zoraMainnet: "https://rpc.zora.co/", zoraGoerli: "https://testnet.rpc.zora.co", + zoraSepoli: "https://sepolia.rpc.zora.energy", }; export const anvilTest = makeAnvilTest({ diff --git a/packages/protocol-sdk/src/premint/premint-client.test.ts b/packages/protocol-sdk/src/premint/premint-client.test.ts index 7a656df6e..154fd0fb8 100644 --- a/packages/protocol-sdk/src/premint/premint-client.test.ts +++ b/packages/protocol-sdk/src/premint/premint-client.test.ts @@ -1,10 +1,14 @@ import { foundry } from "viem/chains"; import { describe, expect, vi } from "vitest"; import { createPremintClient } from "./premint-client"; -import { anvilTest } from "src/anvil"; +import { anvilTest, forkUrls, makeAnvilTest } from "src/anvil"; +import { PremintSignatureResponse } from "./premint-api-client"; describe("ZoraCreator1155Premint", () => { - anvilTest( + makeAnvilTest({ + forkUrl: forkUrls.zoraGoerli, + forkBlockNumber: 1763437, + })( "can sign on the forked premint contract", async ({ viemClients: { walletClient, publicClient } }) => { const [deployerAccount] = await walletClient.getAddresses(); @@ -76,7 +80,7 @@ describe("ZoraCreator1155Premint", () => { publicClient, }); - const premintData = { + const premintData: PremintSignatureResponse = { collection: { contractAdmin: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", contractName: "Testing Contract", @@ -101,15 +105,12 @@ describe("ZoraCreator1155Premint", () => { "ipfs://bafkreice23maski3x52tsfqgxstx3kbiifnt5jotg3a5ynvve53c4soi2u", }, }, - chain_name: "ZORA-TESTNET", + chain_name: "ZORA-GOERLI", signature: "0x588d19641de9ba1dade4d2bb5387c8dc96f4a990fef69787534b60caead759e6334975a6be10a796da948cd7d1d4f5580b3f84d49d9fa4e0b41c97759507975a1c", } as const; - const signatureValid = await premintClient.isValidSignature({ - // @ts-ignore: Fix enum type - data: premintData, - }); + const signatureValid = await premintClient.isValidSignature(premintData); expect(signatureValid.isValid).toBe(true); }, ); diff --git a/packages/protocol-sdk/src/premint/premint-client.ts b/packages/protocol-sdk/src/premint/premint-client.ts index 33abcbb35..46ed2efeb 100644 --- a/packages/protocol-sdk/src/premint/premint-client.ts +++ b/packages/protocol-sdk/src/premint/premint-client.ts @@ -14,7 +14,17 @@ import { zoraCreator1155PremintExecutorImplAddress, zoraCreatorFixedPriceSaleStrategyAddress, } from "@zoralabs/protocol-deployments"; -import { PremintConfig, preminterTypedDataDefinition } from "./preminter"; +import { + PremintConfigAndVersion, + PremintConfigV1, + PremintConfigV2, + PremintConfigVersion, + getPremintCollectionAddress, + premintTypedDataDefinition, + ContractCreationConfig, + recoverAndValidateSignature, + isAuthorizedToCreatePremint, +} from "./preminter"; import type { PremintSignatureGetResponse, PremintSignatureResponse, @@ -96,7 +106,7 @@ export function getPremintedLogFromReceipt( * @param premint Premint object from the server to convert to one that's compatible with viem * @returns Viem type-compatible premint object */ -export const convertPremint = ( +export const convertPremintV1 = ( premint: PremintSignatureGetResponse["premint"], ) => ({ ...premint, @@ -125,10 +135,25 @@ export const convertCollection = ( * @param premint Premint object from viem to convert to a JSON compatible type. * @returns JSON compatible premint */ -export const encodePremintForAPI = ({ +export const encodePremintV1ForAPI = ({ + tokenConfig, + ...premint +}: PremintConfigV1) => ({ + ...premint, + tokenConfig: { + ...tokenConfig, + maxSupply: tokenConfig.maxSupply.toString(), + pricePerToken: tokenConfig.pricePerToken.toString(), + mintStart: tokenConfig.mintStart.toString(), + mintDuration: tokenConfig.mintDuration.toString(), + maxTokensPerAddress: tokenConfig.maxTokensPerAddress.toString(), + }, +}); + +export const encodePremintV2ForAPI = ({ tokenConfig, ...premint -}: PremintConfig) => ({ +}: PremintConfigV2) => ({ ...premint, tokenConfig: { ...tokenConfig, @@ -140,6 +165,19 @@ export const encodePremintForAPI = ({ }, }); +export const encodePremintForAPI = ({ + premintConfig, + premintConfigVersion, +}: PremintConfigAndVersion) => { + if (premintConfigVersion === PremintConfigVersion.V1) { + return encodePremintV1ForAPI(premintConfig); + } + if (premintConfigVersion === PremintConfigVersion.V2) { + return encodePremintV2ForAPI(premintConfig); + } + throw new Error(`Invalid premint config version ${premintConfigVersion}`); +}; + /** * Preminter API to access ZORA Premint functionality. * Currently only supports V1 premints. @@ -228,7 +266,7 @@ class PremintClient { uid: uid, }); - const convertedPremint = convertPremint(signatureResponse.premint); + const convertedPremint = convertPremintV1(signatureResponse.premint); const signerData = { ...signatureResponse, premint: { @@ -251,6 +289,7 @@ class PremintClient { contractAdmin: signerData.collection.contractAdmin as Address, }, premintConfig: signerData.premint, + premintConfigVersion: PremintConfigVersion.V1, }); } @@ -288,7 +327,7 @@ class PremintClient { ...signatureResponse, collection: convertCollection(signatureResponse.collection), premint: { - ...convertPremint(signatureResponse.premint), + ...convertPremintV1(signatureResponse.premint), deleted: true, }, }; @@ -301,6 +340,7 @@ class PremintClient { uid: uid, collection: signerData.collection, premintConfig: signerData.premint, + premintConfigVersion: PremintConfigVersion.V1, }); } @@ -313,20 +353,19 @@ class PremintClient { private async signAndSubmitPremint({ walletClient, verifyingContract, - premintConfig, uid, account, checkSignature, collection, + ...premintConfigAndVersion }: { uid: number; walletClient: WalletClient; verifyingContract: Address; checkSignature: boolean; account?: Address | Account; - premintConfig: PremintConfig; collection: PremintSignatureGetResponse["collection"]; - }) { + } & PremintConfigAndVersion) { if (!account) { account = walletClient.account; } @@ -336,28 +375,37 @@ class PremintClient { const signature = await walletClient.signTypedData({ account, - ...preminterTypedDataDefinition({ + ...premintTypedDataDefinition({ verifyingContract, - premintConfig, + ...premintConfigAndVersion, chainId: this.chain.id, }), }); if (checkSignature) { - const [isValidSignature] = await this.publicClient.readContract({ - abi: zoraCreator1155PremintExecutorImplABI, - address: this.getExecutorAddress(), - functionName: "isValidSignature", - args: [convertCollection(collection), premintConfig, signature], + const convertedCollection = convertCollection(collection); + const isAuthorized = await isAuthorizedToCreatePremint({ + collection: convertCollection(collection), + signature, + publicClient: this.publicClient, + signer: typeof account === "string" ? account : account.address, + collectionAddress: await this.getCollectionAddres(convertedCollection), + ...premintConfigAndVersion, }); - if (!isValidSignature) { - throw new Error("Invalid signature"); + if (!isAuthorized) { + throw new Error("Not authorized to create premint"); } } + if ( + premintConfigAndVersion.premintConfigVersion === PremintConfigVersion.V2 + ) { + throw new Error("premint config v2 not supported yet"); + } + const apiData = { collection, - premint: encodePremintForAPI(premintConfig), + premint: encodePremintV1ForAPI(premintConfigAndVersion.premintConfig), signature: signature, }; @@ -404,11 +452,9 @@ class PremintClient { uid?: number; }; }) { - const newContractAddress = await this.publicClient.readContract({ - address: this.getExecutorAddress(), - abi: zoraCreator1155PremintExecutorImplABI, - functionName: "getContractAddress", - args: [convertCollection(collection)], + const newContractAddress = await getPremintCollectionAddress({ + publicClient: this.publicClient, + collection: convertCollection(collection), }); const tokenConfig = { @@ -432,7 +478,7 @@ class PremintClient { let deleted = executionSettings?.deleted || false; - const premintConfig = { + const premintConfig: PremintConfigV1 = { tokenConfig: tokenConfig, uid, version: 1, @@ -443,6 +489,7 @@ class PremintClient { uid, verifyingContract: newContractAddress, premintConfig, + premintConfigVersion: PremintConfigVersion.V1, checkSignature, account, walletClient, @@ -470,34 +517,33 @@ class PremintClient { }); } + async getCollectionAddres(collection: ContractCreationConfig) { + return await getPremintCollectionAddress({ + collection, + publicClient: this.publicClient, + }); + } + /** * Check user signature for v1 * * @param data Signature data from the API - * @returns isValid = signature is valid or not, contractAddress = assumed contract address, recoveredSigner = signer from contract + * @returns isValid = signature is valid or not, recoveredSigner = signer from contract */ - async isValidSignature({ - data, - }: { - data: PremintSignatureGetResponse; - }): Promise<{ + async isValidSignature(data: PremintSignatureResponse): Promise<{ isValid: boolean; - contractAddress: Address; - recoveredSigner: Address; + recoveredSigner: Address | undefined; }> { - const [isValid, contractAddress, recoveredSigner] = - await this.publicClient.readContract({ - abi: zoraCreator1155PremintExecutorImplABI, - address: this.getExecutorAddress(), - functionName: "isValidSignature", - args: [ - convertCollection(data.collection), - convertPremint(data.premint), - data.signature as Hex, - ], - }); + const {isAuthorized, recoveredAddress }= await recoverAndValidateSignature({ + chainId: this.chain.id, + signature: data.signature as Hex, + premintConfig: convertPremintV1(data.premint), + premintConfigVersion: PremintConfigVersion.V1, + collection: convertCollection(data.collection), + publicClient: this.publicClient, + }); - return { isValid, contractAddress, recoveredSigner }; + return { isValid: isAuthorized, recoveredSigner: recoveredAddress }; } protected makeUrls({ @@ -566,7 +612,7 @@ class PremintClient { const numberToMint = BigInt(mintArguments?.quantityToMint || 1); const args = [ convertCollection(data.collection), - convertPremint(data.premint), + convertPremintV1(data.premint), data.signature as Hex, numberToMint, mintArguments?.mintComment || "", diff --git a/packages/protocol-sdk/src/premint/preminter.test.ts b/packages/protocol-sdk/src/premint/preminter.test.ts index 16368ee4a..0df9c6dc7 100644 --- a/packages/protocol-sdk/src/premint/preminter.test.ts +++ b/packages/protocol-sdk/src/premint/preminter.test.ts @@ -1,17 +1,9 @@ -import { - keccak256, - Hex, - concat, - recoverAddress, - hashDomain, - Address, -} from "viem"; +import { Address, zeroAddress } from "viem"; import { foundry } from "viem/chains"; import { describe, expect } from "vitest"; import { parseEther } from "viem"; import { zoraCreator1155PremintExecutorImplABI as preminterAbi, - zoraCreator1155PremintExecutorImplAddress as zoraCreator1155PremintExecutorAddress, zoraCreator1155ImplABI, zoraCreator1155FactoryImplAddress, zoraCreator1155FactoryImplConfig, @@ -19,16 +11,18 @@ import { import { ContractCreationConfig, - PremintConfig, - TokenCreationConfig, - preminterTypedDataDefinition, + PremintConfigV1, + TokenCreationConfigV1, + premintTypedDataDefinition, + isValidSignature, + PremintConfigVersion, + TokenCreationConfigV2, + PremintConfigV2, + MintArguments, + recoverCreatorFromCreatorAttribution, + getPremintExecutorAddress, } from "./preminter"; -import { - AnvilViemClientsTest, - anvilTest, - forkUrls, - makeAnvilTest, -} from "src/anvil"; +import { AnvilViemClientsTest, forkUrls, makeAnvilTest } from "src/anvil"; // create token and contract creation config: const defaultContractConfig = ({ @@ -41,10 +35,10 @@ const defaultContractConfig = ({ contractName: "My fun NFT", }); -const defaultTokenConfig = ( +const defaultTokenConfigV1 = ( fixedPriceMinterAddress: Address, creatorAccount: Address, -): TokenCreationConfig => ({ +): TokenCreationConfigV1 => ({ tokenURI: "ipfs://tokenIpfsId0", maxSupply: 100n, maxTokensPerAddress: 10n, @@ -57,19 +51,63 @@ const defaultTokenConfig = ( fixedPriceMinter: fixedPriceMinterAddress, }); -const defaultPremintConfig = ( - fixedPriceMinter: Address, +const defaultTokenConfigV2 = ( + fixedPriceMinterAddress: Address, creatorAccount: Address, -): PremintConfig => ({ - tokenConfig: defaultTokenConfig(fixedPriceMinter, creatorAccount), + createReferral: Address, +): TokenCreationConfigV2 => ({ + tokenURI: "ipfs://tokenIpfsId0", + maxSupply: 100n, + maxTokensPerAddress: 10n, + pricePerToken: 0n, + mintStart: 0n, + mintDuration: 100n, + royaltyBPS: 200, + payoutRecipient: creatorAccount, + fixedPriceMinter: fixedPriceMinterAddress, + createReferral, +}); + +const defaultPremintConfigV1 = ({ + fixedPriceMinter, + creatorAccount, +}: { + fixedPriceMinter: Address; + creatorAccount: Address; +}): PremintConfigV1 => ({ + tokenConfig: defaultTokenConfigV1(fixedPriceMinter, creatorAccount), deleted: false, uid: 105, version: 0, }); +const defaultPremintConfigV2 = ({ + fixedPriceMinter, + creatorAccount, + createReferral = zeroAddress, +}: { + fixedPriceMinter: Address; + creatorAccount: Address; + createReferral?: Address; +}): PremintConfigV2 => ({ + tokenConfig: defaultTokenConfigV2( + fixedPriceMinter, + creatorAccount, + createReferral, + ), + deleted: false, + uid: 106, + version: 0, +}); + const ZORA_MINT_FEE = parseEther("0.000777"); -const PREMINTER_ADDRESS = zoraCreator1155PremintExecutorAddress[999]; +const PREMINTER_ADDRESS = getPremintExecutorAddress(); + +const anvilTest = makeAnvilTest({ + forkUrl: forkUrls.zoraSepoli, + forkBlockNumber: 1265490, +}); async function setupContracts({ viemClients: { walletClient, testClient, publicClient }, @@ -109,15 +147,15 @@ describe("ZoraCreator1155Preminter", () => { fixedPriceMinterAddress, accounts: { creatorAccount }, } = await setupContracts({ viemClients }); - const premintConfig = defaultPremintConfig( - fixedPriceMinterAddress, + const premintConfig = defaultPremintConfigV1({ + fixedPriceMinter: fixedPriceMinterAddress, creatorAccount, - ); + }); const contractConfig = defaultContractConfig({ contractAdmin: creatorAccount, }); - const preminterAddress = zoraCreator1155PremintExecutorAddress[999]; + const preminterAddress = getPremintExecutorAddress(); const contractAddress = await viemClients.publicClient.readContract({ abi: preminterAbi, @@ -127,10 +165,11 @@ describe("ZoraCreator1155Preminter", () => { }); const signedMessage = await viemClients.walletClient.signTypedData({ - ...preminterTypedDataDefinition({ + ...premintTypedDataDefinition({ verifyingContract: contractAddress, chainId: 999, premintConfig, + premintConfigVersion: PremintConfigVersion.V1, }), account: creatorAccount, }); @@ -145,26 +184,81 @@ describe("ZoraCreator1155Preminter", () => { }, 20 * 1000, ); + anvilTest( + "can sign and recover a v1 premint config signature", + async ({ viemClients }) => { + const { + fixedPriceMinterAddress, + accounts: { creatorAccount }, + } = await setupContracts({ viemClients }); + + const premintConfig = defaultPremintConfigV1({ + fixedPriceMinter: fixedPriceMinterAddress, + creatorAccount, + }); + const contractConfig = defaultContractConfig({ + contractAdmin: creatorAccount, + }); + + const tokenContract = await viemClients.publicClient.readContract({ + abi: preminterAbi, + address: PREMINTER_ADDRESS, + functionName: "getContractAddress", + args: [contractConfig], + }); + + // sign message containing contract and token creation config and uid + const signedMessage = await viemClients.walletClient.signTypedData({ + ...premintTypedDataDefinition({ + verifyingContract: tokenContract, + // we need to sign here for the anvil chain, cause thats where it is run on + chainId: foundry.id, + premintConfig, + premintConfigVersion: PremintConfigVersion.V1, + }), + account: creatorAccount, + }); + + // recover and verify address is correct + const { recoveredAddress, isAuthorized } = await isValidSignature({ + tokenContract, + chainId: viemClients.publicClient.chain!.id, + originalContractAdmin: contractConfig.contractAdmin, + premintConfig, + premintConfigVersion: PremintConfigVersion.V1, + publicClient: viemClients.publicClient, + signature: signedMessage, + }); + + expect(recoveredAddress).to.equal(creatorAccount); + expect(isAuthorized).toBe(true); + + expect(recoveredAddress).to.equal(creatorAccount); + }, + + 20 * 1000, + ); makeAnvilTest({ - forkUrl: forkUrls.zoraGoerli, - forkBlockNumber: 1676105, + forkUrl: forkUrls.zoraSepoli, + forkBlockNumber: 1262991, })( - "can sign and recover a signature", + "can sign and recover a v2 premint config signature", async ({ viemClients }) => { const { fixedPriceMinterAddress, accounts: { creatorAccount }, } = await setupContracts({ viemClients }); - const premintConfig = defaultPremintConfig( - fixedPriceMinterAddress, + const premintConfig = defaultPremintConfigV2({ creatorAccount, - ); + fixedPriceMinter: fixedPriceMinterAddress, + createReferral: creatorAccount, + }); const contractConfig = defaultContractConfig({ contractAdmin: creatorAccount, }); - const contractAddress = await viemClients.publicClient.readContract({ + const tokenContract = await viemClients.publicClient.readContract({ abi: preminterAbi, address: PREMINTER_ADDRESS, functionName: "getContractAddress", @@ -173,24 +267,29 @@ describe("ZoraCreator1155Preminter", () => { // sign message containing contract and token creation config and uid const signedMessage = await viemClients.walletClient.signTypedData({ - ...preminterTypedDataDefinition({ - verifyingContract: contractAddress, + ...premintTypedDataDefinition({ + verifyingContract: tokenContract, // we need to sign here for the anvil chain, cause thats where it is run on chainId: foundry.id, premintConfig, + premintConfigVersion: PremintConfigVersion.V2, }), 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 isValidSignature({ + tokenContract: tokenContract, + chainId: viemClients.publicClient.chain!.id, + originalContractAdmin: contractConfig.contractAdmin, + premintConfig, + premintConfigVersion: PremintConfigVersion.V2, + publicClient: viemClients.publicClient, + signature: signedMessage, + }); + + expect(recoveredAddress).to.equal(creatorAccount); + expect(isAuthorized).toBe(true); expect(recoveredAddress).to.equal(creatorAccount); }, @@ -205,16 +304,16 @@ describe("ZoraCreator1155Preminter", () => { accounts: { creatorAccount, collectorAccount }, } = await setupContracts({ viemClients }); // setup contract and token creation parameters - const premintConfig = defaultPremintConfig( - fixedPriceMinterAddress, + const premintConfig1 = defaultPremintConfigV1({ + fixedPriceMinter: fixedPriceMinterAddress, creatorAccount, - ); + }); 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); + premintConfig1.uid = Math.round(Math.random() * 1000000); let contractAddress = await viemClients.publicClient.readContract({ abi: preminterAbi, @@ -226,11 +325,12 @@ describe("ZoraCreator1155Preminter", () => { // have creator sign the message to create the contract // and the token const signedMessage = await viemClients.walletClient.signTypedData({ - ...preminterTypedDataDefinition({ + ...premintTypedDataDefinition({ verifyingContract: contractAddress, // we need to sign here for the anvil chain, cause thats where it is run on chainId: foundry.id, - premintConfig, + premintConfig: premintConfig1, + premintConfigVersion: PremintConfigVersion.V1, }), account: creatorAccount, }); @@ -238,11 +338,9 @@ describe("ZoraCreator1155Preminter", () => { const quantityToMint = 2n; const valueToSend = - (ZORA_MINT_FEE + premintConfig.tokenConfig.pricePerToken) * + (ZORA_MINT_FEE + premintConfig1.tokenConfig.pricePerToken) * quantityToMint; - const comment = "I love this!"; - await viemClients.testClient.setBalance({ address: collectorAccount, value: parseEther("10"), @@ -254,12 +352,18 @@ describe("ZoraCreator1155Preminter", () => { abi: preminterAbi, address: PREMINTER_ADDRESS, functionName: "premintStatus", - args: [contractAddress, premintConfig.uid], + args: [contractAddress, premintConfig1.uid], }); expect(contractCreated).toBe(false); expect(tokenId).toBe(0n); + const mintArguments: MintArguments = { + mintComment: "", + mintRecipient: collectorAccount, + mintReferral: collectorAccount, + }; + // 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 @@ -267,16 +371,16 @@ describe("ZoraCreator1155Preminter", () => { // parameters are required to call this function const mintHash = await viemClients.walletClient.writeContract({ abi: preminterAbi, - functionName: "premint", + functionName: "premintV1", account: collectorAccount, chain: foundry, address: PREMINTER_ADDRESS, args: [ contractConfig, - premintConfig, + premintConfig1, signedMessage, quantityToMint, - comment, + mintArguments, ], value: valueToSend, }); @@ -293,7 +397,7 @@ describe("ZoraCreator1155Preminter", () => { abi: preminterAbi, address: PREMINTER_ADDRESS, functionName: "premintStatus", - args: [contractAddress, premintConfig.uid], + args: [contractAddress, premintConfig1.uid], }); expect(contractCreated).toBe(true); @@ -310,22 +414,19 @@ describe("ZoraCreator1155Preminter", () => { // 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"), - }, - }; + const premintConfig2 = defaultPremintConfigV2({ + creatorAccount, + fixedPriceMinter: fixedPriceMinterAddress, + createReferral: creatorAccount, + }); // sign the message to create the second token const signedMessage2 = await viemClients.walletClient.signTypedData({ - ...preminterTypedDataDefinition({ + ...premintTypedDataDefinition({ verifyingContract: contractAddress, chainId: foundry.id, premintConfig: premintConfig2, + premintConfigVersion: PremintConfigVersion.V2, }), account: creatorAccount, }); @@ -336,11 +437,9 @@ describe("ZoraCreator1155Preminter", () => { (ZORA_MINT_FEE + 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 viemClients.walletClient.writeContract({ + const simulationResult = await viemClients.publicClient.simulateContract({ abi: preminterAbi, - functionName: "premint", + functionName: "premintV2", account: collectorAccount, chain: foundry, address: PREMINTER_ADDRESS, @@ -349,18 +448,23 @@ describe("ZoraCreator1155Preminter", () => { premintConfig2, signedMessage2, quantityToMint2, - comment, + mintArguments, ], value: valueToSend2, }); - expect( - ( - await viemClients.publicClient.waitForTransactionReceipt({ - hash: mintHash2, - }) - ).status, - ).toBe("success"); + // now have the collector execute the second signed message. + // it should create a new token against the existing contract + const mintHash2 = await viemClients.walletClient.writeContract( + simulationResult.request, + ); + + const premintV2Receipt = + await viemClients.publicClient.waitForTransactionReceipt({ + hash: mintHash2, + }); + + expect(premintV2Receipt.status).toBe("success"); // now premint status for the second mint, it should be minted [, tokenId] = await viemClients.publicClient.readContract({ @@ -393,10 +497,10 @@ describe("ZoraCreator1155Preminter", () => { fixedPriceMinterAddress, accounts: { creatorAccount, collectorAccount }, } = await setupContracts({ viemClients }); - const premintConfig = defaultPremintConfig( - fixedPriceMinterAddress, + const premintConfig = defaultPremintConfigV2({ + fixedPriceMinter: fixedPriceMinterAddress, creatorAccount, - ); + }); const contractConfig = defaultContractConfig({ contractAdmin: creatorAccount, }); @@ -411,14 +515,17 @@ describe("ZoraCreator1155Preminter", () => { args: [contractConfig], }); + const signingChainId = foundry.id; + // have creator sign the message to create the contract // and the token const signedMessage = await viemClients.walletClient.signTypedData({ - ...preminterTypedDataDefinition({ + ...premintTypedDataDefinition({ verifyingContract: contractAddress, // we need to sign here for the anvil chain, cause thats where it is run on - chainId: foundry.id, + chainId: signingChainId, premintConfig, + premintConfigVersion: PremintConfigVersion.V2, }), account: creatorAccount, }); @@ -429,8 +536,6 @@ describe("ZoraCreator1155Preminter", () => { (ZORA_MINT_FEE + premintConfig.tokenConfig.pricePerToken) * quantityToMint; - const comment = "I love this!"; - await viemClients.testClient.setBalance({ address: collectorAccount, value: parseEther("10"), @@ -443,7 +548,7 @@ describe("ZoraCreator1155Preminter", () => { // parameters are required to call this function const mintHash = await viemClients.walletClient.writeContract({ abi: preminterAbi, - functionName: "premint", + functionName: "premintV2", account: collectorAccount, chain: foundry, address: PREMINTER_ADDRESS, @@ -452,7 +557,11 @@ describe("ZoraCreator1155Preminter", () => { premintConfig, signedMessage, quantityToMint, - comment, + { + mintComment: "", + mintRecipient: collectorAccount, + mintReferral: zeroAddress, + }, ], value: valueToSend, }); @@ -475,46 +584,16 @@ describe("ZoraCreator1155Preminter", () => { const creatorAttributionEvent = topics[0]!; - const { creator, domainName, signature, structHash, version } = - creatorAttributionEvent.args; + const { creator: creatorFromEvent } = creatorAttributionEvent.args; - const chainId = foundry.id; + const recoveredSigner = await recoverCreatorFromCreatorAttribution({ + creatorAttribution: creatorAttributionEvent.args, + chainId: signingChainId, + tokenContract: contractAddress, + }); - // 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); + expect(creatorFromEvent).toBe(creatorAccount); + expect(recoveredSigner).toBe(creatorFromEvent); }, ); }); diff --git a/packages/protocol-sdk/src/premint/preminter.ts b/packages/protocol-sdk/src/premint/preminter.ts index 35e83161d..e32a7bea3 100644 --- a/packages/protocol-sdk/src/premint/preminter.ts +++ b/packages/protocol-sdk/src/premint/preminter.ts @@ -1,72 +1,410 @@ 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, + zoraCreator1155ImplABI, + zoraCreator1155PremintExecutorImplABI, + zoraCreator1155PremintExecutorImplAddress, +} from "@zoralabs/protocol-deployments"; +import { + TypedDataDefinition, + recoverTypedDataAddress, + Hex, + PublicClient, + zeroAddress, + hashDomain, + keccak256, + concat, + recoverAddress, + GetEventArgs, +} from "viem"; -type PremintInputs = ExtractAbiFunction< +type PremintV1Inputs = ExtractAbiFunction< typeof preminterAbi, - "premint" + "premintV1" >["inputs"]; -type PreminterHashDataTypes = AbiParametersToPrimitiveTypes; +type PremintV1HashDataTypes = AbiParametersToPrimitiveTypes; -export type ContractCreationConfig = PreminterHashDataTypes[0]; -export type PremintConfig = PreminterHashDataTypes[1]; -export type TokenCreationConfig = PremintConfig["tokenConfig"]; +export type ContractCreationConfig = PremintV1HashDataTypes[0]; -// Convenience method to create the structured typed data -// needed to sign for a premint contract and token -export const preminterTypedDataDefinition = ({ +export type PremintConfigV1 = PremintV1HashDataTypes[1]; +export type TokenCreationConfigV1 = PremintConfigV1["tokenConfig"]; + +export type MintArguments = PremintV1HashDataTypes[4]; + +type PremintV2Inputs = ExtractAbiFunction< + typeof preminterAbi, + "premintV2" +>["inputs"]; + +type PremintV2HashDataTypes = AbiParametersToPrimitiveTypes; + +export type PremintConfigV2 = PremintV2HashDataTypes[1]; +export type TokenCreationConfigV2 = PremintConfigV2["tokenConfig"]; + +const v1Types = { + 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" }, + ], +} as const; + +const v2Types = { + 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: "royaltyBPS", type: "uint32" }, + { name: "payoutRecipient", type: "address" }, + { name: "fixedPriceMinter", type: "address" }, + { name: "createReferral", type: "address" }, + ], +} as const; + +export const PreminterDomain = "Preminter"; + +type PremintConfigVersion = "1" | "2"; + +export const PremintConfigVersion = { + V1: "1", + V2: "2", +} as const; + +type PremintConfigForVersion = T extends "1" + ? PremintConfigV1 + : PremintConfigV2; + +type PremintConfigWithVersion = { + premintConfig: PremintConfigForVersion; + premintConfigVersion: T; +}; + +export type PremintConfigAndVersion = + | PremintConfigWithVersion<"1"> + | PremintConfigWithVersion<"2">; + +export const getPremintExecutorAddress = () => + zoraCreator1155PremintExecutorImplAddress[999]; + +/** + * Creates a typed data definition for a premint config. Works for all versions of the premint config by specifying the premintConfigVersion. + * + * @param params.verifyingContract the address of the 1155 contract + * @param params.chainId the chain id the premint is signed for + * @param params.premintConfigVersion the version of the premint config + * @param params.premintConfig the premint config + * @returns + */ +export const premintTypedDataDefinition = ({ verifyingContract, - premintConfig, chainId, + premintConfigVersion: version, + premintConfig, }: { 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" }, +} & PremintConfigAndVersion): TypedDataDefinition => { + const domain = { + chainId, + name: PreminterDomain, + version, + verifyingContract: verifyingContract, + }; + + if (version === PremintConfigVersion.V1) + return { + domain, + types: v1Types, + message: premintConfig, + primaryType: "CreatorAttribution", + } satisfies TypedDataDefinition; + if (version === PremintConfigVersion.V2) { + return { + domain, + types: v2Types, + message: premintConfig, + primaryType: "CreatorAttribution", + } satisfies TypedDataDefinition; + } + + throw new Error(`Invalid version ${version}`); +}; + +export type IsValidSignatureReturn = { + isAuthorized: boolean; + recoveredAddress?: Address; +}; + +export async function isAuthorizedToCreatePremint({ + collection, + collectionAddress, + publicClient, + premintConfig, + premintConfigVersion, + signature, + signer, +}: { + collection: ContractCreationConfig; + collectionAddress: Address; + publicClient: PublicClient; + signature: Hex; + signer: Address; +} & PremintConfigAndVersion) { + // if we are using legacy version of premint config, we can use the function + // "isValidSignature" which we know exists on the premint executor contract + if (premintConfigVersion === PremintConfigVersion.V1) { + const [isValidSignature] = await publicClient.readContract({ + abi: zoraCreator1155PremintExecutorImplABI, + address: getPremintExecutorAddress(), + functionName: "isValidSignature", + args: [collection, premintConfig, signature], + }); + + return isValidSignature; + } + + // otherwize, we must assume the newer version of premint executor is deployed, so we call that. + return await publicClient.readContract({ + abi: preminterAbi, + address: getPremintExecutorAddress(), + functionName: "isAuthorizedToCreatePremint", + args: [ + signer, + collection.contractAdmin, + collectionAddress ], + }); +} + +export async function recoverPremintSigner({ + signature, + ...rest +}: { + signature: Hex; + chainId: number; + verifyingContract: Address; +} & PremintConfigAndVersion): Promise
{ + const typedData = premintTypedDataDefinition(rest); + return await recoverTypedDataAddress({ + ...typedData, + signature, + }); +} + +export async function tryRecoverPremintSigner( + params: Parameters[0], +) { + try { + return await recoverPremintSigner(params); + } catch (error) { + console.error(error); + return undefined; + } +} + +/** + * Recovers the address from a typed data signature and then checks if the recovered address is authorized to create a premint + * + * @param params validationProperties + * @param params.typedData typed data definition for premint config + * @param params.signature signature to validate + * @param params.publicClient public rpc read-only client + * @param params.premintConfigContractAdmin the original contractAdmin on the ContractCreationConfig for the premint; this is usually the original creator of the premint + * @param params.tokenContract the address of the 1155 contract + * @returns + */ +export async function recoverAndValidateSignature({ + signature, + publicClient, + collection, + chainId, + ...premintConfigAndVersion +}: { + collection: ContractCreationConfig; + signature: Hex; + chainId: number; + publicClient: PublicClient; +} & PremintConfigAndVersion): Promise { + const tokenContract = await getPremintCollectionAddress({ collection, publicClient }); + const recoveredAddress = await tryRecoverPremintSigner({ + ...premintConfigAndVersion, + signature, + verifyingContract: tokenContract, + chainId, + }); + + if (!recoverAddress) { + return { + isAuthorized: false, + }; + } + + const isAuthorized = await isAuthorizedToCreatePremint({ + signer: recoveredAddress!, + collection, + collectionAddress: tokenContract, + publicClient, + signature, + ...premintConfigAndVersion + }); + + return { + isAuthorized, + recoveredAddress, + }; +} + +/** + * Converts a premint config from v1 to v2 + * + * @param premintConfig premint config to convert + * @param createReferral address that referred the creator, that will receive create referral rewards for the created token + */ +export function migratePremintConfigToV2({ + premintConfig, + createReferral = zeroAddress, +}: { + premintConfig: PremintConfigV1; + createReferral: Address; +}): PremintConfigV2 { + return { + ...premintConfig, + tokenConfig: { + tokenURI: premintConfig.tokenConfig.tokenURI, + maxSupply: premintConfig.tokenConfig.maxSupply, + maxTokensPerAddress: premintConfig.tokenConfig.maxTokensPerAddress, + pricePerToken: premintConfig.tokenConfig.pricePerToken, + mintStart: premintConfig.tokenConfig.mintStart, + mintDuration: premintConfig.tokenConfig.mintDuration, + payoutRecipient: premintConfig.tokenConfig.royaltyRecipient, + royaltyBPS: premintConfig.tokenConfig.royaltyBPS, + fixedPriceMinter: premintConfig.tokenConfig.fixedPriceMinter, + createReferral, + }, }; +} - const result: TypedDataDefinition = { +export type CreatorAttributionEventParams = GetEventArgs< + typeof zoraCreator1155ImplABI, + "CreatorAttribution", + { EnableUnion: false } +>; + +/** + * Recovers the address from a CreatorAttribution event emitted from a ZoraCreator1155 contract + * Useful for verifying that the creator of a token is the one who signed a premint for its creation. + * + + * @param creatorAttribution parameters from the CreatorAttribution event + * @param chainId the chain id of the current chain + * @param tokenContract the address of the 1155 contract + * @returns the address of the signer + */ +export const recoverCreatorFromCreatorAttribution = async ({ + creatorAttribution: { version, domainName, structHash, signature }, + chainId, + tokenContract, +}: { + creatorAttribution: CreatorAttributionEventParams; + tokenContract: Address; + chainId: number; +}) => { + // hash the eip712 domain based on the parameters emitted from the event: + const hashedDomain = hashDomain({ domain: { chainId, - name: "Preminter", - version: "1", - verifyingContract: verifyingContract, - }, - types, - message: { - tokenConfig, - uid, + name: domainName, + verifyingContract: tokenContract, version, - deleted, }, - primaryType: "CreatorAttribution", - }; + 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)); + + return await recoverAddress({ + hash: hashedTypedData, + signature: signature!, + }); +}; + +/** + * Checks if the 1155 contract at that address supports the given version of the premint config. + */ +export const supportsPremintVersion = async ( + version: PremintConfigVersion, + tokenContract: Address, + publicClient: PublicClient, +): Promise => { + const supportedPremintSignatureVersions = await publicClient.readContract({ + abi: preminterAbi, + address: getPremintExecutorAddress(), + functionName: "supportedPremintSignatureVersions", + args: [tokenContract], + }); - return result; + return supportedPremintSignatureVersions.includes(version); }; + +export async function getPremintCollectionAddress({ + collection, + publicClient, +}: { + collection: ContractCreationConfig; + publicClient: PublicClient; +}): Promise
{ + return publicClient.readContract({ + address: getPremintExecutorAddress(), + abi: zoraCreator1155PremintExecutorImplABI, + functionName: "getContractAddress", + args: [collection], + }); +}