diff --git a/package/hashTypedData.ts b/package/hashTypedData.ts new file mode 100644 index 000000000..139715fe2 --- /dev/null +++ b/package/hashTypedData.ts @@ -0,0 +1,247 @@ +import { AbiParameter } from "abitype"; +import { + Address, + HashTypedDataParameters, + Hex, + TypedData, + TypedDataDomain, + TypedDataParameter, + concat, + encodeAbiParameters, + keccak256, + toHex, +} from "viem"; + +export type MessageTypeProperty = { + name: string; + type: string; +}; + +export function hashedTypedDataV4< + const TTypedData extends TypedData | { [key: string]: unknown }, + TPrimaryType extends string = string +>({ + structHash, + chainId, + verifyingContract, + _types, + name, + version, +}: { + structHash: `0x${string}`; + verifyingContract: Address; + chainId: number; + _types: HashTypedDataParameters["types"]; + name: string; + version: string; +}) { + const domain: TypedDataDomain = { + chainId, + name, + version, + verifyingContract, + }; + + const types = { + EIP712Domain: getTypesForEIP712Domain({ domain }), + ...(_types as TTypedData), + }; + + const parts: Hex[] = ["0x1901"]; + + // hash the eip 712 domain, verifying contract, and chain id. + parts.push( + hashDomain({ + domain, + types: types as Record, + }) + ); + + // append the already hashed message + parts.push(structHash); + + return keccak256(concat(parts)); +} + +export function hashDomain({ + domain, + types, +}: { + domain: TypedDataDomain; + types: Record; +}) { + return hashStruct({ + data: domain, + primaryType: "EIP712Domain", + types, + }); +} + +function hashStruct({ + data, + primaryType, + types, +}: { + data: Record; + primaryType: string; + types: Record; +}) { + const encoded = encodeData({ + data, + primaryType, + types, + }); + return keccak256(encoded); +} + +function encodeData({ + data, + primaryType, + types, +}: { + data: Record; + primaryType: string; + types: Record; +}) { + const encodedTypes: AbiParameter[] = [{ type: "bytes32" }]; + const encodedValues: unknown[] = [hashType({ primaryType, types })]; + + for (const field of types[primaryType]!) { + const [type, value] = encodeField({ + types, + name: field.name, + type: field.type, + value: data[field.name], + }); + encodedTypes.push(type); + encodedValues.push(value); + } + + return encodeAbiParameters(encodedTypes, encodedValues); +} + +function hashType({ + primaryType, + types, +}: { + primaryType: string; + types: Record; +}) { + const encodedHashType = toHex(encodeType({ primaryType, types })); + return keccak256(encodedHashType); +} + +function encodeType({ + primaryType, + types, +}: { + primaryType: string; + types: Record; +}) { + let result = ""; + const unsortedDeps = findTypeDependencies({ primaryType, types }); + unsortedDeps.delete(primaryType); + + const deps = [primaryType, ...Array.from(unsortedDeps).sort()]; + for (const type of deps) { + result += `${type}(${types[type]!.map( + ({ name, type: t }) => `${t} ${name}` + ).join(",")})`; + } + + return result; +} + +function findTypeDependencies( + { + primaryType: primaryType_, + types, + }: { + primaryType: string; + types: Record; + }, + results: Set = new Set() +): Set { + const match = primaryType_.match(/^\w*/u); + const primaryType = match?.[0]!; + if (results.has(primaryType) || types[primaryType] === undefined) { + return results; + } + + results.add(primaryType); + + for (const field of types[primaryType]!) { + findTypeDependencies({ primaryType: field.type, types }, results); + } + return results; +} + +function encodeField({ + types, + name, + type, + value, +}: { + types: Record; + name: string; + type: string; + value: any; +}): [type: AbiParameter, value: any] { + if (types[type] !== undefined) { + return [ + { type: "bytes32" }, + keccak256(encodeData({ data: value, primaryType: type, types })), + ]; + } + + if (type === "bytes") { + const prepend = value.length % 2 ? "0" : ""; + value = `0x${prepend + value.slice(2)}`; + return [{ type: "bytes32" }, keccak256(value)]; + } + + if (type === "string") return [{ type: "bytes32" }, keccak256(toHex(value))]; + + if (type.lastIndexOf("]") === type.length - 1) { + const parsedType = type.slice(0, type.lastIndexOf("[")); + const typeValuePairs = (value as [AbiParameter, any][]).map((item) => + encodeField({ + name, + type: parsedType, + types, + value: item, + }) + ); + return [ + { type: "bytes32" }, + keccak256( + encodeAbiParameters( + typeValuePairs.map(([t]) => t), + typeValuePairs.map(([, v]) => v) + ) + ), + ]; + } + + return [{ type }, value]; +} + +export function getTypesForEIP712Domain({ + domain, +}: { + domain?: TypedDataDomain; +}): TypedDataParameter[] { + return [ + typeof domain?.name === "string" && { name: "name", type: "string" }, + domain?.version && { name: "version", type: "string" }, + typeof domain?.chainId === "number" && { + name: "chainId", + type: "uint256", + }, + domain?.verifyingContract && { + name: "verifyingContract", + type: "address", + }, + domain?.salt && { name: "salt", type: "bytes32" }, + ].filter(Boolean) as TypedDataParameter[]; +} diff --git a/package/preminter.test.ts b/package/preminter.test.ts index 100e46386..0337953b9 100644 --- a/package/preminter.test.ts +++ b/package/preminter.test.ts @@ -3,6 +3,7 @@ import { http, createWalletClient, createPublicClient, + parseAbiItem, } from "viem"; import { foundry, zoraTestnet } from "viem/chains"; import { describe, it, beforeEach, expect } from "vitest"; @@ -25,6 +26,7 @@ import { PremintConfig, TokenCreationConfig, preminterTypedDataDefinition, + recoverTypedDataSigner, } from "./preminter"; const walletClient = createWalletClient({ @@ -176,7 +178,7 @@ const defaultPremintConfig = (fixedPriceMinter: Address): PremintConfig => ({ version: 0, }); -const useForkContract = true; +const useForkContract = false; describe("ZoraCreator1155Preminter", () => { beforeEach(async (ctx) => { @@ -202,10 +204,10 @@ describe("ZoraCreator1155Preminter", () => { const deployed = await deployPreminterContract(factoryProxyAddress); preminterAddress = deployed.preminterAddress; } else { - const factoryProxyAddress = (await deployFactoryProxy()) - .factoryProxyAddress; + const { factoryProxyAddress, fixedPriceMinterAddress}= (await deployFactoryProxy()) const deployed = await deployPreminterContract(factoryProxyAddress); preminterAddress = deployed.preminterAddress; + ctx.fixedPriceMinterAddress = fixedPriceMinterAddress; } ctx.zoraMintFee = parseEther("0.000777"); @@ -474,4 +476,115 @@ describe("ZoraCreator1155Preminter", () => { // 10 second timeout 40 * 1000 ); + it( + "can recover the creator from the emitted CreatorAttribution event", + 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() * 1000001); + + 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"), + }); + + // watch for the creator attribution event + // to be emitted before invoking the transaction + // this promise will resolve on event emission later + // and when it resolves the test will be considered complete. + const complete = new Promise((resolve) => { + publicClient.watchContractEvent({ + abi: zoraCreator1155ImplABI, + address: contractAddress, + eventName: "CreatorAttribution", + poll: true, + onLogs: async (logs) => { + console.log('got event'); + const eventArgs = logs[0]?.args!; + const creatorFromEvent = eventArgs.creator; + expect(creatorFromEvent).toBe(creatorAccount); + + // now recover the signer from the event params, and ensure it is correct + const recoveredAddress = await recoverTypedDataSigner({ + chainId: anvilChainId, + contractAddress, + args: { + creator: eventArgs.creator!, + structHash: eventArgs.structHash!, + domainName: eventArgs.domainName!, + version: eventArgs.version!, + signature: eventArgs.signature!, + }, + }); + + expect(creatorFromEvent).toBe(recoveredAddress); + + resolve(null); + }, + }); + }); + + // execute the premint, dont wait for the tx, since + // we want to watch for the event right away in the following promise + const hash = await walletClient.writeContract({ + abi: preminterAbi, + functionName: "premint", + account: collectorAccount, + address: preminterAddress, + args: [ + contractConfig, + premintConfig, + signedMessage, + quantityToMint, + comment, + ], + value: valueToSend, + }); + + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + + expect(receipt.status).toBe('success'); + + return complete; + }, + // 10 second timeout + 40 * 1000 + ); }); diff --git a/package/preminter.ts b/package/preminter.ts index 6b6f4e12c..6bc66b45e 100644 --- a/package/preminter.ts +++ b/package/preminter.ts @@ -1,7 +1,22 @@ -import { Address } from "abitype"; +import { Address, ExtractAbiEvent } from "abitype"; import { ExtractAbiFunction, AbiParametersToPrimitiveTypes } from "abitype"; -import { zoraCreator1155PremintExecutorABI as preminterAbi } from "./wagmiGenerated"; -import { TypedDataDefinition } from "viem"; +import { + zoraCreator1155PremintExecutorABI as preminterAbi, + zoraCreator1155FactoryImplABI, + zoraCreator1155ImplABI, +} from "./wagmiGenerated"; +import { + GetEventArgs, + HashTypedDataParameters, + Hex, + TypedData, + TypedDataDefinition, + TypedDataDomain, + keccak256, + recoverPublicKey, + toHex, +} from "viem"; +import { hashedTypedDataV4 } from "./hashTypedData"; type PremintInputs = ExtractAbiFunction< typeof preminterAbi, @@ -14,6 +29,36 @@ export type ContractCreationConfig = PreminterHashDataTypes[0]; export type PremintConfig = PreminterHashDataTypes[1]; export type TokenCreationConfig = PremintConfig["tokenConfig"]; +const name = "Preminter"; +const version = "1"; + +const primaryType = "CreatorAttribution"; + +const signPremintTypes = { + 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" }, + ], +}; + // Convenience method to create the structured typed data // needed to sign for a premint contract and token export const preminterTypedDataDefinition = ({ @@ -26,39 +71,18 @@ export const preminterTypedDataDefinition = ({ 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 = { + const result: TypedDataDefinition< + typeof signPremintTypes, + typeof primaryType + > = { domain: { chainId, name: "Preminter", version: "1", verifyingContract: verifyingContract, }, - types, + types: signPremintTypes, message: { tokenConfig, uid, @@ -68,7 +92,39 @@ export const preminterTypedDataDefinition = ({ primaryType: "CreatorAttribution", }; - // console.log({ result, deleted }); - return result; }; + +export const recoverTypedDataSigner = ({ + args, + contractAddress, + chainId, +}: { + args: GetEventArgs< + typeof zoraCreator1155ImplABI, + "CreatorAttribution", + { + EnableUnion: false; + IndexedOnly: false; + Required: true; + } + >; + contractAddress: Address; + chainId: number; +}) => { + const structHash = args.structHash; + + const hashedTypedData = hashedTypedDataV4({ + structHash, + verifyingContract: contractAddress, + chainId, + name, + version, + _types: signPremintTypes, + }); + + return recoverPublicKey({ + hash: hashedTypedData, + signature: args.signature, + }); +}; diff --git a/src/nft/ZoraCreator1155Impl.sol b/src/nft/ZoraCreator1155Impl.sol index e554f6641..a67035312 100644 --- a/src/nft/ZoraCreator1155Impl.sol +++ b/src/nft/ZoraCreator1155Impl.sol @@ -727,7 +727,7 @@ contract ZoraCreator1155Impl is /* start eip712 functionality */ mapping(uint32 => uint256) public delegatedTokenId; - event CreatorAttribution(bytes32 structHash, bytes32 domainName, bytes32 version, bytes signature); + event CreatorAttribution(bytes32 structHash, bytes32 domainName, bytes32 version, address creator, bytes signature); error PremintAlreadyExecuted(); @@ -740,14 +740,14 @@ contract ZoraCreator1155Impl is bytes32 hashedPremintConfig = ZoraCreator1155Attribution.validateAndHashPremint(premintConfig); - // this is what attributes this token to have been created by the original creator - emit CreatorAttribution(hashedPremintConfig, ZoraCreator1155Attribution.HASHED_NAME, ZoraCreator1155Attribution.HASHED_VERSION, signature); - // recover the signer from the data - address recoveredSigner = ZoraCreator1155Attribution.recoverSignerHashed(hashedPremintConfig, signature, address(this), block.chainid); + address creator = ZoraCreator1155Attribution.recoverSignerHashed(hashedPremintConfig, signature, address(this), block.chainid); + + // this is what attributes this token to have been created by the original creator + emit CreatorAttribution(hashedPremintConfig, ZoraCreator1155Attribution.HASHED_NAME, ZoraCreator1155Attribution.HASHED_VERSION, creator, signature); // require that the signer can create new tokens (is a valid creator) - _requireAdminOrRole(recoveredSigner, CONTRACT_BASE_ID, PERMISSION_BIT_MINTER); + _requireAdminOrRole(creator, CONTRACT_BASE_ID, PERMISSION_BIT_MINTER); // create the new token; msg sender will have PERMISSION_BIT_ADMIN on the new token newTokenId = _setupNewTokenAndPermission(premintConfig.tokenConfig.tokenURI, premintConfig.tokenConfig.maxSupply, msg.sender, PERMISSION_BIT_ADMIN); @@ -755,7 +755,7 @@ contract ZoraCreator1155Impl is delegatedTokenId[premintConfig.uid] = newTokenId; // invoke setup actions for new token, to save contract size, first get them from an external lib - bytes[] memory tokenSetupActions = PremintTokenSetup.makeSetupNewTokenCalls(newTokenId, recoveredSigner, premintConfig.tokenConfig); + bytes[] memory tokenSetupActions = PremintTokenSetup.makeSetupNewTokenCalls(newTokenId, creator, premintConfig.tokenConfig); // then invoke them, calling account should be original msg.sender, which has admin on the new token _multicallInternal(tokenSetupActions); @@ -764,6 +764,6 @@ contract ZoraCreator1155Impl is _removePermission(newTokenId, msg.sender, PERMISSION_BIT_ADMIN); // grant the token creator as admin of the newly created token - _addPermission(newTokenId, recoveredSigner, PERMISSION_BIT_ADMIN); + _addPermission(newTokenId, creator, PERMISSION_BIT_ADMIN); } }