From 1051edb8cc66d17e894f25839d3d002710afd533 Mon Sep 17 00:00:00 2001 From: Dan Oved Date: Tue, 17 Oct 2023 16:52:27 -0700 Subject: [PATCH] Update EIP-7015: allow arbitratry `TYPEHASH` and correponding fields for `structHash` Merged by EIP-Bot. --- EIPS/eip-7015.md | 136 +++--- assets/eip-7015/contracts/DelegatedErc721.sol | 70 +++ assets/eip-7015/contracts/EIP7015.sol | 56 +++ assets/eip-7015/js-test/abi.ts | 419 ++++++++++++++++++ .../eip-7015/js-test/delegatedErc721.test.ts | 186 ++++++++ assets/eip-7015/package.json | 11 + 6 files changed, 803 insertions(+), 75 deletions(-) create mode 100644 assets/eip-7015/contracts/DelegatedErc721.sol create mode 100644 assets/eip-7015/contracts/EIP7015.sol create mode 100644 assets/eip-7015/js-test/abi.ts create mode 100644 assets/eip-7015/js-test/delegatedErc721.test.ts create mode 100644 assets/eip-7015/package.json diff --git a/EIPS/eip-7015.md b/EIPS/eip-7015.md index 950154438fd0c7..3cb425dfb97bcf 100644 --- a/EIPS/eip-7015.md +++ b/EIPS/eip-7015.md @@ -29,22 +29,26 @@ This EIP takes advantage of the fact that contract addresses can be precomputed **Signing Mechanism** -Creator consent is given by signing an [EIP-712](./eip-712.md) compatible message; all signatures compliant with this EIP MUST include all fields defined. The struct signed is: +Creator consent is given by signing an [EIP-712](./eip-712.md) compatible message; all signatures compliant with this EIP MUST include all fields defined. The struct signed can be any arbitrary data that defines how to create the token; it must hashed in an EIP-712 compatible format with a proper EIP-712 domain. + +The following shows some examples of structs that could be encoded into `structHash` (defined below): ```solidity +// example struct that can be encoded in `structHash`; defines that a token can be created with a metadataUri and price: + struct TokenCreation { - bytes32 structHash; + string metadataUri; + uint256 price; + uint256 nonce; } ``` -Where `structHash` represent the hashed data used to deploy the NFT. - **Signature Validation** Creator attribution is given through a signature verification that MUST be verified by the NFT contract being deployed and an event that MUST be emitted by the NFT contract during the deployment transaction. The event includes all the necessary fields for reconstructing the signed digest and validating the signature to ensure it matches the specified creator. The event name is `CreatorAttribution` and includes the following fields: -- `structHash`: hashed information for deploying the NFT contract (e.g. name, symbol, admins etc) -- `domainName`: the domain name of the contract verifying the singature (for EIP-712 signature validation) +- `structHash`: hashed information for deploying the NFT contract (e.g. name, symbol, admins etc). This corresponds to the value `hashStruct` as defined in the [EIP-712 definition of hashStruct](./eip-712.md#definition-of-hashstruct) standard. +- `domainName`: the domain name of the contract verifying the singature (for EIP-712 signature validation). - `version`: the version of the contract verifying the signature (for EIP-712 signature validation) - `creator`: the creator's account - `signature`: the creator’s signature @@ -56,7 +60,7 @@ event CreatorAttribution( bytes32 structHash, string domainName, string version, - address creator, + address creator, bytes signature ); ``` @@ -70,78 +74,60 @@ A platform can verify the validity of the creator attribution by reconstructing #### Example signature validator ```solidity -// SPDX-License-Identifier: CC0-1.0 -pragma solidity 0.8.19; - -import "openzeppelin-contracts/contracts/utils/cryptography/EIP712.sol"; -import "openzeppelin-contracts/contracts/interfaces/IERC1271.sol"; +pragma solidity 0.8.20; +import "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "@openzeppelin/contracts/interfaces/IERC1271.sol"; abstract contract ERC7015 is EIP712 { - error Invalid_Signature(); - - event CreatorAttribution( - bytes32 structHash, - string domainName, - string version, - address creator, - bytes signature - ); - - /// @notice Define magic value to verify smart contract signatures (ERC1271). - bytes4 internal constant MAGIC_VALUE = - bytes4(keccak256("isValidSignature(bytes32,bytes)")); - - bytes32 public constant TYPEHASH = - keccak256( - "CreatorAttribution(bytes32 structHash)" - ); - - constructor() EIP712("ERC7015", "1") {} - - function _validateSignature( - string memory name, - string memory symbol, - bytes32 structHash, - address creator, - bytes memory signature - ) internal { - if (!_isValid(structHash, creator, signature)) - revert Invalid_Signature(); - - emit CreatorAttribution( - structHash, - "ERC7015", - "1", - creator, - signature - ); - } + error Invalid_Signature(); + event CreatorAttribution( + bytes32 structHash, + string domainName, + string version, + address creator, + bytes signature + ); + + /// @notice Define magic value to verify smart contract signatures (ERC1271). + bytes4 internal constant MAGIC_VALUE = + bytes4(keccak256("isValidSignature(bytes32,bytes)")); - function _isValid( - bytes32 structHash, - address signer, - bytes memory signature - ) internal view returns (bool) { - require(signer != address(0), "cannot validate"); - - bytes32 digest = _hashTypedDataV4( - keccak256(abi.encode(TYPEHASH, structHash, token)) - ); - - if (signer.code.length != 0) { - try IERC1271(signer).isValidSignature(digest, signature) returns ( - bytes4 magicValue - ) { - return MAGIC_VALUE == magicValue; - } catch { - return false; - } - } - - address recoveredSigner = ECDSA.recover(digest, signature); - - return recoveredSigner == signer; + function _validateSignature( + bytes32 structHash, + address creator, + bytes memory signature + ) internal { + if (!_isValid(structHash, creator, signature)) revert Invalid_Signature(); + emit CreatorAttribution(structHash, "ERC7015", "1", creator, signature); + } + + function _isValid( + bytes32 structHash, + address signer, + bytes memory signature + ) internal view returns (bool) { + require(signer != address(0), "cannot validate"); + + bytes32 digest = _hashTypedDataV4(structHash); + + // if smart contract is the signer, verify using ERC-1271 smart-contract + /// signature verification method + if (signer.code.length != 0) { + try IERC1271(signer).isValidSignature(digest, signature) returns ( + bytes4 magicValue + ) { + return MAGIC_VALUE == magicValue; + } catch { + return false; + } } + + // otherwise, recover signer and validate that it matches the expected + // signer + address recoveredSigner = ECDSA.recover(digest, signature); + return recoveredSigner == signer; + } } ``` diff --git a/assets/eip-7015/contracts/DelegatedErc721.sol b/assets/eip-7015/contracts/DelegatedErc721.sol new file mode 100644 index 00000000000000..4dbd47308bd960 --- /dev/null +++ b/assets/eip-7015/contracts/DelegatedErc721.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +import "./EIP7015.sol"; + +contract DelegatedErc721 is ERC7015, ERC721, ERC721URIStorage, Ownable { + error AlreadyMinted(); + error NotAuthorized(); + + uint256 private _nextTokenId; + + bytes32 public constant TYPEHASH = + keccak256("CreatorAttribution(string uri,uint256 nonce)"); + + // mapping of signature nonce to if it has been minted + mapping(uint256 => bool) public minted; + + constructor( + address initialOwner + ) EIP712("ERC7015", "1") ERC721("My Token", "TKN") Ownable(initialOwner) {} + + function delegatedSafeMint( + address to, + string memory uri, + uint256 nonce, + address creator, + bytes calldata signature + ) external { + uint256 tokenId = _nextTokenId++; + + if (!isAuthorizedToCreate(creator)) revert NotAuthorized(); + + // validate that the nonce has not been used + if (minted[nonce]) revert AlreadyMinted(); + minted[nonce] = true; + + bytes32 structHash = keccak256( + abi.encode(TYPEHASH, keccak256(bytes(uri)), nonce) + ); + + _validateSignature(structHash, creator, signature); + + _safeMint(to, tokenId); + _setTokenURI(tokenId, uri); + } + + // override required function to define if a signer is authorized to create + function isAuthorizedToCreate(address signer) internal view returns (bool) { + return signer == owner(); + } + + // The following functions are overrides required by Solidity. + function tokenURI( + uint256 tokenId + ) public view override(ERC721, ERC721URIStorage) returns (string memory) { + return super.tokenURI(tokenId); + } + + function supportsInterface( + bytes4 interfaceId + ) public view override(ERC721, ERC721URIStorage) returns (bool) { + return super.supportsInterface(interfaceId); + } +} diff --git a/assets/eip-7015/contracts/EIP7015.sol b/assets/eip-7015/contracts/EIP7015.sol new file mode 100644 index 00000000000000..7e591764a83cb3 --- /dev/null +++ b/assets/eip-7015/contracts/EIP7015.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity 0.8.20; +import "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "@openzeppelin/contracts/interfaces/IERC1271.sol"; + +abstract contract ERC7015 is EIP712 { + error Invalid_Signature(); + event CreatorAttribution( + bytes32 structHash, + string domainName, + string version, + address creator, + bytes signature + ); + + /// @notice Define magic value to verify smart contract signatures (ERC1271). + bytes4 internal constant MAGIC_VALUE = + bytes4(keccak256("isValidSignature(bytes32,bytes)")); + + function _validateSignature( + bytes32 structHash, + address creator, + bytes memory signature + ) internal { + if (!_isValid(structHash, creator, signature)) revert Invalid_Signature(); + emit CreatorAttribution(structHash, "ERC7015", "1", creator, signature); + } + + function _isValid( + bytes32 structHash, + address signer, + bytes memory signature + ) internal view returns (bool) { + require(signer != address(0), "cannot validate"); + + bytes32 digest = _hashTypedDataV4(structHash); + + // if smart contract is the signer, verify using ERC-1271 smart-contract + /// signature verification method + if (signer.code.length != 0) { + try IERC1271(signer).isValidSignature(digest, signature) returns ( + bytes4 magicValue + ) { + return MAGIC_VALUE == magicValue; + } catch { + return false; + } + } + + // otherwise, recover signer and validate that it matches the expected + // signer + address recoveredSigner = ECDSA.recover(digest, signature); + return recoveredSigner == signer; + } +} diff --git a/assets/eip-7015/js-test/abi.ts b/assets/eip-7015/js-test/abi.ts new file mode 100644 index 00000000000000..fb1cdb0d0c2810 --- /dev/null +++ b/assets/eip-7015/js-test/abi.ts @@ -0,0 +1,419 @@ +export const delegatedErc721ABI = [ + { + stateMutability: 'nonpayable', + type: 'constructor', + inputs: [ + { name: 'initialOwner', internalType: 'address', type: 'address' }, + ], + }, + { type: 'error', inputs: [], name: 'AlreadyMinted' }, + { type: 'error', inputs: [], name: 'ECDSAInvalidSignature' }, + { + type: 'error', + inputs: [{ name: 'length', internalType: 'uint256', type: 'uint256' }], + name: 'ECDSAInvalidSignatureLength', + }, + { + type: 'error', + inputs: [{ name: 's', internalType: 'bytes32', type: 'bytes32' }], + name: 'ECDSAInvalidSignatureS', + }, + { + type: 'error', + inputs: [ + { name: 'sender', internalType: 'address', type: 'address' }, + { name: 'tokenId', internalType: 'uint256', type: 'uint256' }, + { name: 'owner', internalType: 'address', type: 'address' }, + ], + name: 'ERC721IncorrectOwner', + }, + { + type: 'error', + inputs: [ + { name: 'operator', internalType: 'address', type: 'address' }, + { name: 'tokenId', internalType: 'uint256', type: 'uint256' }, + ], + name: 'ERC721InsufficientApproval', + }, + { + type: 'error', + inputs: [{ name: 'approver', internalType: 'address', type: 'address' }], + name: 'ERC721InvalidApprover', + }, + { + type: 'error', + inputs: [{ name: 'operator', internalType: 'address', type: 'address' }], + name: 'ERC721InvalidOperator', + }, + { + type: 'error', + inputs: [{ name: 'owner', internalType: 'address', type: 'address' }], + name: 'ERC721InvalidOwner', + }, + { + type: 'error', + inputs: [{ name: 'receiver', internalType: 'address', type: 'address' }], + name: 'ERC721InvalidReceiver', + }, + { + type: 'error', + inputs: [{ name: 'sender', internalType: 'address', type: 'address' }], + name: 'ERC721InvalidSender', + }, + { + type: 'error', + inputs: [{ name: 'tokenId', internalType: 'uint256', type: 'uint256' }], + name: 'ERC721NonexistentToken', + }, + { type: 'error', inputs: [], name: 'InvalidShortString' }, + { type: 'error', inputs: [], name: 'Invalid_Signature' }, + { + type: 'error', + inputs: [{ name: 'owner', internalType: 'address', type: 'address' }], + name: 'OwnableInvalidOwner', + }, + { + type: 'error', + inputs: [{ name: 'account', internalType: 'address', type: 'address' }], + name: 'OwnableUnauthorizedAccount', + }, + { + type: 'error', + inputs: [{ name: 'str', internalType: 'string', type: 'string' }], + name: 'StringTooLong', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'owner', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'approved', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'tokenId', + internalType: 'uint256', + type: 'uint256', + indexed: true, + }, + ], + name: 'Approval', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'owner', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'operator', + internalType: 'address', + type: 'address', + indexed: true, + }, + { name: 'approved', internalType: 'bool', type: 'bool', indexed: false }, + ], + name: 'ApprovalForAll', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: '_fromTokenId', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + { + name: '_toTokenId', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'BatchMetadataUpdate', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'structHash', + internalType: 'bytes32', + type: 'bytes32', + indexed: false, + }, + { + name: 'domainName', + internalType: 'string', + type: 'string', + indexed: false, + }, + { + name: 'version', + internalType: 'string', + type: 'string', + indexed: false, + }, + { + name: 'creator', + internalType: 'address', + type: 'address', + indexed: false, + }, + { + name: 'signature', + internalType: 'bytes', + type: 'bytes', + indexed: false, + }, + ], + name: 'CreatorAttribution', + }, + { type: 'event', anonymous: false, inputs: [], name: 'EIP712DomainChanged' }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: '_tokenId', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'MetadataUpdate', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'previousOwner', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'newOwner', + internalType: 'address', + type: 'address', + indexed: true, + }, + ], + name: 'OwnershipTransferred', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'from', internalType: 'address', type: 'address', indexed: true }, + { name: 'to', internalType: 'address', type: 'address', indexed: true }, + { + name: 'tokenId', + internalType: 'uint256', + type: 'uint256', + indexed: true, + }, + ], + name: 'Transfer', + }, + { + stateMutability: 'view', + type: 'function', + inputs: [], + name: 'TYPEHASH', + outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], + }, + { + stateMutability: 'nonpayable', + type: 'function', + inputs: [ + { name: 'to', internalType: 'address', type: 'address' }, + { name: 'tokenId', internalType: 'uint256', type: 'uint256' }, + ], + name: 'approve', + outputs: [], + }, + { + stateMutability: 'view', + type: 'function', + inputs: [{ name: 'owner', internalType: 'address', type: 'address' }], + name: 'balanceOf', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + }, + { + stateMutability: 'nonpayable', + type: 'function', + inputs: [ + { name: 'to', internalType: 'address', type: 'address' }, + { name: 'uri', internalType: 'string', type: 'string' }, + { name: 'nonce', internalType: 'uint256', type: 'uint256' }, + { name: 'signature', internalType: 'bytes', type: 'bytes' }, + ], + name: 'delegatedSafeMint', + outputs: [], + }, + { + stateMutability: 'view', + type: 'function', + inputs: [], + name: 'eip712Domain', + outputs: [ + { name: 'fields', internalType: 'bytes1', type: 'bytes1' }, + { name: 'name', internalType: 'string', type: 'string' }, + { name: 'version', internalType: 'string', type: 'string' }, + { name: 'chainId', internalType: 'uint256', type: 'uint256' }, + { name: 'verifyingContract', internalType: 'address', type: 'address' }, + { name: 'salt', internalType: 'bytes32', type: 'bytes32' }, + { name: 'extensions', internalType: 'uint256[]', type: 'uint256[]' }, + ], + }, + { + stateMutability: 'view', + type: 'function', + inputs: [{ name: 'tokenId', internalType: 'uint256', type: 'uint256' }], + name: 'getApproved', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + }, + { + stateMutability: 'view', + type: 'function', + inputs: [ + { name: 'owner', internalType: 'address', type: 'address' }, + { name: 'operator', internalType: 'address', type: 'address' }, + ], + name: 'isApprovedForAll', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + }, + { + stateMutability: 'pure', + type: 'function', + inputs: [ + { name: 'uri', internalType: 'string', type: 'string' }, + { name: 'nonce', internalType: 'uint256', type: 'uint256' }, + ], + name: 'makeAttributionStructHash', + outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], + }, + { + stateMutability: 'view', + type: 'function', + inputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + name: 'minted', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + }, + { + stateMutability: 'view', + type: 'function', + inputs: [], + name: 'name', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + }, + { + stateMutability: 'view', + type: 'function', + inputs: [], + name: 'owner', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + }, + { + stateMutability: 'view', + type: 'function', + inputs: [{ name: 'tokenId', internalType: 'uint256', type: 'uint256' }], + name: 'ownerOf', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + }, + { + stateMutability: 'nonpayable', + type: 'function', + inputs: [], + name: 'renounceOwnership', + outputs: [], + }, + { + stateMutability: 'nonpayable', + type: 'function', + inputs: [ + { name: 'from', internalType: 'address', type: 'address' }, + { name: 'to', internalType: 'address', type: 'address' }, + { name: 'tokenId', internalType: 'uint256', type: 'uint256' }, + ], + name: 'safeTransferFrom', + outputs: [], + }, + { + stateMutability: 'nonpayable', + type: 'function', + inputs: [ + { name: 'from', internalType: 'address', type: 'address' }, + { name: 'to', internalType: 'address', type: 'address' }, + { name: 'tokenId', internalType: 'uint256', type: 'uint256' }, + { name: 'data', internalType: 'bytes', type: 'bytes' }, + ], + name: 'safeTransferFrom', + outputs: [], + }, + { + stateMutability: 'nonpayable', + type: 'function', + inputs: [ + { name: 'operator', internalType: 'address', type: 'address' }, + { name: 'approved', internalType: 'bool', type: 'bool' }, + ], + name: 'setApprovalForAll', + outputs: [], + }, + { + stateMutability: 'view', + type: 'function', + inputs: [{ name: 'interfaceId', internalType: 'bytes4', type: 'bytes4' }], + name: 'supportsInterface', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + }, + { + stateMutability: 'view', + type: 'function', + inputs: [], + name: 'symbol', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + }, + { + stateMutability: 'view', + type: 'function', + inputs: [{ name: 'tokenId', internalType: 'uint256', type: 'uint256' }], + name: 'tokenURI', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + }, + { + stateMutability: 'nonpayable', + type: 'function', + inputs: [ + { name: 'from', internalType: 'address', type: 'address' }, + { name: 'to', internalType: 'address', type: 'address' }, + { name: 'tokenId', internalType: 'uint256', type: 'uint256' }, + ], + name: 'transferFrom', + outputs: [], + }, + { + stateMutability: 'nonpayable', + type: 'function', + inputs: [{ name: 'newOwner', internalType: 'address', type: 'address' }], + name: 'transferOwnership', + outputs: [], + }, +] as const \ No newline at end of file diff --git a/assets/eip-7015/js-test/delegatedErc721.test.ts b/assets/eip-7015/js-test/delegatedErc721.test.ts new file mode 100644 index 00000000000000..310ebf77791d5d --- /dev/null +++ b/assets/eip-7015/js-test/delegatedErc721.test.ts @@ -0,0 +1,186 @@ +import { + createTestClient, + http, + createWalletClient, + createPublicClient, + hashDomain, + Hex, + keccak256, + concat, + recoverAddress, + TypedDataDomain, +} from "viem"; +import { foundry } from "viem/chains"; +import { describe, it, beforeEach, expect } from "vitest"; +import { getTypesForEIP712Domain } from "viem"; +import { + delegatedErc721ABI +} from "./abi"; +import { bytecode as delegatedErc721ByteCode } from "../out/DelegatedErc721.sol/DelegatedErc721.json"; + + +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}`; + + +const deployContractAndGetAddress = async ( + args: Parameters[0] +) => { + const hash = await walletClient.deployContract(args); + return ( + await publicClient.waitForTransactionReceipt({ + hash, + }) + ).contractAddress!; +}; + +type TestContext = { + creator: Address, + contractAddress: Address, +} + + +// JSON-RPC Account +const [ + creatorAccount, minter, randomAccount +] = (await walletClient.getAddresses()) as [Address, Address, Address, Address]; + +describe("DelegatedErc721", () => { + beforeEach(async (ctx) => { + const creator = creatorAccount; + + const contractAddress = await deployContractAndGetAddress({ + abi: delegatedErc721ABI, + bytecode: delegatedErc721ByteCode.object as `0x${string}`, + args: [creator], + account: creatorAccount, + }) + + ctx.contractAddress = contractAddress; + ctx.creator = creator; + }, 20 * 1000); + + // skip for now - we need to make this work on zora testnet chain too + it( + "can sign and mint a token and recover the signer from the CreatorAttribution event", + async ({ creator, contractAddress }) => { + + // 1. Have the creator sign a message to create a token + // sign a message for the CreatorAttribution, which has a TYPEHASH of CreatorAttribution(string uri,uint256 nonce) + + const tokenUri = 'ipfs://QmYXJ5Y2FzdC5qcGc'; + const nonce = 1n; + + // eipDomain params + const chainId = await walletClient.getChainId(); + + // have creator sign a message permitting a token to be created on the contract + const signature = await walletClient.signTypedData({ + types: { + CreatorAttribution: [ + { name: "uri", type: "string" }, + { name: "nonce", type: "uint256" }, + ], + }, + primaryType: "CreatorAttribution", + message: { + uri: tokenUri, + nonce, + }, + // signer of the message; the contract requires + // this to match the owner of the contract + account: creator, + domain: { + chainId: await walletClient.getChainId(), + verifyingContract: contractAddress, + // these two need to match the domain name and version in the erc712 contract + name: "ERC7015", + version: "1" + } + }); + + // 2. Have a collector submit the signature and mint the token + + const tx = await walletClient.writeContract({ + abi: delegatedErc721ABI, + address: contractAddress, + account: minter, + functionName: 'delegatedSafeMint', + args: [ + minter, tokenUri, nonce, signature + ] + }); + + const receipt = await publicClient.waitForTransactionReceipt({ + hash: tx + }); + + // check that the transaction succeeded + expect(receipt.status).toBe('success'); + + // 3. Get the CreatorAttribution event from the contract and recover the signer/creator from the emitted signature and params: + + // get the CreatorAttribution event from the erc1155 contract: + const topics = await publicClient.getContractEvents({ + abi: delegatedErc721ABI, + address: contractAddress, + eventName: "CreatorAttribution" + }); + + expect(topics.length).toBe(1); + + const creatorAttributionEventArgs = topics[0]!.args; + + const domain: TypedDataDomain = { + chainId, + name: creatorAttributionEventArgs.domainName!, + verifyingContract: contractAddress, + version: creatorAttributionEventArgs.version! + } + + // hash the eip712 domain based on the parameters emitted from the event: + const hashedDomain = hashDomain({ + domain, + types: { + EIP712Domain: getTypesForEIP712Domain({ domain }) + } + }); + + // 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, creatorAttributionEventArgs.structHash!]; + + const hashedTypedData = keccak256(concat(parts)); + + // recover the signer from the hashed typed data and the signature: + const recoveredSigner = await recoverAddress({ + hash: hashedTypedData, + signature: signature!, + }); + + expect(recoveredSigner).toBe(creator); + + }, + 20 * 1000 + ); + +}); diff --git a/assets/eip-7015/package.json b/assets/eip-7015/package.json new file mode 100644 index 00000000000000..99802b953284a8 --- /dev/null +++ b/assets/eip-7015/package.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "private": true, + "dependencies": { + "@openzeppelin/contracts": "^5.0.0", + "viem": "^1.16.3", + "typescript": "^5.0.4", + "vite": "^4.3.5", + "vitest": "^0.34.6" + } +} \ No newline at end of file