diff --git a/app/models/erc721_ethscriptions_collection_parser.rb b/app/models/erc721_ethscriptions_collection_parser.rb index 6985118..8d879f1 100644 --- a/app/models/erc721_ethscriptions_collection_parser.rb +++ b/app/models/erc721_ethscriptions_collection_parser.rb @@ -9,12 +9,13 @@ class Erc721EthscriptionsCollectionParser # Operation schemas defining exact structure and ABI encoding OPERATION_SCHEMAS = { 'create_collection' => { - keys: %w[name symbol total_supply description logo_image_uri banner_image_uri background_color website_link twitter_link discord_link], - abi_type: '(string,string,uint256,string,string,string,string,string,string,string)', + keys: %w[name symbol max_supply description logo_image_uri banner_image_uri background_color website_link twitter_link discord_link], + # Contract expects an extra bytes32 merkleRoot at the end. We append zero when omitted. + abi_type: '(string,string,uint256,string,string,string,string,string,string,string,bytes32)', validators: { 'name' => :string, 'symbol' => :string, - 'total_supply' => :uint256, + 'max_supply' => :uint256, 'description' => :string, 'logo_image_uri' => :string, 'banner_image_uri' => :string, @@ -24,12 +25,32 @@ class Erc721EthscriptionsCollectionParser 'discord_link' => :string } }, - 'add_items_batch' => { - keys: %w[collection_id items], - abi_type: '(bytes32,(uint256,string,bytes32,string,string,(string,string)[])[])', + # New combined create op name used by the contract; keep legacy alias below + 'create_collection_and_add_self' => { + keys: %w[metadata item], + # ((CollectionParams),(ItemData)) - ItemData without ethscription_id (item refers to itself) + abi_type: '((string,string,uint256,string,string,string,string,string,string,string,bytes32),(uint256,string,string,string,(string,string)[],bytes32[]))', + validators: { + 'metadata' => :collection_metadata, + 'item' => :single_item + } + }, + # Legacy alias retained for backwards compatibility + 'create_and_add_self' => { + keys: %w[metadata item], + abi_type: '((string,string,uint256,string,string,string,string,string,string,string,bytes32),(uint256,string,string,string,(string,string)[],bytes32[]))', + validators: { + 'metadata' => :collection_metadata, + 'item' => :single_item + } + }, + # New single-item add op; keep legacy batch below for compatibility + 'add_self_to_collection' => { + keys: %w[collection_id item], + abi_type: '(bytes32,(uint256,string,string,string,(string,string)[],bytes32[]))', validators: { 'collection_id' => :bytes32, - 'items' => :items_array + 'item' => :single_item } }, 'remove_items' => { @@ -42,7 +63,8 @@ class Erc721EthscriptionsCollectionParser }, 'edit_collection' => { keys: %w[collection_id description logo_image_uri banner_image_uri background_color website_link twitter_link discord_link], - abi_type: '(bytes32,string,string,string,string,string,string,string)', + # Contract includes a bytes32 merkleRoot; append zero when omitted + abi_type: '(bytes32,string,string,string,string,string,string,string,bytes32)', validators: { 'collection_id' => :bytes32, 'description' => :string, @@ -72,30 +94,33 @@ class Erc721EthscriptionsCollectionParser validators: { 'collection_id' => :bytes32 } - }, - 'sync_ownership' => { - keys: %w[collection_id ethscription_ids], - abi_type: '(bytes32,bytes32[])', - validators: { - 'collection_id' => :bytes32, - 'ethscription_ids' => :bytes32_array - } } }.freeze - # Item keys for add_items_batch validation - ITEM_KEYS = %w[item_index name ethscription_id background_color description attributes].freeze + # Item keys for validation (ethscription_id removed - item refers to itself) + # merkle_proof is always required (can be empty array) + ITEM_KEYS = %w[item_index name background_color description attributes merkle_proof].freeze # Attribute keys for NFT metadata ATTRIBUTE_KEYS = %w[trait_type value].freeze class ValidationError < StandardError; end - def self.extract(content_uri) - new.extract(content_uri) + DEFAULT_ITEMS_PATH = ENV['COLLECTIONS_ITEMS_PATH'] || Rails.root.join('items_by_ethscription.json') + DEFAULT_COLLECTIONS_PATH = ENV['COLLECTIONS_META_PATH'] || Rails.root.join('collections_by_name.json') + + def self.extract(content_uri, ethscription_id: nil) + new.extract(content_uri, ethscription_id: ethscription_id) end - def extract(content_uri) + def extract(content_uri, ethscription_id: nil) + # Import-aware path takes precedence if id is provided + if ethscription_id + if (encoded = build_import_encoded_params(ethscription_id.to_hex)) + return encoded + end + end + return DEFAULT_PARAMS unless valid_data_uri?(content_uri) begin @@ -127,7 +152,7 @@ def extract(content_uri) # Remove protocol fields for encoding encoding_data = data.reject { |k, _| k == 'p' || k == 'op' } - + # Validate field types and encode encoded_data = encode_operation(operation, encoding_data, schema) @@ -139,7 +164,180 @@ def extract(content_uri) end end - private + # -------------------- Import fallback -------------------- + + # Returns [protocol, operation, encoded_data] or nil + def build_import_encoded_params(id) + data = self.class.load_import_data( + items_path: DEFAULT_ITEMS_PATH, + collections_path: DEFAULT_COLLECTIONS_PATH + ) + + item = data[:items_by_id][id] + return nil unless item + + coll_name = item['collection_name'] + return nil unless coll_name + + leader_id = data[:leader_by_collection][coll_name] + return nil unless leader_id + + item_index = data[:zero_index_by_id][id] || 0 + + if id == leader_id + metadata = data[:collections_by_name][coll_name] + return nil unless metadata + operation = 'create_collection_and_add_self' + schema = OPERATION_SCHEMAS[operation] + encoding_data = { + 'metadata' => build_metadata_object(metadata), + 'item' => build_item_object(item: item, item_id: id, item_index: item_index) + } + encoded_data = encode_operation(operation, encoding_data, schema) + ['erc-721-ethscriptions-collection'.b, operation.b, encoded_data.b] + else + operation = 'add_self_to_collection' + schema = OPERATION_SCHEMAS[operation] + encoding_data = { + 'collection_id' => to_bytes32_hex(leader_id), + 'item' => build_item_object(item: item, item_id: id, item_index: item_index) + } + encoded_data = encode_operation(operation, encoding_data, schema) + ['erc-721-ethscriptions-collection'.b, operation.b, encoded_data.b] + end + end + + class << self + include Memery + + def load_import_data(items_path:, collections_path:) + items = JSON.parse(File.read(items_path)) + collections = JSON.parse(File.read(collections_path)) + + items_by_id = {} + items.each { |k, v| items_by_id[k.to_s.downcase] = v } + + # Group items by collection and derive leader (min ethscription_number) + groups = Hash.new { |h, k| h[k] = [] } + items_by_id.each do |iid, it| + cname = it['collection_name'] + next unless cname.is_a?(String) && !cname.empty? + num = it['ethscription_number'].to_i + groups[cname] << [iid, num] + end + + leader_by_collection = {} + groups.each do |cname, pairs| + next if pairs.empty? + leader_by_collection[cname] = pairs.min_by { |_id, num| num }[0] + end + + # Normalize item indices to zero-based + zero_index_by_id = {} + groups.each do |_cname, pairs| + explicit = pairs.map { |(iid, _)| [iid, items_by_id[iid]['index']] } + explicit_indices = explicit.filter_map { |_iid, idx| idx if idx.is_a?(Integer) } + if explicit_indices.size == pairs.size + min_idx = explicit_indices.min + offset = (min_idx == 0) ? 0 : 1 + explicit.each { |iid, idx| zero_index_by_id[iid] = [idx - offset, 0].max } + else + pairs.sort_by { |_iid, num| num }.each_with_index { |(iid, _), i| zero_index_by_id[iid] = i } + end + end + + { + items_by_id: items_by_id, + collections_by_name: collections, + leader_by_collection: leader_by_collection, + zero_index_by_id: zero_index_by_id + } + end + memoize :load_import_data + end + + # Build ordered JSON objects to match strict parser expectations + def build_metadata_object(meta) + name = safe_string(meta['name']) + symbol = safe_string(meta['symbol'] || meta['slug'] || meta['name']) + max_supply = safe_uint_string(meta['max_supply'] || meta['total_supply'] || 0) + description = safe_string(meta['description']) + logo_image_uri = safe_string(meta['logo_image_uri']) + banner_image_uri = safe_string(meta['banner_image_uri']) + background_color = safe_string(meta['background_color']) + website_link = safe_string(meta['website_link']) + twitter_link = safe_string(meta['twitter_link']) + discord_link = safe_string(meta['discord_link']) + + OrderedHash[ + 'name', name, + 'symbol', symbol, + 'max_supply', max_supply, + 'description', description, + 'logo_image_uri', logo_image_uri, + 'banner_image_uri', banner_image_uri, + 'background_color', background_color, + 'website_link', website_link, + 'twitter_link', twitter_link, + 'discord_link', discord_link + ] + end + + def build_item_object(item:, item_id:, item_index:) + attrs = Array(item['attributes']).map do |a| + OrderedHash['trait_type', safe_string(a['trait_type']), 'value', safe_string(a['value'])] + end + + OrderedHash[ + 'item_index', safe_uint_string(item_index), + 'name', safe_string(item['name']), + 'background_color', safe_string(item['background_color']), + 'description', safe_string(item['description']), + 'attributes', attrs, + 'merkle_proof', [] + ] + end + + def to_bytes32_hex(val) + h = safe_string(val).downcase + raise ValidationError, "Invalid bytes32 hex: #{val}" unless h.match?(/\A0x[0-9a-f]{64}\z/) + h + end + + # Integer coercion helper for import computations + def safe_uint(val) + case val + when Integer then val + when String then (val =~ /\A\d+\z/ ? val.to_i : 0) + else 0 + end + end + + def safe_uint_string(val) + n = case val + when Integer then val + when String then (val =~ /\A\d+\z/ ? val.to_i : 0) + else 0 + end + n = 0 if n.negative? + n.to_s + end + + def safe_string(val) + val.nil? ? '' : val.to_s + end + + def ordered_json(pairs) + JSON.generate(OrderedHash[pairs.to_a.flatten]) + end + + class OrderedHash < ::Hash + def self.[](*args) + h = new + args.each_slice(2) { |k, v| h[k] = v } + h + end + end def valid_data_uri?(uri) DataUri.valid?(uri) @@ -153,8 +351,10 @@ def encode_operation(operation, data, schema) values = case operation when 'create_collection' build_create_collection_values(validated_data) - when 'add_items_batch' - build_add_items_batch_values(validated_data) + when 'create_collection_and_add_self', 'create_and_add_self' + build_create_and_add_self_values(validated_data) + when 'add_self_to_collection' + build_add_self_to_collection_values(validated_data) when 'remove_items' build_remove_items_values(validated_data) when 'edit_collection' @@ -163,14 +363,38 @@ def encode_operation(operation, data, schema) build_edit_collection_item_values(validated_data) when 'lock_collection' build_lock_collection_values(validated_data) - when 'sync_ownership' - build_sync_ownership_values(validated_data) else raise ValidationError, "Unknown operation: #{operation}" end # Use ABI type from schema for encoding - Eth::Abi.encode([schema[:abi_type]], [values]) + begin + Eth::Abi.encode([schema[:abi_type]], [values]) + rescue Encoding::CompatibilityError => e + Rails.logger.error "=== Collection ABI Encoding Error ===" + Rails.logger.error "Error: #{e.message}" + Rails.logger.error "operation: #{operation}" + Rails.logger.error "schema abi_type: #{schema[:abi_type]}" + Rails.logger.error "values inspection:" + log_encoding_details(values) + raise + end + end + + def log_encoding_details(obj, indent = 0) + prefix = " " * indent + case obj + when Array + Rails.logger.error "#{prefix}Array[#{obj.size}]:" + obj.each_with_index do |item, idx| + Rails.logger.error "#{prefix} [#{idx}]:" + log_encoding_details(item, indent + 2) + end + when String + Rails.logger.error "#{prefix}String: #{obj.inspect[0..100]}, encoding: #{obj.encoding.name}, bytesize: #{obj.bytesize}" + else + Rails.logger.error "#{prefix}#{obj.class}: #{obj.inspect[0..100]}" + end end def validate_fields(data, validators) @@ -196,7 +420,7 @@ def validate_string(value, field_name) unless value.is_a?(String) raise ValidationError, "Field #{field_name} must be a string, got #{value.class.name}" end - value + value.b end def validate_uint256(value, field_name) @@ -243,24 +467,57 @@ def validate_items_array(value, field_name) end end + def validate_single_item(value, field_name) + unless value.is_a?(Hash) + raise ValidationError, "Expected object for #{field_name}" + end + validate_item(value) + end + + def validate_collection_metadata(value, field_name) + unless value.is_a?(Hash) + raise ValidationError, "Expected object for #{field_name}" + end + # Expected keys for metadata (merkle_root optional) + expected_min_keys = %w[name symbol max_supply description logo_image_uri banner_image_uri background_color website_link twitter_link discord_link] + unless value.keys == expected_min_keys || value.keys == (expected_min_keys + ['merkle_root']) + raise ValidationError, "Invalid metadata keys or order" + end + + { + name: validate_string(value['name'], 'name'), + symbol: validate_string(value['symbol'], 'symbol'), + maxSupply: validate_uint256(value['max_supply'], 'max_supply'), + description: validate_string(value['description'], 'description'), + logoImageUri: validate_string(value['logo_image_uri'], 'logo_image_uri'), + bannerImageUri: validate_string(value['banner_image_uri'], 'banner_image_uri'), + backgroundColor: validate_string(value['background_color'], 'background_color'), + websiteLink: validate_string(value['website_link'], 'website_link'), + twitterLink: validate_string(value['twitter_link'], 'twitter_link'), + discordLink: validate_string(value['discord_link'], 'discord_link'), + merkleRoot: value.key?('merkle_root') ? validate_bytes32(value['merkle_root'], 'merkle_root') : nil + } + end + def validate_item(item) unless item.is_a?(Hash) raise ValidationError, "Item must be an object" end - # Check exact key order + # Check exact key order - merkle_proof is always required unless item.keys == ITEM_KEYS - raise ValidationError, "Invalid item keys or order. Expected: #{ITEM_KEYS.join(',')}, got: #{item.keys.join(',')}" + expected = "[#{ITEM_KEYS.join(', ')}]" + raise ValidationError, "Invalid item keys or order. Expected: #{expected}, got: [#{item.keys.join(', ')}]" end # Validate each field - return in internal format for encoding { itemIndex: validate_uint256(item['item_index'], 'item_index'), name: validate_string(item['name'], 'name'), - ethscriptionId: validate_bytes32(item['ethscription_id'], 'ethscription_id'), backgroundColor: validate_string(item['background_color'], 'background_color'), description: validate_string(item['description'], 'description'), - attributes: validate_attributes_array(item['attributes'], 'attributes') + attributes: validate_attributes_array(item['attributes'], 'attributes'), + merkleProof: validate_bytes32_array(item['merkle_proof'], 'merkle_proof') } end @@ -297,27 +554,74 @@ def build_create_collection_values(data) [ data['name'], data['symbol'], - data['total_supply'], + data['max_supply'], data['description'], data['logo_image_uri'], data['banner_image_uri'], data['background_color'], data['website_link'], data['twitter_link'], - data['discord_link'] + data['discord_link'], + # Append zero merkle root to satisfy contract struct shape + ["".ljust(64, '0')].pack('H*') ] end + def build_create_and_add_self_values(data) + meta = data['metadata'] + item = data['item'] + + # Metadata tuple with optional merkleRoot + merkle_root = meta[:merkleRoot] || ["".ljust(64, '0')].pack('H*') + metadata_tuple = [ + meta[:name], + meta[:symbol], + meta[:maxSupply], + meta[:description], + meta[:logoImageUri], + meta[:bannerImageUri], + meta[:backgroundColor], + meta[:websiteLink], + meta[:twitterLink], + meta[:discordLink], + merkle_root + ] + + item_tuple = [ + item[:itemIndex], + item[:name], + item[:backgroundColor], + item[:description], + item[:attributes], + item[:merkleProof] + ] + + [metadata_tuple, item_tuple] + end + + def build_add_self_to_collection_values(data) + item = data['item'] + item_tuple = [ + item[:itemIndex], + item[:name], + item[:backgroundColor], + item[:description], + item[:attributes], + item[:merkleProof] + ] + [data['collection_id'], item_tuple] + end + def build_add_items_batch_values(data) # Transform items to array format for encoding items_array = data['items'].map do |item| [ item[:itemIndex], item[:name], - item[:ethscriptionId], item[:backgroundColor], item[:description], - item[:attributes] + item[:attributes], + item[:merkleProof] ] end @@ -329,7 +633,7 @@ def build_remove_items_values(data) end def build_edit_collection_values(data) - [ + values = [ data['collection_id'], data['description'], data['logo_image_uri'], @@ -339,6 +643,10 @@ def build_edit_collection_values(data) data['twitter_link'], data['discord_link'] ] + + # Append zero merkle root if not provided in payload (parser schema omits it) + values << ["".ljust(64, '0')].pack('H*') + values end def build_edit_collection_item_values(data) @@ -356,8 +664,4 @@ def build_lock_collection_values(data) # Single bytes32, not a tuple - but we need to return just the value data['collection_id'] end - - def build_sync_ownership_values(data) - [data['collection_id'], data['ethscription_ids']] - end end diff --git a/app/models/ethscription_transaction.rb b/app/models/ethscription_transaction.rb index 4c85109..44a3fbd 100644 --- a/app/models/ethscription_transaction.rb +++ b/app/models/ethscription_transaction.rb @@ -180,8 +180,12 @@ def build_create_calldata esip6 = DataUri.esip6?(content_uri) || false # Extract protocol params - returns [protocol, operation, encoded_data] - protocol, operation, encoded_data = ProtocolExtractor.for_calldata(content_uri) - + # Pass the ethscription_id context so parsers can inject it when needed + protocol, operation, encoded_data = ProtocolExtractor.for_calldata( + content_uri, + ethscription_id: eth_transaction.transaction_hash + ) + # Hash the content for protocol uniqueness content_uri_hash_hex = Digest::SHA256.hexdigest(content_uri) content_uri_hash = [content_uri_hash_hex].pack('H*') @@ -208,10 +212,22 @@ def build_create_calldata protocol_params # ProtocolParams tuple ] - encoded = Eth::Abi.encode( - ['(bytes32,bytes32,address,bytes,string,bool,(string,string,bytes))'], - [params] - ) + begin + encoded = Eth::Abi.encode( + ['(bytes32,bytes32,address,bytes,string,bool,(string,string,bytes))'], + [params] + ) + rescue Encoding::CompatibilityError => e + Rails.logger.error "=== ABI Encoding Error (build_create_calldata) ===" + Rails.logger.error "Error: #{e.message}" + Rails.logger.error "content_uri: #{content_uri[0..100]}" + Rails.logger.error "protocol: #{protocol.inspect[0..100]}, encoding: #{protocol.encoding.name}" + Rails.logger.error "operation: #{operation.inspect[0..100]}, encoding: #{operation.encoding.name}" + Rails.logger.error "encoded_data: #{encoded_data.inspect[0..100]}, encoding: #{encoded_data.encoding.name}, bytesize: #{encoded_data.bytesize}" + Rails.logger.error "mimetype: #{mimetype.inspect}, encoding: #{mimetype.encoding.name}" + Rails.logger.error "raw_content encoding: #{raw_content.encoding.name}, bytesize: #{raw_content.bytesize}" + raise + end # Ensure binary encoding (function_sig + encoded).b diff --git a/app/models/protocol_extractor.rb b/app/models/protocol_extractor.rb index b2566ae..3a4b5ef 100644 --- a/app/models/protocol_extractor.rb +++ b/app/models/protocol_extractor.rb @@ -5,21 +5,17 @@ class ProtocolExtractor COLLECTIONS_DEFAULT_PARAMS = Erc721EthscriptionsCollectionParser::DEFAULT_PARAMS GENERIC_DEFAULT_PARAMS = GenericProtocolExtractor::DEFAULT_PARAMS - def self.extract(content_uri) - return nil unless content_uri.is_a?(String) - - # Centralized validation: must be a valid data URI and JSON payload - return nil unless DataUri.valid?(content_uri) - begin - payload = DataUri.new(content_uri).decoded_data - rescue StandardError - return nil - end - return nil unless payload.start_with?('{') + def self.extract(content_uri, ethscription_id: nil) + # begin + # payload = DataUri.new(content_uri).decoded_data + # rescue StandardError + # return nil + # end + # return nil unless payload.start_with?('{') # Try extractors in order of strictness # 1. Token protocol (most strict - exact character position matters) - # 2. Collections protocol (strict - exact key order required) - gated by ENABLE_COLLECTIONS + # 2. Collections protocol (strict - exact key order required) # 3. Generic protocol (flexible - for all other protocols) - gated by ENABLE_GENERIC_PROTOCOLS # Try token extractor first (most strict) @@ -27,10 +23,8 @@ def self.extract(content_uri) return result if result # Try collections extractor next (if enabled) - if ENV['ENABLE_COLLECTIONS'] == 'true' - result = try_collections_extractor(content_uri) - return result if result - end + result = try_collections_extractor(content_uri, ethscription_id: ethscription_id) + return result if result # Try generic extractor last (if enabled) if ENV['ENABLE_GENERIC_PROTOCOLS'] == 'true' @@ -63,9 +57,12 @@ def self.try_token_extractor(content_uri) end end - def self.try_collections_extractor(content_uri) + def self.try_collections_extractor(content_uri, ethscription_id: nil) # Erc721EthscriptionsCollectionParser returns [''.b, ''.b, ''.b] if no match - protocol, operation, encoded_data = Erc721EthscriptionsCollectionParser.extract(content_uri) + protocol, operation, encoded_data = Erc721EthscriptionsCollectionParser.extract( + content_uri, + ethscription_id: ethscription_id + ) # Check if extraction succeeded if protocol != ''.b && operation != ''.b @@ -129,8 +126,8 @@ def self.encode_token_params(params) # Get protocol data formatted for L2 calldata # Returns [protocol, operation, encoded_data] for contract consumption - def self.for_calldata(content_uri) - result = extract(content_uri) + def self.for_calldata(content_uri, ethscription_id: nil) + result = extract(content_uri, ethscription_id: ethscription_id) if result.nil? # No protocol detected - return empty protocol params diff --git a/config/derive_ethscriptions_blocks.rb b/config/derive_ethscriptions_blocks.rb index e9e17b9..8ecb281 100644 --- a/config/derive_ethscriptions_blocks.rb +++ b/config/derive_ethscriptions_blocks.rb @@ -176,7 +176,7 @@ module Clockwork # Don't sleep here - the loop's sleep at line 192 will handle it rescue => e Rails.logger.error "Import error: #{e.class} - #{e.message}" - Rails.logger.error e.backtrace.first(20).join("\n") + Rails.logger.error e.backtrace.join("\n") puts "[#{Time.now}] ❌ Error: #{e.message}" diff --git a/contracts/script/L2Genesis.s.sol b/contracts/script/L2Genesis.s.sol index 4761090..5162c07 100644 --- a/contracts/script/L2Genesis.s.sol +++ b/contracts/script/L2Genesis.s.sol @@ -230,18 +230,8 @@ contract L2Genesis is Script { ethscriptions.registerProtocol("erc-20-fixed-denomination", Predeploys.ERC20_FIXED_DENOMINATION_MANAGER); console.log("Registered erc-20-fixed-denomination protocol handler:", Predeploys.ERC20_FIXED_DENOMINATION_MANAGER); - // Check environment variable for collections - // Default to true so forge tests work (they don't go through genesis_generator.rb) - // Production explicitly sets ENABLE_COLLECTIONS=false - bool enableCollections = vm.envOr("ENABLE_COLLECTIONS", true); - - if (enableCollections) { - // Register the collections protocol handler for ERC-721 Ethscriptions collections - ethscriptions.registerProtocol("erc-721-ethscriptions-collection", Predeploys.ERC721_ETHSCRIPTIONS_COLLECTION_MANAGER); - console.log("Registered erc-721-ethscriptions-collection protocol handler:", Predeploys.ERC721_ETHSCRIPTIONS_COLLECTION_MANAGER); - } else { - console.log("Collections protocol not registered (ENABLE_COLLECTIONS=false)"); - } + ethscriptions.registerProtocol("erc-721-ethscriptions-collection", Predeploys.ERC721_ETHSCRIPTIONS_COLLECTION_MANAGER); + console.log("Registered erc-721-ethscriptions-collection protocol handler:", Predeploys.ERC721_ETHSCRIPTIONS_COLLECTION_MANAGER); } /// @notice Deploy L1Block contract (stores L1 block attributes) diff --git a/contracts/src/ERC721EthscriptionsCollection.sol b/contracts/src/ERC721EthscriptionsCollection.sol index 501f27a..a738f6b 100644 --- a/contracts/src/ERC721EthscriptionsCollection.sol +++ b/contracts/src/ERC721EthscriptionsCollection.sol @@ -1,188 +1,106 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.24; -// import "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; import "./ERC721EthscriptionsEnumerableUpgradeable.sol"; -// import "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol"; -// import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import "./Ethscriptions.sol"; +import "./libraries/Predeploys.sol"; import {LibString} from "solady/utils/LibString.sol"; import {Base64} from "solady/utils/Base64.sol"; import "./ERC721EthscriptionsCollectionManager.sol"; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; -import {IERC721Metadata} from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; /// @title ERC721EthscriptionsCollection -/// @notice ERC-721 contract for an Ethscription collection -/// @dev Maintains internal state but overrides ownerOf to delegate to Ethscriptions contract -contract ERC721EthscriptionsCollection is ERC721EthscriptionsEnumerableUpgradeable { +/// @notice Thin ERC-721 wrapper for Ethscription collections where the manager controls mint/burn +contract ERC721EthscriptionsCollection is ERC721EthscriptionsEnumerableUpgradeable, OwnableUpgradeable { using LibString for *; - /// @notice The main Ethscriptions contract Ethscriptions public constant ethscriptions = Ethscriptions(Predeploys.ETHSCRIPTIONS); - /// @notice The collection factory that created this contract + /// @notice Factory (manager) that deployed this contract address public factory; - /// @notice The collection ID (Ethscription hash) - bytes32 public collectionId; - - bool public locked; - // Events event MemberAdded(bytes32 indexed ethscriptionId, uint256 indexed tokenId); event MemberRemoved(bytes32 indexed ethscriptionId, uint256 indexed tokenId); - event CollectionLocked(); // Errors error NotFactory(); - error CollectionIsLocked(); + error UnknownCollection(); error TransferNotAllowed(); - error AlreadyMember(); - error NotMember(); modifier onlyFactory() { if (msg.sender != factory) revert NotFactory(); _; } - modifier notLocked() { - if (locked) revert CollectionIsLocked(); - _; - } - - /// @notice Initialize the collection (called once after cloning) function initialize( - string memory _name, - string memory _symbol, - bytes32 _collectionId + string memory name_, + string memory symbol_, + address initialOwner_ ) external initializer { - // Initialize parent contracts - __ERC721_init(_name, _symbol); - - // Set collection-specific values - collectionId = _collectionId; + __ERC721_init(name_, symbol_); + __Ownable_init(initialOwner_); factory = msg.sender; } - /// @notice Add a member to the collection with specific token ID - /// @param ethscriptionId The Ethscription to add - /// @param tokenId The token ID to mint (same as item index) - function addMember(bytes32 ethscriptionId, uint256 tokenId) external onlyFactory notLocked { - // Get current owner from Ethscriptions contract - address owner = ethscriptions.ownerOf(ethscriptionId); + /// @notice Lookup collection id via the factory registry + function collectionId() public view returns (bytes32) { + ERC721EthscriptionsCollectionManager manager = ERC721EthscriptionsCollectionManager(factory); + bytes32 id = manager.collectionIdForAddress(address(this)); + if (id == bytes32(0)) revert UnknownCollection(); + return id; + } - // Mint using the specified token ID + function addMember(bytes32 ethscriptionId, uint256 tokenId) external onlyFactory { + address owner = ethscriptions.ownerOf(ethscriptionId); _mint(owner, tokenId); - emit MemberAdded(ethscriptionId, tokenId); } - /// @notice Remove a member from the collection - /// @param ethscriptionId The Ethscription to remove - /// @param tokenId The token ID to remove - function removeMember(bytes32 ethscriptionId, uint256 tokenId) external onlyFactory notLocked { - // Get current owner before removal (for the Transfer event) - address currentOwner = _ownerOf(tokenId); - - // Mark token as non-existent in the base contract + function removeMember(bytes32 ethscriptionId, uint256 tokenId) external onlyFactory { + require(_tokenExists(tokenId), "Token does not exist"); + address owner = ownerOf(tokenId); + // Mark token as non-existent (handles enumeration cleanup) _setTokenExists(tokenId, false); - // Emit Transfer to address(0) for indexers to track removal - emit Transfer(currentOwner, address(0), tokenId); + // Emit burn-style transfer for indexers + emit Transfer(owner, address(0), tokenId); + emit MemberRemoved(ethscriptionId, tokenId); } - /// @notice Sync ownership for a specific token - /// @param tokenId The token to sync - /// @param ethscriptionId The ethscription ID for this token - function syncOwnership(uint256 tokenId, bytes32 ethscriptionId) external onlyFactory { - // Check if token still exists in collection - require(_tokenExists(tokenId), "Token does not exist"); - - // Get actual owner from Ethscriptions contract - address actualOwner = ethscriptions.ownerOf(ethscriptionId); - - // Get recorded owner from our state - address recordedOwner = _ownerOf(tokenId); - - // If they differ, update our state - if (actualOwner != recordedOwner) { - // Use internal _transfer to update state and emit event - _transfer(recordedOwner, actualOwner, tokenId); - } + /// @notice Called by the manager to mirror Ethscription transfers + function forceTransfer(address from, address to, uint256 tokenId) external onlyFactory { + require(_ownerOf(tokenId) == from, "Unexpected owner"); + _transfer(from, to, tokenId); } - - /// @notice Lock the collection (freeze it) - function lockCollection() external onlyFactory { - locked = true; - emit CollectionLocked(); - } - - // Override ownerOf to delegate to Ethscriptions contract - function ownerOf(uint256 tokenId) - public - view - override(ERC721EthscriptionsUpgradeable, IERC721) - returns (address) - { - // Check if token exists in collection - if (!_tokenExists(tokenId)) { - revert("Token does not exist"); - } - // Get ethscription ID from manager - ERC721EthscriptionsCollectionManager manager = ERC721EthscriptionsCollectionManager(factory); - ERC721EthscriptionsCollectionManager.CollectionItem memory item = manager.getCollectionItem(collectionId, tokenId); - - if (item.ethscriptionId == bytes32(0)) { - revert("Token not in collection"); - } - - // Always return the actual owner from Ethscriptions contract - return ethscriptions.ownerOf(item.ethscriptionId); + /// @notice Let the manager update Ownable owner to match inscription holder + function factoryTransferOwnership(address newOwner) external onlyFactory { + _transferOwnership(newOwner); } - // Override tokenURI to generate full metadata JSON function tokenURI(uint256 tokenId) public view override(ERC721EthscriptionsUpgradeable) returns (string memory) { - if (!_tokenExists(tokenId)) { - revert("Token does not exist"); - } + if (!_tokenExists(tokenId)) revert("Token does not exist"); - // Get collection metadata and item data from ERC721EthscriptionsCollectionManager ERC721EthscriptionsCollectionManager manager = ERC721EthscriptionsCollectionManager(factory); - ERC721EthscriptionsCollectionManager.CollectionItem memory item = manager.getCollectionItem(collectionId, tokenId); + ERC721EthscriptionsCollectionManager.CollectionItem memory item = + manager.getCollectionItem(collectionId(), tokenId); + if (item.ethscriptionId == bytes32(0)) revert("Token not in collection"); - if (item.ethscriptionId == bytes32(0)) { - revert("Token not in collection"); - } - - // Get media URI from Ethscriptions contract (string memory mediaType, string memory mediaUri) = ethscriptions.getMediaUri(item.ethscriptionId); - // Build JSON components - string memory jsonStart = string.concat( - '{"name":"', - item.name.escapeJSON(), - '"' - ); - - // Add description if present + string memory jsonStart = string.concat('{"name":"', item.name.escapeJSON(), '"'); if (bytes(item.description).length > 0) { - jsonStart = string.concat( - jsonStart, - ',"description":"', - item.description.escapeJSON(), - '"' - ); + jsonStart = string.concat(jsonStart, ',"description":"', item.description.escapeJSON(), '"'); } - // Add media field (image or animation_url) string memory mediaField = string.concat( ',"', mediaType, @@ -191,19 +109,13 @@ contract ERC721EthscriptionsCollection is ERC721EthscriptionsEnumerableUpgradeab '"' ); - // Add background color if present string memory bgColor = ""; if (bytes(item.backgroundColor).length > 0) { - bgColor = string.concat( - ',"background_color":"', - item.backgroundColor.escapeJSON(), - '"' - ); + bgColor = string.concat(',"background_color":"', item.backgroundColor.escapeJSON(), '"'); } - // Build attributes array from Attribute structs string memory attributesJson = ',"attributes":['; - for (uint i = 0; i < item.attributes.length; i++) { + for (uint256 i = 0; i < item.attributes.length; i++) { if (i > 0) attributesJson = string.concat(attributesJson, ','); attributesJson = string.concat( attributesJson, @@ -216,23 +128,13 @@ contract ERC721EthscriptionsCollection is ERC721EthscriptionsEnumerableUpgradeab } attributesJson = string.concat(attributesJson, ']'); - // Combine all parts - string memory json = string.concat( - jsonStart, - mediaField, - bgColor, - attributesJson, - '}' - ); + string memory json = string.concat(jsonStart, mediaField, bgColor, attributesJson, '}'); - // Return as base64-encoded data URI - return string.concat( - "data:application/json;base64,", - Base64.encode(bytes(json)) - ); + return string.concat("data:application/json;base64,", Base64.encode(bytes(json))); } - // Block external transfers - only internal _transfer is allowed for syncing + // --- Transfer/approvals blocked externally --------------------------------- + function transferFrom(address, address, uint256) public pure @@ -249,7 +151,6 @@ contract ERC721EthscriptionsCollection is ERC721EthscriptionsEnumerableUpgradeab revert TransferNotAllowed(); } - // Block approvals - not needed for non-transferable tokens function approve(address, uint256) public pure diff --git a/contracts/src/ERC721EthscriptionsCollectionManager.sol b/contracts/src/ERC721EthscriptionsCollectionManager.sol index 64db9c0..1cf9621 100644 --- a/contracts/src/ERC721EthscriptionsCollectionManager.sol +++ b/contracts/src/ERC721EthscriptionsCollectionManager.sol @@ -2,7 +2,7 @@ pragma solidity 0.8.24; import "@openzeppelin/contracts/utils/Create2.sol"; -import {LibString} from "solady/utils/LibString.sol"; +import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; import "./ERC721EthscriptionsCollection.sol"; import "./libraries/Proxy.sol"; import "./Ethscriptions.sol"; @@ -10,19 +10,15 @@ import "./libraries/Predeploys.sol"; import "./interfaces/IProtocolHandler.sol"; contract ERC721EthscriptionsCollectionManager is IProtocolHandler { - using LibString for string; - - // Standard NFT attribute structure struct Attribute { string traitType; string value; } - // Core collection metadata fields (reused across create/edit/storage) - struct CollectionMetadata { + struct CollectionParams { string name; string symbol; - uint256 totalSupply; + uint256 maxSupply; string description; string logoImageUri; string bannerImageUri; @@ -30,14 +26,23 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler { string websiteLink; string twitterLink; string discordLink; + bytes32 merkleRoot; } - // Runtime state for a collection (separate from metadata) - struct CollectionState { + struct CollectionRecord { address collectionContract; - bytes32 createEthscriptionId; - uint256 currentSize; bool locked; + string name; + string symbol; + uint256 maxSupply; + string description; + string logoImageUri; + string bannerImageUri; + string backgroundColor; + string websiteLink; + string twitterLink; + string discordLink; + bytes32 merkleRoot; } struct CollectionItem { @@ -46,21 +51,21 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler { bytes32 ethscriptionId; string backgroundColor; string description; - Attribute[] attributes; // Standard NFT attribute format - } - - struct AddItemsBatchOperation { - bytes32 collectionId; - ItemData[] items; + Attribute[] attributes; } struct ItemData { - uint256 itemIndex; // Proper uint256, no parsing needed + uint256 itemIndex; string name; - bytes32 ethscriptionId; string backgroundColor; string description; - Attribute[] attributes; // Standard NFT attribute format + Attribute[] attributes; + bytes32[] merkleProof; + } + + struct Membership { + bytes32 collectionId; + uint256 tokenIdPlusOne; // 0 means not a member } struct RemoveItemsOperation { @@ -70,7 +75,6 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler { struct EditCollectionOperation { bytes32 collectionId; - // Flattened metadata fields (totalSupply excluded as it's not editable) string description; string logoImageUri; string bannerImageUri; @@ -78,32 +82,37 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler { string websiteLink; string twitterLink; string discordLink; + bytes32 merkleRoot; } struct EditCollectionItemOperation { bytes32 collectionId; - uint256 itemIndex; // Index of the item to edit + uint256 itemIndex; string name; string backgroundColor; string description; - Attribute[] attributes; // If non-empty, replaces all attributes. If empty, keeps existing. - // Note: Similar to ItemData but without ethscriptionId (can't change) + Attribute[] attributes; + } + + struct CreateAndAddSelfParams { + CollectionParams metadata; + ItemData item; + } + + struct AddSelfToCollectionParams { + bytes32 collectionId; + ItemData item; } address public constant collectionsImplementation = Predeploys.ERC721_ETHSCRIPTIONS_COLLECTION_IMPLEMENTATION; - address public constant ethscriptions = Predeploys.ETHSCRIPTIONS; + Ethscriptions public constant ethscriptions = Ethscriptions(Predeploys.ETHSCRIPTIONS); string public constant protocolName = "erc-721-ethscriptions-collection"; - - // Track deployed collections by ID - mapping(bytes32 => CollectionState) public collectionState; // Runtime state (contract address, size, locked) - // Metadata storage - mapping(bytes32 => CollectionMetadata) public collectionMetadata; // Descriptive metadata + mapping(bytes32 => CollectionRecord) private collectionStore; mapping(bytes32 => mapping(uint256 => CollectionItem)) public collectionItems; - // Maps ethscription to index+1 (so 0 means not in collection, 1 means index 0, etc) - mapping(bytes32 => mapping(bytes32 => uint256)) public ethscriptionToIndexPlusOne; + mapping(bytes32 => Membership) public membershipOfEthscription; + mapping(address => bytes32) private collectionAddressToId; - // Array of all collection IDs for enumeration bytes32[] public collectionIds; event CollectionCreated( @@ -111,234 +120,108 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler { address indexed collectionContract, string name, string symbol, - uint256 maxSize - ); - - event ItemsAdded( - bytes32 indexed collectionId, - uint256 count, - bytes32 updateTxHash - ); - - event ItemsRemoved( - bytes32 indexed collectionId, - uint256 count, - bytes32 updateTxHash + uint256 maxSupply ); + event ItemsAdded(bytes32 indexed collectionId, uint256 count, bytes32 updateTxHash); + event ItemsRemoved(bytes32 indexed collectionId, uint256 count, bytes32 updateTxHash); event CollectionEdited(bytes32 indexed collectionId); event CollectionLocked(bytes32 indexed collectionId); modifier onlyEthscriptions() { - require(msg.sender == ethscriptions, "Only Ethscriptions contract"); + require(msg.sender == address(ethscriptions), "Only Ethscriptions contract"); _; } - /// @notice Handle create_collection operation - function op_create_collection(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions { - // Decode the operation data directly into CollectionMetadata - CollectionMetadata memory metadata = abi.decode(data, (CollectionMetadata)); - - // Use the ethscription hash as the collection ID - bytes32 collectionId = ethscriptionId; - - // Check if collection already exists - require(collectionState[collectionId].collectionContract == address(0), "Collection already exists"); - - // Get totalSupply from metadata - uint256 totalSupply = metadata.totalSupply; - - // Deploy ERC721 proxy with CREATE2 using collectionId as salt for deterministic address - Proxy collectionProxy = new Proxy{salt: collectionId}(address(this)); - - // Initialize implementation via proxy - bytes memory initCalldata = abi.encodeWithSelector( - ERC721EthscriptionsCollection.initialize.selector, - metadata.name, - metadata.symbol, - collectionId - ); - collectionProxy.upgradeToAndCall(collectionsImplementation, initCalldata); - // Hand over admin to global ProxyAdmin - collectionProxy.changeAdmin(Predeploys.PROXY_ADMIN); - - // Store collection state - collectionState[collectionId] = CollectionState({ - collectionContract: address(collectionProxy), - createEthscriptionId: ethscriptionId, - currentSize: 0, - locked: false - }); - - // Store metadata (already decoded) - collectionMetadata[collectionId] = metadata; - - collectionIds.push(collectionId); - - emit CollectionCreated(collectionId, address(collectionProxy), metadata.name, metadata.symbol, totalSupply); + function collectionExists(bytes32 collectionId) public view returns (bool) { + return collectionStore[collectionId].collectionContract != address(0); } - /// @notice Handle add_items_batch operation with full metadata - function op_add_items_batch(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions { - // Get who is trying to add items - Ethscriptions ethscriptionsContract = Ethscriptions(ethscriptions); - Ethscriptions.Ethscription memory ethscription = ethscriptionsContract.getEthscription(ethscriptionId); - address sender = ethscription.creator; - - AddItemsBatchOperation memory addOp = abi.decode(data, (AddItemsBatchOperation)); - - CollectionState storage collection = collectionState[addOp.collectionId]; - CollectionMetadata storage metadata = collectionMetadata[addOp.collectionId]; - - require(collection.collectionContract != address(0), "Collection does not exist"); - require(!collection.locked, "Collection is locked"); - - // Only current owner of the collection ethscription can add items - address currentOwner = ethscriptionsContract.ownerOf(addOp.collectionId); - require(currentOwner == sender, "Only collection owner can add items"); - - // Check max size if set - if (metadata.totalSupply > 0) { - require( - collection.currentSize + addOp.items.length <= metadata.totalSupply, - "Exceeds total supply" - ); - } - - // Add each item with full metadata - ERC721EthscriptionsCollection collectionContract = ERC721EthscriptionsCollection(collection.collectionContract); - - for (uint256 i = 0; i < addOp.items.length; i++) { - ItemData memory itemData = addOp.items[i]; - uint256 itemIndex = itemData.itemIndex; // Already uint256 - - // Check that this item slot isn't already taken - require(collectionItems[addOp.collectionId][itemIndex].ethscriptionId == bytes32(0), "Item slot already taken"); - - // Check that this ethscription isn't already in ANY slot - require(ethscriptionToIndexPlusOne[addOp.collectionId][itemData.ethscriptionId] == 0, "Ethscription already in collection"); - - // No validation needed - Attribute struct ensures proper structure + function collectionIdForAddress(address collectionAddress) public view returns (bytes32) { + return collectionAddressToId[collectionAddress]; + } - // Store the full item data - CollectionItem storage newItem = collectionItems[addOp.collectionId][itemIndex]; - newItem.itemIndex = itemIndex; - newItem.name = itemData.name; - newItem.ethscriptionId = itemData.ethscriptionId; - newItem.backgroundColor = itemData.backgroundColor; - newItem.description = itemData.description; + function op_create_collection(bytes32 ethscriptionId, bytes calldata data) public onlyEthscriptions { + CollectionParams memory metadata = abi.decode(data, (CollectionParams)); + _createCollection(ethscriptionId, metadata); + } - // Copy attributes element by element - for (uint256 j = 0; j < itemData.attributes.length; j++) { - newItem.attributes.push(itemData.attributes[j]); - } + function op_create_collection_and_add_self(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions { + CreateAndAddSelfParams memory op = abi.decode(data, (CreateAndAddSelfParams)); - // Map ethscription to its index+1 for lookups (0 means not in collection) - ethscriptionToIndexPlusOne[addOp.collectionId][itemData.ethscriptionId] = itemIndex + 1; + _createCollection(ethscriptionId, op.metadata); + _addSingleItem(ethscriptionId, ethscriptionId, op.item); + } - // Add to ERC721 collection with the specified token ID - collectionContract.addMember(itemData.ethscriptionId, itemIndex); + function op_add_self_to_collection(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions { + AddSelfToCollectionParams memory op = abi.decode(data, (AddSelfToCollectionParams)); - collection.currentSize++; - } - - emit ItemsAdded(addOp.collectionId, addOp.items.length, ethscriptionId); + _addSingleItem(op.collectionId, ethscriptionId, op.item); } - /// @notice Handle remove_items operation function op_remove_items(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions { - // Get who is trying to remove items - Ethscriptions ethscriptionsContract = Ethscriptions(ethscriptions); - Ethscriptions.Ethscription memory ethscription = ethscriptionsContract.getEthscription(ethscriptionId); - address sender = ethscription.creator; - - // Decode the operation data RemoveItemsOperation memory removeOp = abi.decode(data, (RemoveItemsOperation)); - - CollectionState storage collection = collectionState[removeOp.collectionId]; + CollectionRecord storage collection = collectionStore[removeOp.collectionId]; require(collection.collectionContract != address(0), "Collection does not exist"); require(!collection.locked, "Collection is locked"); + _requireCollectionOwner(ethscriptionId, removeOp.collectionId, "Only collection owner can remove"); - // Only current owner of the collection ethscription can remove items - // The collectionId IS the transaction hash of the collection ethscription - address currentOwner = ethscriptionsContract.ownerOf(removeOp.collectionId); - require(currentOwner == sender, "Only collection owner can remove items"); + ERC721EthscriptionsCollection collectionContract = + ERC721EthscriptionsCollection(collection.collectionContract); - // Remove each ethscription from the collection - ERC721EthscriptionsCollection collectionContract = ERC721EthscriptionsCollection(collection.collectionContract); for (uint256 i = 0; i < removeOp.ethscriptionIds.length; i++) { - bytes32 ethscriptionId = removeOp.ethscriptionIds[i]; + bytes32 itemId = removeOp.ethscriptionIds[i]; + Membership storage membership = membershipOfEthscription[itemId]; + require(membership.collectionId == removeOp.collectionId, "Ethscription not in collection"); - // Get the token ID for this ethscription - uint256 tokenIdPlusOne = ethscriptionToIndexPlusOne[removeOp.collectionId][ethscriptionId]; - require(tokenIdPlusOne != 0, "Not in this collection"); + uint256 tokenIdPlusOne = membership.tokenIdPlusOne; + require(tokenIdPlusOne != 0, "Token missing"); uint256 tokenId = tokenIdPlusOne - 1; - // Clear the item data + delete membershipOfEthscription[itemId]; delete collectionItems[removeOp.collectionId][tokenId]; - delete ethscriptionToIndexPlusOne[removeOp.collectionId][ethscriptionId]; - // Remove from ERC721 collection - collectionContract.removeMember(ethscriptionId, tokenId); - collection.currentSize--; + collectionContract.removeMember(itemId, tokenId); } emit ItemsRemoved(removeOp.collectionId, removeOp.ethscriptionIds.length, ethscriptionId); } - /// @notice Handle edit_collection operation function op_edit_collection(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions { - Ethscriptions ethscriptionsContract = Ethscriptions(ethscriptions); - Ethscriptions.Ethscription memory ethscription = ethscriptionsContract.getEthscription(ethscriptionId); - address sender = ethscription.creator; - EditCollectionOperation memory editOp = abi.decode(data, (EditCollectionOperation)); - CollectionMetadata storage metadata = collectionMetadata[editOp.collectionId]; - CollectionState storage collection = collectionState[editOp.collectionId]; + CollectionRecord storage collection = collectionStore[editOp.collectionId]; require(collection.collectionContract != address(0), "Collection does not exist"); require(!collection.locked, "Collection is locked"); + _requireCollectionOwner(ethscriptionId, editOp.collectionId, "Only collection owner can edit"); - address currentOwner = ethscriptionsContract.ownerOf(editOp.collectionId); - require(currentOwner == sender, "Only collection owner can edit"); - - // Update metadata fields (only non-empty values update) - if (bytes(editOp.description).length > 0) metadata.description = editOp.description; - if (bytes(editOp.logoImageUri).length > 0) metadata.logoImageUri = editOp.logoImageUri; - if (bytes(editOp.bannerImageUri).length > 0) metadata.bannerImageUri = editOp.bannerImageUri; - if (bytes(editOp.backgroundColor).length > 0) metadata.backgroundColor = editOp.backgroundColor; - if (bytes(editOp.websiteLink).length > 0) metadata.websiteLink = editOp.websiteLink; - if (bytes(editOp.twitterLink).length > 0) metadata.twitterLink = editOp.twitterLink; - if (bytes(editOp.discordLink).length > 0) metadata.discordLink = editOp.discordLink; + if (bytes(editOp.description).length > 0) collection.description = editOp.description; + if (bytes(editOp.logoImageUri).length > 0) collection.logoImageUri = editOp.logoImageUri; + if (bytes(editOp.bannerImageUri).length > 0) collection.bannerImageUri = editOp.bannerImageUri; + if (bytes(editOp.backgroundColor).length > 0) collection.backgroundColor = editOp.backgroundColor; + if (bytes(editOp.websiteLink).length > 0) collection.websiteLink = editOp.websiteLink; + if (bytes(editOp.twitterLink).length > 0) collection.twitterLink = editOp.twitterLink; + if (bytes(editOp.discordLink).length > 0) collection.discordLink = editOp.discordLink; + collection.merkleRoot = editOp.merkleRoot; emit CollectionEdited(editOp.collectionId); } - /// @notice Handle edit_collection_item operation function op_edit_collection_item(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions { - Ethscriptions ethscriptionsContract = Ethscriptions(ethscriptions); - Ethscriptions.Ethscription memory ethscription = ethscriptionsContract.getEthscription(ethscriptionId); - address sender = ethscription.creator; - EditCollectionItemOperation memory editOp = abi.decode(data, (EditCollectionItemOperation)); - uint256 itemIndex = editOp.itemIndex; // Already uint256 - CollectionState storage collection = collectionState[editOp.collectionId]; + CollectionRecord storage collection = collectionStore[editOp.collectionId]; require(collection.collectionContract != address(0), "Collection does not exist"); require(!collection.locked, "Collection is locked"); + _requireCollectionOwner(ethscriptionId, editOp.collectionId, "Only collection owner can edit items"); - address currentOwner = ethscriptionsContract.ownerOf(editOp.collectionId); - require(currentOwner == sender, "Only collection owner can edit items"); - - CollectionItem storage item = collectionItems[editOp.collectionId][itemIndex]; + CollectionItem storage item = collectionItems[editOp.collectionId][editOp.itemIndex]; require(item.ethscriptionId != bytes32(0), "Item does not exist"); - // Update item fields (only non-empty values update) if (bytes(editOp.name).length > 0) item.name = editOp.name; if (bytes(editOp.backgroundColor).length > 0) item.backgroundColor = editOp.backgroundColor; if (bytes(editOp.description).length > 0) item.description = editOp.description; if (editOp.attributes.length > 0) { - // Clear existing attributes and copy new ones element by element delete item.attributes; for (uint256 i = 0; i < editOp.attributes.length; i++) { item.attributes.push(editOp.attributes[i]); @@ -346,97 +229,63 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler { } } - /// @notice Handle lock_collection operation function op_lock_collection(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions { - // Get the ethscription details from the Ethscriptions contract - Ethscriptions ethscriptionsContract = Ethscriptions(ethscriptions); - Ethscriptions.Ethscription memory ethscription = ethscriptionsContract.getEthscription(ethscriptionId); - address sender = ethscription.creator; // Who sent this lock operation - - // Decode just the collection ID bytes32 collectionId = abi.decode(data, (bytes32)); - - CollectionState storage collection = collectionState[collectionId]; + CollectionRecord storage collection = collectionStore[collectionId]; require(collection.collectionContract != address(0), "Collection does not exist"); - - // Only current owner of the collection ethscription can lock - // The collectionId IS the transaction hash of the collection ethscription - address currentOwner = ethscriptionsContract.ownerOf(collectionId); - require(currentOwner == sender, "Only collection owner can lock"); + _requireCollectionOwner(ethscriptionId, collectionId, "Only collection owner can lock"); collection.locked = true; - ERC721EthscriptionsCollection(collection.collectionContract).lockCollection(); emit CollectionLocked(collectionId); } - /// @notice Handle sync_ownership operation to sync ERC721 ownership with Ethscription ownership - /// @dev Requires specifying the collection ID to sync for, to avoid iterating over unbounded user data - function op_sync_ownership(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions { - // User must specify which collection to sync for - // Decode the operation: collection ID + ethscription IDs to sync - (bytes32 collectionId, bytes32[] memory ethscriptionIds) = abi.decode(data, (bytes32, bytes32[])); - - CollectionState memory collection = collectionState[collectionId]; - require(collection.collectionContract != address(0), "Collection does not exist"); - - ERC721EthscriptionsCollection collectionContract = ERC721EthscriptionsCollection(collection.collectionContract); - - // Sync ownership for specified ethscriptions in this collection - for (uint256 i = 0; i < ethscriptionIds.length; i++) { - bytes32 ethscriptionId = ethscriptionIds[i]; - uint256 tokenIdPlusOne = ethscriptionToIndexPlusOne[collectionId][ethscriptionId]; - - if (tokenIdPlusOne != 0) { - // This ethscription is in the collection, sync its ownership - uint256 tokenId = tokenIdPlusOne - 1; - collectionContract.syncOwnership(tokenId, ethscriptionId); - } + function onTransfer(bytes32 ethscriptionId, address from, address to) external override onlyEthscriptions { + if (collectionExists(ethscriptionId)) { + ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionStore[ethscriptionId].collectionContract); + collection.factoryTransferOwnership(to); + return; } - } + + Membership storage membership = membershipOfEthscription[ethscriptionId]; + + if (!collectionExists(membership.collectionId)) { + return; + } + + ERC721EthscriptionsCollection c = ERC721EthscriptionsCollection(collectionStore[membership.collectionId].collectionContract); - /// @notice Handle transfer notification from Ethscriptions contract - /// @dev When an ethscription that's part of a collection is transferred, sync the ERC721 - function onTransfer( - bytes32 ethscriptionId, - address from, - address to - ) external override onlyEthscriptions {} + ERC721EthscriptionsCollection(c).forceTransfer(from, to, membership.tokenIdPlusOne - 1); + } - // View functions + // -------------------- Views -------------------- function getCollectionAddress(bytes32 collectionId) external view returns (address) { - return collectionState[collectionId].collectionContract; + return collectionStore[collectionId].collectionContract; } - function getCollectionState(bytes32 collectionId) external view returns (CollectionState memory) { - return collectionState[collectionId]; + function getCollection(bytes32 collectionId) external view returns (CollectionRecord memory) { + return collectionStore[collectionId]; } function getCollectionItem(bytes32 collectionId, uint256 itemIndex) external view returns (CollectionItem memory) { return collectionItems[collectionId][itemIndex]; } - function getCollectionMetadata(bytes32 collectionId) external view returns (CollectionMetadata memory) { - return collectionMetadata[collectionId]; - } - function isInCollection(bytes32 ethscriptionId, bytes32 collectionId) external view returns (bool) { - return ethscriptionToIndexPlusOne[collectionId][ethscriptionId] != 0; + return membershipOfEthscription[ethscriptionId].collectionId == collectionId; } - function getEthscriptionTokenId(bytes32 ethscriptionId, bytes32 collectionId) external view returns (uint256) { - uint256 tokenIdPlusOne = ethscriptionToIndexPlusOne[collectionId][ethscriptionId]; + function getEthscriptionTokenId(bytes32 ethscriptionId) external view returns (uint256) { + uint256 tokenIdPlusOne = membershipOfEthscription[ethscriptionId].tokenIdPlusOne; require(tokenIdPlusOne != 0, "Not in collection"); return tokenIdPlusOne - 1; } function predictCollectionAddress(bytes32 collectionId) external view returns (address) { - // Check if already deployed - if (collectionState[collectionId].collectionContract != address(0)) { - return collectionState[collectionId].collectionContract; + if (collectionStore[collectionId].collectionContract != address(0)) { + return collectionStore[collectionId].collectionContract; } - // Predict using CREATE2 bytes memory creationCode = abi.encodePacked(type(Proxy).creationCode, abi.encode(address(this))); return Create2.computeAddress(collectionId, keccak256(creationCode), address(this)); } @@ -444,4 +293,117 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler { function getAllCollections() external view returns (bytes32[] memory) { return collectionIds; } + + // -------------------- Helpers -------------------- + + function _createCollection(bytes32 collectionId, CollectionParams memory metadata) internal { + require(!collectionExists(collectionId), "Collection already exists"); + + Proxy collectionProxy = new Proxy{salt: collectionId}(address(this)); + bytes memory initCalldata = abi.encodeWithSelector( + ERC721EthscriptionsCollection.initialize.selector, + metadata.name, + metadata.symbol, + ethscriptions.ownerOf(collectionId) + ); + + collectionProxy.upgradeToAndCall(collectionsImplementation, initCalldata); + collectionProxy.changeAdmin(Predeploys.PROXY_ADMIN); + + collectionStore[collectionId] = CollectionRecord({ + collectionContract: address(collectionProxy), + locked: false, + name: metadata.name, + symbol: metadata.symbol, + maxSupply: metadata.maxSupply, + description: metadata.description, + logoImageUri: metadata.logoImageUri, + bannerImageUri: metadata.bannerImageUri, + backgroundColor: metadata.backgroundColor, + websiteLink: metadata.websiteLink, + twitterLink: metadata.twitterLink, + discordLink: metadata.discordLink, + merkleRoot: metadata.merkleRoot + }); + + collectionAddressToId[address(collectionProxy)] = collectionId; + collectionIds.push(collectionId); + + emit CollectionCreated(collectionId, address(collectionProxy), metadata.name, metadata.symbol, metadata.maxSupply); + } + + function _addSingleItem( + bytes32 collectionId, + bytes32 ethscriptionId, + ItemData memory item + ) internal { + + CollectionRecord storage collection = collectionStore[collectionId]; + require(collection.collectionContract != address(0), "Collection does not exist"); + require(!collection.locked, "Collection is locked"); + + address sender = _getEthscriptionCreator(ethscriptionId); + + ERC721EthscriptionsCollection collectionContract = + ERC721EthscriptionsCollection(collection.collectionContract); + address collectionOwner = collectionContract.owner(); + bool senderIsCollectionOwner = sender == collectionOwner; + + if (collection.maxSupply > 0) { + uint256 supply = collectionContract.totalSupply(); + require(supply + 1 <= collection.maxSupply, "Exceeds max supply"); + } + + Membership storage membership = membershipOfEthscription[ethscriptionId]; + require(membership.collectionId == bytes32(0), "Ethscription already in collection"); + require(collectionItems[collectionId][item.itemIndex].ethscriptionId == bytes32(0), "Item slot taken"); + + if (!senderIsCollectionOwner && !_inImportMode()) { + _verifyItemMerkleProof(item, collection.merkleRoot); + } + + _storeCollectionItem(collectionId, ethscriptionId, item); + membership.collectionId = collectionId; + membership.tokenIdPlusOne = item.itemIndex + 1; + collectionContract.addMember(ethscriptionId, item.itemIndex); + + emit ItemsAdded(collectionId, 1, ethscriptionId); + } + + function _storeCollectionItem(bytes32 collectionId, bytes32 ethscriptionId, ItemData memory item) private { + CollectionItem storage newItem = collectionItems[collectionId][item.itemIndex]; + newItem.itemIndex = item.itemIndex; + newItem.name = item.name; + newItem.ethscriptionId = ethscriptionId; + newItem.backgroundColor = item.backgroundColor; + newItem.description = item.description; + + for (uint256 j = 0; j < item.attributes.length; j++) { + newItem.attributes.push(item.attributes[j]); + } + } + + function _getEthscriptionCreator(bytes32 ethscriptionId) private view returns (address) { + Ethscriptions.Ethscription memory operation = ethscriptions.getEthscription(ethscriptionId, false); + return operation.creator; + } + + function _requireCollectionOwner(bytes32 ethscriptionId, bytes32 collectionId, string memory errorMessage) private view { + address sender = _getEthscriptionCreator(ethscriptionId); + CollectionRecord storage collection = collectionStore[collectionId]; + require(collection.collectionContract != address(0), "Collection does not exist"); + ERC721EthscriptionsCollection collectionContract = ERC721EthscriptionsCollection(collection.collectionContract); + address currentOwner = collectionContract.owner(); + require(currentOwner == sender, errorMessage); + } + + function _verifyItemMerkleProof(ItemData memory item, bytes32 merkleRoot) private pure { + require(merkleRoot != bytes32(0), "Merkle proof required"); + bytes32 leaf = keccak256(abi.encode(item)); + require(MerkleProof.verify(item.merkleProof, merkleRoot, leaf), "Invalid Merkle proof"); + } + + function _inImportMode() private view returns (bool) { + return block.timestamp < Constants.historicalBackfillApproxDoneAt; + } } diff --git a/contracts/test/AddressPrediction.t.sol b/contracts/test/AddressPrediction.t.sol index 0981443..39b7a7c 100644 --- a/contracts/test/AddressPrediction.t.sol +++ b/contracts/test/AddressPrediction.t.sol @@ -41,20 +41,41 @@ contract AddressPredictionTest is TestSetup { function testPredictCollectionsAddress() public { // Arrange bytes32 collectionId = keccak256("collection-1"); + address creator = makeAddr("creator"); - ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata = ERC721EthscriptionsCollectionManager.CollectionMetadata({ - name: "My Collection", - symbol: "MYC", - totalSupply: 1000, - description: "A test collection", - logoImageUri: "data:,logo", - bannerImageUri: "data:,banner", - backgroundColor: "#000000", - websiteLink: "https://example.com", - twitterLink: "", - discordLink: "" + // First, create the ethscription that will represent this collection + Ethscriptions.CreateEthscriptionParams memory ethscriptionParams = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: collectionId, + contentUriHash: keccak256("collection-content"), + initialOwner: creator, + content: bytes("collection-content"), + mimetype: "text/plain", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "", + operation: "", + data: "" + }) }); + vm.prank(creator); + ethscriptions.createEthscription(ethscriptionParams); + + ERC721EthscriptionsCollectionManager.CollectionParams memory metadata = + ERC721EthscriptionsCollectionManager.CollectionParams({ + name: "My Collection", + symbol: "MYC", + maxSupply: 1000, + description: "A test collection", + logoImageUri: "data:,logo", + bannerImageUri: "data:,banner", + backgroundColor: "#000000", + websiteLink: "https://example.com", + twitterLink: "", + discordLink: "", + merkleRoot: bytes32(0) + }); + // Manually compute predicted proxy address bytes memory creationCode = abi.encodePacked(type(Proxy).creationCode, abi.encode(address(collectionsHandler))); address predicted = Create2.computeAddress(collectionId, keccak256(creationCode), address(collectionsHandler)); @@ -64,7 +85,9 @@ contract AddressPredictionTest is TestSetup { collectionsHandler.op_create_collection(collectionId, abi.encode(metadata)); // Assert deployed matches predicted - (address actual,,,) = collectionsHandler.collectionState(collectionId); + ERC721EthscriptionsCollectionManager.CollectionRecord memory collection = + collectionsHandler.getCollection(collectionId); + address actual = collection.collectionContract; assertEq(actual, predicted, "Predicted collection address should match actual deployed proxy"); } } diff --git a/contracts/test/CollectionsManager.t.sol b/contracts/test/CollectionsManager.t.sol index 3a3d5c2..f120955 100644 --- a/contracts/test/CollectionsManager.t.sol +++ b/contracts/test/CollectionsManager.t.sol @@ -25,20 +25,22 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { // Create a collection as Alice vm.prank(alice); - string memory collectionContent = 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test Collection","symbol":"TEST","total_supply":"100"}'; - - ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata = ERC721EthscriptionsCollectionManager.CollectionMetadata({ - name: "Test Collection", - symbol: "TEST", - totalSupply: 100, - description: "A test collection for unit tests", - logoImageUri: "esc://ethscriptions/0x123/data", - bannerImageUri: "esc://ethscriptions/0x456/data", - backgroundColor: "#FF5733", - websiteLink: "https://example.com", - twitterLink: "https://twitter.com/test", - discordLink: "https://discord.gg/test" - }); + string memory collectionContent = 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test Collection","symbol":"TEST","max_supply":"100"}'; + + ERC721EthscriptionsCollectionManager.CollectionParams memory metadata = + ERC721EthscriptionsCollectionManager.CollectionParams({ + name: "Test Collection", + symbol: "TEST", + maxSupply: 100, + description: "A test collection for unit tests", + logoImageUri: "esc://ethscriptions/0x123/data", + bannerImageUri: "esc://ethscriptions/0x456/data", + backgroundColor: "#FF5733", + websiteLink: "https://example.com", + twitterLink: "https://twitter.com/test", + discordLink: "https://discord.gg/test", + merkleRoot: bytes32(0) + }); Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: COLLECTION_TX_HASH, @@ -66,12 +68,101 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { // Collection owner is tracked through the original ethscription ownership // Verify metadata was stored - ERC721EthscriptionsCollectionManager.CollectionMetadata memory storedMetadata = collectionsHandler.getCollectionMetadata(COLLECTION_TX_HASH); - assertEq(storedMetadata.name, "Test Collection"); - assertEq(storedMetadata.symbol, "TEST"); - assertEq(storedMetadata.totalSupply, 100); - assertEq(storedMetadata.description, "A test collection for unit tests"); - assertEq(storedMetadata.backgroundColor, "#FF5733"); + ERC721EthscriptionsCollectionManager.CollectionRecord memory stored = collectionsHandler.getCollection(COLLECTION_TX_HASH); + assertEq(stored.name, "Test Collection"); + assertEq(stored.symbol, "TEST"); + assertEq(stored.maxSupply, 100); + assertEq(stored.description, "A test collection for unit tests"); + assertEq(stored.backgroundColor, "#FF5733"); + } + + function testCreateCollectionAndAddSelf() public { + // Create ethscription that both creates a collection and adds itself as the first item + bytes32 collectionAndItemId = bytes32(uint256(0xC0FFEE)); + + // Prepare collection metadata + ERC721EthscriptionsCollectionManager.CollectionParams memory metadata = + ERC721EthscriptionsCollectionManager.CollectionParams({ + name: "Self Collection", + symbol: "SELF", + maxSupply: 100, + description: "A collection where creator is first item", + logoImageUri: "esc://ethscriptions/0x123/data", + bannerImageUri: "esc://ethscriptions/0x456/data", + backgroundColor: "#112233", + websiteLink: "https://example.com", + twitterLink: "", + discordLink: "", + merkleRoot: bytes32(0) + }); + + // Prepare item data + ERC721EthscriptionsCollectionManager.Attribute[] memory attributes = + new ERC721EthscriptionsCollectionManager.Attribute[](2); + attributes[0] = ERC721EthscriptionsCollectionManager.Attribute({ + traitType: "Type", + value: "Genesis" + }); + attributes[1] = ERC721EthscriptionsCollectionManager.Attribute({ + traitType: "Creator", + value: "Alice" + }); + + ERC721EthscriptionsCollectionManager.ItemData memory itemData = + ERC721EthscriptionsCollectionManager.ItemData({ + itemIndex: 0, + name: "Genesis Item #0", + backgroundColor: "#445566", + description: "The first item in this collection", + attributes: attributes, + merkleProof: new bytes32[](0) + }); + + ERC721EthscriptionsCollectionManager.CreateAndAddSelfParams memory params = + ERC721EthscriptionsCollectionManager.CreateAndAddSelfParams({ + metadata: metadata, + item: itemData + }); + + // Create the ethscription + Ethscriptions.CreateEthscriptionParams memory ethscriptionParams = + Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: collectionAndItemId, + contentUriHash: sha256(bytes("collection and item content")), + initialOwner: alice, + content: bytes("collection and item content"), + mimetype: "text/plain", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "erc-721-ethscriptions-collection", + operation: "create_collection_and_add_self", + data: abi.encode(params) + }) + }); + + vm.prank(alice); + ethscriptions.createEthscription(ethscriptionParams); + + // Verify collection was created + address collectionAddress = collectionsHandler.getCollectionAddress(collectionAndItemId); + assertTrue(collectionAddress != address(0), "Collection should be created"); + + ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddress); + assertEq(collection.name(), "Self Collection"); + assertEq(collection.symbol(), "SELF"); + + // Verify item was added as token ID 0 + assertEq(collection.ownerOf(0), alice); + + // Verify item metadata + ERC721EthscriptionsCollectionManager.CollectionItem memory item = + collectionsHandler.getCollectionItem(collectionAndItemId, 0); + assertEq(item.name, "Genesis Item #0"); + assertEq(item.description, "The first item in this collection"); + assertEq(item.backgroundColor, "#445566"); + assertEq(item.attributes.length, 2); + assertEq(item.attributes[0].traitType, "Type"); + assertEq(item.attributes[0].value, "Genesis"); } function testAddToCollection() public { @@ -81,17 +172,10 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { address collectionAddress = collectionsHandler.getCollectionAddress(COLLECTION_TX_HASH); ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddress); - // Create an ethscription to add to the collection - vm.prank(alice); - - string memory itemContent = 'data:,{"p":"erc-721-ethscriptions-collection","op":"add","collection":"0x1234","item":"artwork1"}'; - - bytes32[] memory items = new bytes32[](1); - items[0] = ITEM1_TX_HASH; + // Create an ethscription that adds itself to the collection at creation time + string memory itemContent = 'data:,{"p":"erc-721-ethscriptions-collection","op":"add_self","collection":"0x1234","item":"artwork1"}'; // Create item data with attributes - ERC721EthscriptionsCollectionManager.ItemData[] memory itemsData = new ERC721EthscriptionsCollectionManager.ItemData[](1); - ERC721EthscriptionsCollectionManager.Attribute[] memory attributes = new ERC721EthscriptionsCollectionManager.Attribute[](3); attributes[0] = ERC721EthscriptionsCollectionManager.Attribute({ traitType: "Type", @@ -106,20 +190,22 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { value: "Blue" }); - itemsData[0] = ERC721EthscriptionsCollectionManager.ItemData({ + ERC721EthscriptionsCollectionManager.ItemData memory itemData = ERC721EthscriptionsCollectionManager.ItemData({ itemIndex: 0, name: "Test Item #0", - ethscriptionId: ITEM1_TX_HASH, backgroundColor: "#0000FF", description: "First test item", - attributes: attributes + attributes: attributes, + merkleProof: new bytes32[](0) }); - ERC721EthscriptionsCollectionManager.AddItemsBatchOperation memory addOp = ERC721EthscriptionsCollectionManager.AddItemsBatchOperation({ - collectionId: COLLECTION_TX_HASH, - items: itemsData - }); + ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams memory addSelfParams = + ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams({ + collectionId: COLLECTION_TX_HASH, + item: itemData + }); + // Create the ethscription with protocol set to add itself to the collection Ethscriptions.CreateEthscriptionParams memory itemParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: ITEM1_TX_HASH, contentUriHash: sha256(bytes(itemContent)), @@ -129,8 +215,8 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { esip6: false, protocolParams: Ethscriptions.ProtocolParams({ protocolName: "erc-721-ethscriptions-collection", - operation: "add_items_batch", - data: abi.encode(addOp) + operation: "add_self_to_collection", + data: abi.encode(addSelfParams) }) }); @@ -184,13 +270,14 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { address collectionAddress = collectionsHandler.getCollectionAddress(COLLECTION_TX_HASH); ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddress); + uint256 tokenId = collectionsHandler.getEthscriptionTokenId(ITEM1_TX_HASH); + // Burn the ethscription (transfer to address(0)) vm.prank(alice); ethscriptions.transferEthscription(address(0), ITEM1_TX_HASH); // Verify item is still in collection but owned by address(0) // Token ID is the item index (0 for the first item) - uint256 tokenId = 0; assertEq(collection.ownerOf(tokenId), address(0)); } @@ -228,12 +315,22 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { }) }); + // Check token exists before removal + uint256 tokenId = 0; + address ownerBefore = collection.ownerOf(tokenId); + assertEq(ownerBefore, alice, "Should own token before removal"); + vm.prank(alice); ethscriptions.createEthscription(removeParams); + // Check membership was removed from manager + (bytes32 collId, uint256 tokenIdPlusOne) = collectionsHandler.membershipOfEthscription(ITEM1_TX_HASH); + assertEq(collId, bytes32(0), "Collection ID should be zero after removal"); + assertEq(tokenIdPlusOne, 0, "Token ID plus one should be zero after removal"); + // Verify item was removed - token should no longer exist - uint256 tokenId = 0; - vm.expectRevert("Token does not exist"); + // This should revert with ERC721NonexistentToken + vm.expectRevert(abi.encodeWithSignature("ERC721NonexistentToken(uint256)", tokenId)); collection.ownerOf(tokenId); } @@ -285,16 +382,12 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { address collectionAddress = collectionsHandler.getCollectionAddress(COLLECTION_TX_HASH); ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddress); - // Add multiple items + // Add multiple items (each adds itself at creation time) bytes32[3] memory itemHashes = [ITEM1_TX_HASH, ITEM2_TX_HASH, ITEM3_TX_HASH]; address[3] memory owners = [alice, bob, charlie]; for (uint i = 0; i < 3; i++) { - vm.prank(alice); - - // Create item data for the batch add - ERC721EthscriptionsCollectionManager.ItemData[] memory itemsData = new ERC721EthscriptionsCollectionManager.ItemData[](1); - + // Create item data for self-add ERC721EthscriptionsCollectionManager.Attribute[] memory attributes = new ERC721EthscriptionsCollectionManager.Attribute[](1); attributes[0] = ERC721EthscriptionsCollectionManager.Attribute({ traitType: "Type", @@ -303,20 +396,22 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { string memory itemName = i == 0 ? "Item #0" : i == 1 ? "Item #1" : "Item #2"; - itemsData[0] = ERC721EthscriptionsCollectionManager.ItemData({ + ERC721EthscriptionsCollectionManager.ItemData memory itemData = ERC721EthscriptionsCollectionManager.ItemData({ itemIndex: uint256(i), name: itemName, - ethscriptionId: itemHashes[i], backgroundColor: "#000000", description: "Test item", - attributes: attributes + attributes: attributes, + merkleProof: new bytes32[](0) }); - ERC721EthscriptionsCollectionManager.AddItemsBatchOperation memory addOp = ERC721EthscriptionsCollectionManager.AddItemsBatchOperation({ - collectionId: COLLECTION_TX_HASH, - items: itemsData - }); + ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams memory addSelfParams = + ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams({ + collectionId: COLLECTION_TX_HASH, + item: itemData + }); + // Create ethscription that adds itself to the collection Ethscriptions.CreateEthscriptionParams memory itemParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: itemHashes[i], contentUriHash: sha256(abi.encodePacked("item", i)), @@ -326,8 +421,8 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { esip6: false, protocolParams: Ethscriptions.ProtocolParams({ protocolName: "erc-721-ethscriptions-collection", - operation: "add_items_batch", - data: abi.encode(addOp) + operation: "add_self_to_collection", + data: abi.encode(addSelfParams) }) }); @@ -357,8 +452,6 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { string memory imageContent = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; // Create item data with attributes - ERC721EthscriptionsCollectionManager.ItemData[] memory itemsData = new ERC721EthscriptionsCollectionManager.ItemData[](1); - ERC721EthscriptionsCollectionManager.Attribute[] memory attributes = new ERC721EthscriptionsCollectionManager.Attribute[](4); attributes[0] = ERC721EthscriptionsCollectionManager.Attribute({ traitType: "Type", @@ -377,19 +470,20 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { value: "Rare" }); - itemsData[0] = ERC721EthscriptionsCollectionManager.ItemData({ + ERC721EthscriptionsCollectionManager.ItemData memory itemData = ERC721EthscriptionsCollectionManager.ItemData({ itemIndex: 0, name: "Ittybit #0000", - ethscriptionId: ITEM1_TX_HASH, backgroundColor: "#648595", description: "A rare ittybit with green eye shadow", - attributes: attributes + attributes: attributes, + merkleProof: new bytes32[](0) }); - ERC721EthscriptionsCollectionManager.AddItemsBatchOperation memory addOp = ERC721EthscriptionsCollectionManager.AddItemsBatchOperation({ - collectionId: COLLECTION_TX_HASH, - items: itemsData - }); + ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams memory addSelfParams = + ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams({ + collectionId: COLLECTION_TX_HASH, + item: itemData + }); // Create the ethscription with image content Ethscriptions.CreateEthscriptionParams memory itemParams = Ethscriptions.CreateEthscriptionParams({ @@ -401,8 +495,8 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { esip6: false, protocolParams: Ethscriptions.ProtocolParams({ protocolName: "erc-721-ethscriptions-collection", - operation: "add_items_batch", - data: abi.encode(addOp) + operation: "add_self_to_collection", + data: abi.encode(addSelfParams) }) }); @@ -496,60 +590,42 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { // Setup: Create collection and add item with attributes testCreateCollection(); - // First create the ethscription that we'll add to the collection - vm.prank(alice); - Ethscriptions.CreateEthscriptionParams memory itemCreationParams = Ethscriptions.CreateEthscriptionParams({ - ethscriptionId: ITEM1_TX_HASH, - contentUriHash: sha256(bytes("item content")), - initialOwner: alice, - content: bytes("item content"), - mimetype: "text/plain", - esip6: false, - protocolParams: Ethscriptions.ProtocolParams({ - protocolName: "", - operation: "", - data: "" - }) - }); - ethscriptions.createEthscription(itemCreationParams); - - // Now add item with attributes - vm.prank(alice); + // Create ethscription that adds itself to the collection with attributes ERC721EthscriptionsCollectionManager.Attribute[] memory attributes = new ERC721EthscriptionsCollectionManager.Attribute[](2); attributes[0] = ERC721EthscriptionsCollectionManager.Attribute({traitType: "Hair Color", value: "Brown"}); attributes[1] = ERC721EthscriptionsCollectionManager.Attribute({traitType: "Hair", value: "Blonde Bob"}); - ERC721EthscriptionsCollectionManager.ItemData[] memory items = new ERC721EthscriptionsCollectionManager.ItemData[](1); - items[0] = ERC721EthscriptionsCollectionManager.ItemData({ + ERC721EthscriptionsCollectionManager.ItemData memory itemData = ERC721EthscriptionsCollectionManager.ItemData({ itemIndex: 0, name: "Test Item #0", - ethscriptionId: ITEM1_TX_HASH, backgroundColor: "#FF5733", description: "First item description", - attributes: attributes + attributes: attributes, + merkleProof: new bytes32[](0) }); - ERC721EthscriptionsCollectionManager.AddItemsBatchOperation memory addOp = ERC721EthscriptionsCollectionManager.AddItemsBatchOperation({ - collectionId: COLLECTION_TX_HASH, - items: items - }); + ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams memory addSelfParams = + ERC721EthscriptionsCollectionManager.AddSelfToCollectionParams({ + collectionId: COLLECTION_TX_HASH, + item: itemData + }); - Ethscriptions.CreateEthscriptionParams memory addParams = Ethscriptions.CreateEthscriptionParams({ - ethscriptionId: bytes32(uint256(0xADD1733)), - contentUriHash: sha256(bytes("add")), + Ethscriptions.CreateEthscriptionParams memory itemParams = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: ITEM1_TX_HASH, + contentUriHash: sha256(bytes("item content")), initialOwner: alice, - content: bytes("add"), + content: bytes("item content"), mimetype: "text/plain", esip6: false, protocolParams: Ethscriptions.ProtocolParams({ protocolName: "erc-721-ethscriptions-collection", - operation: "add_items_batch", - data: abi.encode(addOp) + operation: "add_self_to_collection", + data: abi.encode(addSelfParams) }) }); vm.prank(alice); - ethscriptions.createEthscription(addParams); + ethscriptions.createEthscription(itemParams); // Edit item 0 - only update name and description, keep existing attributes vm.prank(alice); @@ -729,37 +805,13 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { ethscriptions.transferEthscription(alice, ITEM2_TX_HASH); // Verify ethscriptions have new owners - // Note: ERC721's ownerOf always returns the current ethscription owner assertEq(ethscriptions.ownerOf(ITEM1_TX_HASH), charlie); assertEq(ethscriptions.ownerOf(ITEM2_TX_HASH), alice); - assertEq(collection.ownerOf(0), charlie); // Immediately reflects new owner - assertEq(collection.ownerOf(1), alice); // Immediately reflects new owner - - // Sync multiple items at once - bytes32[] memory ethscriptionIds = new bytes32[](2); - ethscriptionIds[0] = ITEM1_TX_HASH; - ethscriptionIds[1] = ITEM2_TX_HASH; - - Ethscriptions.CreateEthscriptionParams memory syncParams = Ethscriptions.CreateEthscriptionParams({ - ethscriptionId: bytes32(uint256(0x5914CD)), - contentUriHash: sha256(bytes("sync-multi")), - initialOwner: alice, - content: bytes("sync-multi"), - mimetype: "text/plain", - esip6: false, - protocolParams: Ethscriptions.ProtocolParams({ - protocolName: "erc-721-ethscriptions-collection", - operation: "sync_ownership", - data: abi.encode(COLLECTION_TX_HASH, ethscriptionIds) - }) - }); - - vm.prank(alice); - ethscriptions.createEthscription(syncParams); - // Verify all ownerships are now synced - assertEq(collection.ownerOf(0), charlie); - assertEq(collection.ownerOf(1), alice); + // Ownership should sync automatically via onTransfer callback + // since these ethscriptions have the collection protocol set + assertEq(collection.ownerOf(0), charlie); // Should be synced automatically + assertEq(collection.ownerOf(1), alice); // Should be synced automatically } function testSyncOwnershipNonExistentItem() public { diff --git a/contracts/test/CollectionsProtocol.t.sol b/contracts/test/CollectionsProtocol.t.sol index db47c88..a7acb70 100644 --- a/contracts/test/CollectionsProtocol.t.sol +++ b/contracts/test/CollectionsProtocol.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.24; import "forge-std/Test.sol"; import "../src/ERC721EthscriptionsCollectionManager.sol"; +import "../src/ERC721EthscriptionsCollection.sol"; import "../src/Ethscriptions.sol"; import "../src/libraries/Predeploys.sol"; import "./TestSetup.sol"; @@ -12,24 +13,44 @@ contract CollectionsProtocolTest is TestSetup { function test_CreateCollection() public { // Encode collection metadata as ABI tuple - ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata = ERC721EthscriptionsCollectionManager.CollectionMetadata({ - name: "Test Collection", - symbol: "TEST", - totalSupply: 100, - description: "A test collection", - logoImageUri: "https://example.com/logo.png", - bannerImageUri: "", - backgroundColor: "", - websiteLink: "", - twitterLink: "", - discordLink: "" - }); + ERC721EthscriptionsCollectionManager.CollectionParams memory metadata = + ERC721EthscriptionsCollectionManager.CollectionParams({ + name: "Test Collection", + symbol: "TEST", + maxSupply: 100, + description: "A test collection", + logoImageUri: "https://example.com/logo.png", + bannerImageUri: "", + backgroundColor: "", + websiteLink: "", + twitterLink: "", + discordLink: "", + merkleRoot: bytes32(0) + }); bytes memory encodedMetadata = abi.encode(metadata); // Create the ethscription bytes32 txHash = keccak256("create_collection_tx"); + // First, create the ethscription that will represent this collection + Ethscriptions.CreateEthscriptionParams memory ethscriptionParams = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: txHash, + contentUriHash: keccak256("test-collection-content"), + initialOwner: alice, + content: bytes("test-collection-content"), + mimetype: "text/plain", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "", + operation: "", + data: "" + }) + }); + + vm.prank(alice); + ethscriptions.createEthscription(ethscriptionParams); + vm.prank(address(ethscriptions)); collectionsHandler.op_create_collection(txHash, encodedMetadata); @@ -37,39 +58,42 @@ contract CollectionsProtocolTest is TestSetup { bytes32 collectionId = txHash; // Use the getter functions instead of direct mapping access - ERC721EthscriptionsCollectionManager.CollectionState memory state = collectionsHandler.getCollectionState(collectionId); - assertNotEq(state.collectionContract, address(0), "Collection contract should be deployed"); - assertEq(state.createEthscriptionId, txHash, "Create ethscription ID should match"); - assertEq(state.currentSize, 0, "Initial size should be 0"); - assertEq(state.locked, false, "Should not be locked"); + ERC721EthscriptionsCollectionManager.CollectionRecord memory collection = collectionsHandler.getCollection(collectionId); + assertNotEq(collection.collectionContract, address(0), "Collection contract should be deployed"); + assertEq(collection.locked, false, "Should not be locked"); + + ERC721EthscriptionsCollection collectionContract = ERC721EthscriptionsCollection(collection.collectionContract); + assertEq(collectionContract.totalSupply(), 0, "Initial size should be 0"); // Verify metadata - ERC721EthscriptionsCollectionManager.CollectionMetadata memory storedMetadata = collectionsHandler.getCollectionMetadata(collectionId); - assertEq(storedMetadata.name, "Test Collection", "Name should match"); - assertEq(storedMetadata.symbol, "TEST", "Symbol should match"); - assertEq(storedMetadata.totalSupply, 100, "Total supply should match"); - assertEq(storedMetadata.description, "A test collection", "Description should match"); + ERC721EthscriptionsCollectionManager.CollectionRecord memory stored = collectionsHandler.getCollection(collectionId); + assertEq(stored.name, "Test Collection", "Name should match"); + assertEq(stored.symbol, "TEST", "Symbol should match"); + assertEq(stored.maxSupply, 100, "Max supply should match"); + assertEq(stored.description, "A test collection", "Description should match"); } function test_CreateCollectionEndToEnd() public { // Full end-to-end test: create ethscription with JSON, let it call the protocol handler // The JSON data - string memory json = '{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test NFTs","symbol":"TEST","totalSupply":"100","description":"","logoImageUri":"","bannerImageUri":"","backgroundColor":"","websiteLink":"","twitterLink":"","discordLink":""}'; + string memory json = '{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test NFTs","symbol":"TEST","maxSupply":"100","description":"","logoImageUri":"","bannerImageUri":"","backgroundColor":"","websiteLink":"","twitterLink":"","discordLink":""}'; // Encode the metadata as the protocol handler expects - ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata = ERC721EthscriptionsCollectionManager.CollectionMetadata({ - name: "Test NFTs", - symbol: "TEST", - totalSupply: 100, - description: "", - logoImageUri: "", - bannerImageUri: "", - backgroundColor: "", - websiteLink: "", - twitterLink: "", - discordLink: "" - }); + ERC721EthscriptionsCollectionManager.CollectionParams memory metadata = + ERC721EthscriptionsCollectionManager.CollectionParams({ + name: "Test NFTs", + symbol: "TEST", + maxSupply: 100, + description: "", + logoImageUri: "", + bannerImageUri: "", + backgroundColor: "", + websiteLink: "", + twitterLink: "", + discordLink: "", + merkleRoot: bytes32(0) + }); bytes memory encodedProtocolData = abi.encode(metadata); @@ -97,51 +121,71 @@ contract CollectionsProtocolTest is TestSetup { bytes32 collectionId = txHash; // Read back the state - ERC721EthscriptionsCollectionManager.CollectionState memory state = collectionsHandler.getCollectionState(collectionId); + ERC721EthscriptionsCollectionManager.CollectionRecord memory collection = collectionsHandler.getCollection(collectionId); - console.log("Collection exists:", state.collectionContract != address(0)); - console.log("Collection contract:", state.collectionContract); - console.log("Current size:", state.currentSize); + console.log("Collection exists:", collection.collectionContract != address(0)); + console.log("Collection contract:", collection.collectionContract); + ERC721EthscriptionsCollection collectionContract = ERC721EthscriptionsCollection(collection.collectionContract); + console.log("Current size:", collectionContract.totalSupply()); // Verify the collection was created - assertTrue(state.collectionContract != address(0), "Collection should exist"); - assertEq(state.createEthscriptionId, txHash); - assertEq(state.currentSize, 0); - assertEq(state.locked, false); + assertTrue(collection.collectionContract != address(0), "Collection should exist"); + assertEq(collection.locked, false); + assertEq(collectionContract.totalSupply(), 0); // Read metadata - ERC721EthscriptionsCollectionManager.CollectionMetadata memory storedMetadata = collectionsHandler.getCollectionMetadata(collectionId); - assertEq(storedMetadata.name, "Test NFTs"); - assertEq(storedMetadata.symbol, "TEST"); - assertEq(storedMetadata.totalSupply, 100); + ERC721EthscriptionsCollectionManager.CollectionRecord memory stored = collectionsHandler.getCollection(collectionId); + assertEq(stored.name, "Test NFTs"); + assertEq(stored.symbol, "TEST"); + assertEq(stored.maxSupply, 100); } function test_ReadCollectionStateViaEthCall() public { // Create a collection first - ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata = ERC721EthscriptionsCollectionManager.CollectionMetadata({ - name: "Call Test", - symbol: "CALL", - totalSupply: 50, - description: "", - logoImageUri: "", - bannerImageUri: "", - backgroundColor: "", - websiteLink: "", - twitterLink: "", - discordLink: "" - }); + ERC721EthscriptionsCollectionManager.CollectionParams memory metadata = + ERC721EthscriptionsCollectionManager.CollectionParams({ + name: "Call Test", + symbol: "CALL", + maxSupply: 50, + description: "", + logoImageUri: "", + bannerImageUri: "", + backgroundColor: "", + websiteLink: "", + twitterLink: "", + discordLink: "", + merkleRoot: bytes32(0) + }); bytes32 txHash = keccak256("call_test_tx"); + // First, create the ethscription that will represent this collection + Ethscriptions.CreateEthscriptionParams memory ethscriptionParams = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: txHash, + contentUriHash: keccak256("call-test-content"), + initialOwner: alice, + content: bytes("call-test-content"), + mimetype: "text/plain", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "", + operation: "", + data: "" + }) + }); + + vm.prank(alice); + ethscriptions.createEthscription(ethscriptionParams); + vm.prank(address(ethscriptions)); collectionsHandler.op_create_collection(txHash, abi.encode(metadata)); // Now simulate an eth_call to read the state bytes32 collectionId = txHash; - // Encode the function call: getCollectionState(bytes32) + // Encode the function call: getCollection(bytes32) bytes memory callData = abi.encodeWithSelector( - collectionsHandler.getCollectionState.selector, + collectionsHandler.getCollection.selector, collectionId ); @@ -156,12 +200,12 @@ contract CollectionsProtocolTest is TestSetup { console.logBytes(result); // Decode the result - ERC721EthscriptionsCollectionManager.CollectionState memory state = abi.decode(result, (ERC721EthscriptionsCollectionManager.CollectionState)); + ERC721EthscriptionsCollectionManager.CollectionRecord memory collection = abi.decode(result, (ERC721EthscriptionsCollectionManager.CollectionRecord)); - assertTrue(state.collectionContract != address(0), "Should have collection contract"); - assertEq(state.createEthscriptionId, txHash); - assertEq(state.currentSize, 0); - assertEq(state.locked, false); + assertTrue(collection.collectionContract != address(0), "Should have collection contract"); + assertEq(collection.locked, false); + ERC721EthscriptionsCollection collectionContract = ERC721EthscriptionsCollection(collection.collectionContract); + assertEq(collectionContract.totalSupply(), 0); console.log("Successfully read collection state via eth_call!"); } diff --git a/lib/collections_reader.rb b/lib/collections_reader.rb index f4ac721..db89605 100644 --- a/lib/collections_reader.rb +++ b/lib/collections_reader.rb @@ -1,55 +1,33 @@ class CollectionsReader COLLECTIONS_MANAGER_ADDRESS = '0x3300000000000000000000000000000000000006' + ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' # Define struct ABIs matching CollectionsManager.sol - COLLECTION_METADATA_STRUCT = { - 'components' => [ - { 'name' => 'name', 'type' => 'string' }, - { 'name' => 'symbol', 'type' => 'string' }, - { 'name' => 'totalSupply', 'type' => 'uint256' }, - { 'name' => 'description', 'type' => 'string' }, - { 'name' => 'logoImageUri', 'type' => 'string' }, - { 'name' => 'bannerImageUri', 'type' => 'string' }, - { 'name' => 'backgroundColor', 'type' => 'string' }, - { 'name' => 'websiteLink', 'type' => 'string' }, - { 'name' => 'twitterLink', 'type' => 'string' }, - { 'name' => 'discordLink', 'type' => 'string' } - ], - 'type' => 'tuple' - } - - COLLECTION_STATE_STRUCT = { - 'components' => [ - { 'name' => 'collectionContract', 'type' => 'address' }, - { 'name' => 'createTxHash', 'type' => 'bytes32' }, - { 'name' => 'currentSize', 'type' => 'uint256' }, - { 'name' => 'locked', 'type' => 'bool' } - ], - 'type' => 'tuple' - } - # Contract ABI for functions we need CONTRACT_ABI = [ { - 'name' => 'collectionState', - 'type' => 'function', - 'stateMutability' => 'view', - 'inputs' => [ - { 'name' => 'collectionId', 'type' => 'bytes32' } - ], - 'outputs' => [ - COLLECTION_STATE_STRUCT - ] - }, - { - 'name' => 'collectionMetadata', + 'name' => 'getCollection', 'type' => 'function', 'stateMutability' => 'view', 'inputs' => [ { 'name' => 'collectionId', 'type' => 'bytes32' } ], 'outputs' => [ - COLLECTION_METADATA_STRUCT + [ + { 'name' => 'collectionContract', 'type' => 'address' }, + { 'name' => 'locked', 'type' => 'bool' }, + { 'name' => 'name', 'type' => 'string' }, + { 'name' => 'symbol', 'type' => 'string' }, + { 'name' => 'maxSupply', 'type' => 'uint256' }, + { 'name' => 'description', 'type' => 'string' }, + { 'name' => 'logoImageUri', 'type' => 'string' }, + { 'name' => 'bannerImageUri', 'type' => 'string' }, + { 'name' => 'backgroundColor', 'type' => 'string' }, + { 'name' => 'websiteLink', 'type' => 'string' }, + { 'name' => 'twitterLink', 'type' => 'string' }, + { 'name' => 'discordLink', 'type' => 'string' }, + { 'name' => 'merkleRoot', 'type' => 'bytes32' } + ] ] } ] @@ -61,50 +39,47 @@ def self.get_collection_state(collection_id, block_tag: 'latest') # Encode parameters encoded_params = Eth::Abi.encode(input_types, [normalize_bytes32(collection_id)]) # Use the actual function name from the contract - function_selector = Eth::Util.keccak256('getCollectionState(bytes32)')[0..3] - data = (function_selector + encoded_params).unpack1('H*') - data = '0x' + data - - # Make the call - result = EthRpcClient.l2.eth_call( - to: COLLECTIONS_MANAGER_ADDRESS, - data: data, - block_number: block_tag - ) - - return nil if result == '0x' || result.nil? - - # Decode the result - expect a tuple with (address, bytes32, uint256, bool) - output_types = ['(address,bytes32,uint256,bool)'] - # Remove '0x' prefix before packing to avoid shifting the bytes - decoded = Eth::Abi.decode(output_types, [result.delete_prefix('0x')].pack('H*')) - state_tuple = decoded[0] - - # Debug the raw bytes32 value - raw_bytes32 = state_tuple[1] - hex_value = raw_bytes32.unpack1('H*') - - # Ensure we have a proper 32-byte hex string (64 hex chars) - if hex_value.length < 64 - hex_value = hex_value.rjust(64, '0') + data = fetch_collection(collection_id, block_tag) + return nil if data.nil? + collection_contract = data[:collectionContract] + locked = data[:locked] + current_size = 0 + if collection_contract && collection_contract != ZERO_ADDRESS + current_size = get_collection_supply(collection_contract, block_tag: block_tag) end { - collectionContract: state_tuple[0], - createTxHash: '0x' + hex_value, - currentSize: state_tuple[2], - locked: state_tuple[3] + collectionContract: collection_contract, + createTxHash: format_bytes32_hex(collection_id), + currentSize: current_size, + locked: locked } end def self.get_collection_metadata(collection_id, block_tag: 'latest') - # Encode the function call - input_types = ['bytes32'] + data = fetch_collection(collection_id, block_tag) + return nil if data.nil? - # Encode parameters + { + name: data[:name], + symbol: data[:symbol], + maxSupply: data[:maxSupply], + totalSupply: data[:maxSupply], + description: data[:description], + logoImageUri: data[:logoImageUri], + bannerImageUri: data[:bannerImageUri], + backgroundColor: data[:backgroundColor], + websiteLink: data[:websiteLink], + twitterLink: data[:twitterLink], + discordLink: data[:discordLink], + merkleRoot: data[:merkleRoot] + } + end + + def self.fetch_collection(collection_id, block_tag) + input_types = ['bytes32'] encoded_params = Eth::Abi.encode(input_types, [normalize_bytes32(collection_id)]) - # Use the actual function name from the contract - function_selector = Eth::Util.keccak256('getCollectionMetadata(bytes32)')[0..3] + function_selector = Eth::Util.keccak256('getCollection(bytes32)')[0..3] data = (function_selector + encoded_params).unpack1('H*') data = '0x' + data @@ -117,23 +92,24 @@ def self.get_collection_metadata(collection_id, block_tag: 'latest') return nil if result == '0x' || result.nil? - # Decode the result - CollectionMetadata tuple with 10 string/uint256 fields - output_types = ['(string,string,uint256,string,string,string,string,string,string,string)'] - # Remove '0x' prefix before packing to avoid shifting the bytes + output_types = ['(address,bool,string,string,uint256,string,string,string,string,string,string,string,bytes32)'] decoded = Eth::Abi.decode(output_types, [result.delete_prefix('0x')].pack('H*')) - metadata_tuple = decoded[0] + tuple = decoded[0] { - name: metadata_tuple[0], - symbol: metadata_tuple[1], - totalSupply: metadata_tuple[2], - description: metadata_tuple[3], - logoImageUri: metadata_tuple[4], - bannerImageUri: metadata_tuple[5], - backgroundColor: metadata_tuple[6], - websiteLink: metadata_tuple[7], - twitterLink: metadata_tuple[8], - discordLink: metadata_tuple[9] + collectionContract: tuple[0], + locked: tuple[1], + name: tuple[2], + symbol: tuple[3], + maxSupply: tuple[4], + description: tuple[5], + logoImageUri: tuple[6], + bannerImageUri: tuple[7], + backgroundColor: tuple[8], + websiteLink: tuple[9], + twitterLink: tuple[10], + discordLink: tuple[11], + merkleRoot: '0x' + tuple[12].unpack1('H*') } end @@ -215,4 +191,29 @@ def self.normalize_bytes32(value) hex = hex.rjust(64, '0') if hex.length < 64 [hex].pack('H*') end -end \ No newline at end of file + + def self.format_bytes32_hex(value) + hex = value.to_s.delete_prefix('0x') + hex = hex.rjust(64, '0')[0,64] + '0x' + hex.downcase + end + + def self.get_collection_supply(collection_contract, block_tag: 'latest') + function_selector = Eth::Util.keccak256('totalSupply()')[0..3] + data = '0x' + function_selector.unpack1('H*') + + result = EthRpcClient.l2.eth_call( + to: collection_contract, + data: data, + block_number: block_tag + ) + + return 0 if result == '0x' || result.nil? + + decoded = Eth::Abi.decode(['uint256'], [result.delete_prefix('0x')].pack('H*')) + decoded[0] + rescue => e + Rails.logger.error "Failed to get supply for collection #{collection_contract}: #{e.message}" + 0 + end +end diff --git a/lib/genesis_generator.rb b/lib/genesis_generator.rb index cbb945c..c45a78d 100755 --- a/lib/genesis_generator.rb +++ b/lib/genesis_generator.rb @@ -91,11 +91,9 @@ def run_forge_genesis_script! end should_perform_genesis_import = ENV.fetch('PERFORM_GENESIS_IMPORT', 'true') == 'true' - # Collections default to true in forge (for tests), but we disable in dev/prod via env files - enable_collections = ENV.fetch('ENABLE_COLLECTIONS', 'false') == 'true' # Build the forge script command - cmd = "cd #{contracts_dir} && PERFORM_GENESIS_IMPORT=#{should_perform_genesis_import} ENABLE_COLLECTIONS=#{enable_collections} forge script '#{script_path}:L2Genesis'" + cmd = "cd #{contracts_dir} && PERFORM_GENESIS_IMPORT=#{should_perform_genesis_import} forge script '#{script_path}:L2Genesis'" log "Executing: #{cmd}" log nil diff --git a/spec/integration/collections_protocol_e2e_spec.rb b/spec/integration/collections_protocol_e2e_spec.rb index 21dacdd..7f2105f 100644 --- a/spec/integration/collections_protocol_e2e_spec.rb +++ b/spec/integration/collections_protocol_e2e_spec.rb @@ -84,7 +84,7 @@ def create_and_validate_ethscription(creator:, to:, data_uri:) "op" => "create_collection", "name" => "Test NFT", "symbol" => "TNFT", - "total_supply" => "100", + "max_supply" => "100", "description" => "Test", "logo_image_uri" => "", "banner_image_uri" => "", @@ -164,7 +164,7 @@ def create_and_validate_ethscription(creator:, to:, data_uri:) "op" => "create_collection", "name" => "Test Batch Collection", "symbol" => "TBATCH", - "total_supply" => "100", + "max_supply" => "100", "description" => "Test batch collection", "logo_image_uri" => "", "banner_image_uri" => "", @@ -191,89 +191,96 @@ def create_and_validate_ethscription(creator:, to:, data_uri:) collection_id = results[:ethscription_ids].first expect(collection_id).to be_present - # Now create the ethscriptions that will be added to the collection - # Use the same direct pattern for creating items + # Now create the ethscriptions that add themselves to the collection + # Item 1: Create ethscription with add_self_to_collection + item1_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "add_self_to_collection", + "collection_id" => collection_id, + "item" => { + "item_index" => "0", + "name" => "Test Item #0", + "background_color" => "#648595", + "description" => "First test item with multiple attributes", + "attributes" => [ + {"trait_type" => "Type", "value" => "Common"}, + {"trait_type" => "Color", "value" => "Blue"}, + {"trait_type" => "Rarity", "value" => "1"}, + {"trait_type" => "Power", "value" => "100"} + ], + "merkle_proof" => [] + } + } + item1_spec = create_input( creator: alice, to: alice, - data_uri: "data:,Batch Item 1 content" + data_uri: "data:," + item1_data.to_json ) item1_results = import_l1_block([item1_spec], esip_overrides: { esip6_is_enabled: true }) item1_id = item1_results[:ethscription_ids].first expect(item1_id).to be_present, "Item 1 should be created" + # Item 2: Create ethscription with add_self_to_collection + item2_data = { + "p" => "erc-721-ethscriptions-collection", + "op" => "add_self_to_collection", + "collection_id" => collection_id, + "item" => { + "item_index" => "1", + "name" => "Test Item #1", + "background_color" => "#FF5733", + "description" => "Second test item with different attributes", + "attributes" => [ + {"trait_type" => "Type", "value" => "Rare"}, + {"trait_type" => "Color", "value" => "Red"}, + {"trait_type" => "Rarity", "value" => "5"}, + {"trait_type" => "Power", "value" => "500"} + ], + "merkle_proof" => [] + } + } + item2_spec = create_input( creator: alice, to: alice, - data_uri: "data:,Batch Item 2 content" + data_uri: "data:," + item2_data.to_json ) item2_results = import_l1_block([item2_spec], esip_overrides: { esip6_is_enabled: true }) item2_id = item2_results[:ethscription_ids].first expect(item2_id).to be_present, "Item 2 should be created" - # Complex nested structure matching real-world usage - batch_data = { - "p" => "erc-721-ethscriptions-collection", - "op" => "add_items_batch", - "collection_id" => collection_id, - "items" => [ - { - "item_index" => "0", - "name" => "Test Item #0", - "ethscription_id" => item1_id, - "background_color" => "#648595", - "description" => "First test item with multiple attributes", - "attributes" => [ - {"trait_type" => "Type", "value" => "Common"}, - {"trait_type" => "Color", "value" => "Blue"}, - {"trait_type" => "Rarity", "value" => "1"}, - {"trait_type" => "Power", "value" => "100"} - ] - }, - { - "item_index" => "1", - "name" => "Test Item #1", - "ethscription_id" => item2_id, - "background_color" => "#FF5733", - "description" => "Second test item with different attributes", - "attributes" => [ - {"trait_type" => "Type", "value" => "Rare"}, - {"trait_type" => "Color", "value" => "Red"}, - {"trait_type" => "Rarity", "value" => "5"}, - {"trait_type" => "Power", "value" => "500"} - ] - } - ] - } + # Validate first item addition + expect(item1_results[:ethscription_ids]).not_to be_empty, "Should create first item ethscription" + expect(item1_results[:l2_receipts]).not_to be_empty, "Should have L2 receipt for item 1" - # Create the add_items_batch transaction using the direct pattern - batch_spec = create_input( - creator: alice, - to: dummy_recipient, - data_uri: "data:," + batch_data.to_json - ) + # Parse events for item 1 + require_relative '../../lib/protocol_event_reader' + item1_events = ProtocolEventReader.parse_receipt_events(item1_results[:l2_receipts].first) - batch_results = import_l1_block([batch_spec], esip_overrides: { esip6_is_enabled: true }) + item1_added_event = item1_events.find { |e| e[:event] == 'ItemsAdded' } + expect(item1_added_event).not_to be_nil, "Should emit ItemsAdded event for item 1" + expect(item1_added_event[:count]).to eq(1), "Should add 1 item" - # Validate ethscription creation - expect(batch_results[:ethscription_ids]).not_to be_empty, "Should create batch ethscription" - batch_id = batch_results[:ethscription_ids].first - expect(batch_id).to be_present + # Validate second item addition + expect(item2_results[:ethscription_ids]).not_to be_empty, "Should create second item ethscription" + expect(item2_results[:l2_receipts]).not_to be_empty, "Should have L2 receipt for item 2" - # Parse events to validate protocol execution - require_relative '../../lib/protocol_event_reader' - events = ProtocolEventReader.parse_receipt_events(batch_results[:l2_receipts].first) + # Parse events for item 2 + item2_events = ProtocolEventReader.parse_receipt_events(item2_results[:l2_receipts].first) + + item2_added_event = item2_events.find { |e| e[:event] == 'ItemsAdded' } + expect(item2_added_event).not_to be_nil, "Should emit ItemsAdded event for item 2" + expect(item2_added_event[:count]).to eq(1), "Should add 1 item" - # Check for ItemsAdded event - items_added_event = events.find { |e| e[:event] == 'ItemsAdded' } - expect(items_added_event).not_to be_nil, "Should emit ItemsAdded event" - expect(items_added_event[:count]).to eq(2), "Should add 2 items" + # Check for protocol success for both items + item1_success = item1_events.any? { |e| e[:event] == 'ProtocolHandlerSuccess' } + expect(item1_success).to eq(true), "Protocol operation should succeed for item 1" - # Check for protocol success - protocol_success = events.any? { |e| e[:event] == 'ProtocolHandlerSuccess' } - expect(protocol_success).to eq(true), "Protocol operation should succeed" + item2_success = item2_events.any? { |e| e[:event] == 'ProtocolHandlerSuccess' } + expect(item2_success).to eq(true), "Protocol operation should succeed for item 2" # Validate collection state updated collection_state = get_collection_state(collection_id) @@ -296,4 +303,4 @@ def create_and_validate_ethscription(creator:, to:, data_uri:) expect(item1[:attributes][0]).to eq(["Type", "Rare"]) end end -end \ No newline at end of file +end diff --git a/spec/integration/collections_protocol_spec.rb b/spec/integration/collections_protocol_spec.rb index 9cb818b..fe48d6e 100644 --- a/spec/integration/collections_protocol_spec.rb +++ b/spec/integration/collections_protocol_spec.rb @@ -18,7 +18,7 @@ "name" => "Test NFTs", "symbol" => "TEST", "description" => "Test collection", - "total_supply" => "10000", + "max_supply" => "10000", "logo_image_uri" => "https://example.com/logo.png" } @@ -48,7 +48,7 @@ "op" => "create_collection", "name" => "Minimal Collection", "symbol" => "MIN", - "total_supply" => "1000" + "max_supply" => "1000" } expect_ethscription_success( @@ -60,13 +60,13 @@ ) end - it "handles numeric strings for total_supply (JS compatibility)" do + it "handles numeric strings for max_supply (JS compatibility)" do collection_data = { "p" => "erc-721-ethscriptions-collection", "op" => "create_collection", "name" => "Big Supply Collection", "symbol" => "BIG", - "total_supply" => "1000000000000000000" # Large number as string + "max_supply" => "1000000000000000000" # Large number as string } expect_ethscription_success( @@ -284,13 +284,13 @@ describe "Contract State Verification" do it "creates a collection and verifies it exists in contract" do - # Must include ALL CollectionMetadata fields in correct order + # Must include ALL CollectionParams fields in correct order collection_data = { "p" => "erc-721-ethscriptions-collection", "op" => "create_collection", "name" => "Verified Collection", "symbol" => "VRFY", - "total_supply" => "100", + "max_supply" => "100", "description" => "", "logo_image_uri" => "", "banner_image_uri" => "", @@ -340,7 +340,7 @@ "p" => "erc-721-ethscriptions-collection", "op" => "create_collection", "name" => "Invalid", - "total_supply" => too_big + "max_supply" => too_big } expect_protocol_extraction_failure( @@ -382,7 +382,7 @@ "op" => "create_collection", "name" => "Full Test Collection", "symbol" => "FULL", - "total_supply" => "10" + "max_supply" => "10" } create_results = expect_ethscription_success( diff --git a/spec/models/erc721_collections_import_fallback_spec.rb b/spec/models/erc721_collections_import_fallback_spec.rb new file mode 100644 index 0000000..e045679 --- /dev/null +++ b/spec/models/erc721_collections_import_fallback_spec.rb @@ -0,0 +1,222 @@ +require 'rails_helper' + +RSpec.describe Erc721EthscriptionsCollectionParser do + describe 'ID-aware import fallback and normal add flow' do + let(:leader_id) { '0x' + '1' * 64 } + let(:member_id) { '0x' + '2' * 64 } + + let(:collections_json) do + { + 'Test Collection' => { + 'name' => 'Test Collection', + 'slug' => 'TEST', + 'description' => 'Imported collection', + 'logo_image_uri' => '', + 'banner_image_uri' => '', + 'website_link' => 'https://example.com', + 'twitter_link' => '', + 'discord_link' => '', + 'background_color' => '#FFFFFF', + 'total_supply' => 2 + } + } + end + + let(:items_json) do + { + leader_id => { + 'index' => 0, + 'name' => 'Item Zero', + 'description' => 'First', + 'attributes' => [ { 'trait_type' => 'Type', 'value' => 'Genesis' } ], + 'ethscription_number' => 100, + 'collection_name' => 'Test Collection', + 'collection_slug' => 'TEST' + }, + member_id => { + 'index' => 1, + 'name' => 'Item One', + 'description' => 'Second', + 'attributes' => [], + 'ethscription_number' => 200, + 'collection_name' => 'Test Collection', + 'collection_slug' => 'TEST' + } + } + end + + let(:items_path) { Rails.root.join('tmp', 'spec_import_items.json').to_s } + let(:collections_path) { Rails.root.join('tmp', 'spec_import_collections.json').to_s } + + before do + FileUtils.mkdir_p(File.dirname(items_path)) + File.write(items_path, JSON.pretty_generate(items_json)) + File.write(collections_path, JSON.pretty_generate(collections_json)) + stub_const('Erc721EthscriptionsCollectionParser::DEFAULT_ITEMS_PATH', items_path) + stub_const('Erc721EthscriptionsCollectionParser::DEFAULT_COLLECTIONS_PATH', collections_path) + end + + it 'builds create_collection_and_add_self for the leader via import fallback' do + protocol, operation, encoded = described_class.extract( + 'data:,{}', + ethscription_id: ByteString.from_hex(leader_id) + ) + expect(protocol).to eq('erc-721-ethscriptions-collection'.b) + expect(operation).to eq('create_collection_and_add_self'.b) + + decoded = Eth::Abi.decode([ + '((string,string,uint256,string,string,string,string,string,string,string,bytes32),(uint256,string,string,string,(string,string)[],bytes32[]))' + ], encoded)[0] + + metadata = decoded[0] + item = decoded[1] + + expect(metadata[0]).to eq('Test Collection') # name + expect(metadata[1]).to eq('TEST') # symbol (from slug) + expect(metadata[2]).to eq(2) # maxSupply from total_supply + + expect(item[0]).to eq(0) # item index + expect(item[1]).to eq('Item Zero') # name + expect(item[2]).to eq('') # background_color + end + + it 'builds add_self_to_collection for a member via import fallback' do + protocol, operation, encoded = described_class.extract( + 'data:,{}', + ethscription_id: ByteString.from_hex(member_id) + ) + + expect(protocol).to eq('erc-721-ethscriptions-collection'.b) + expect(operation).to eq('add_self_to_collection'.b) + + decoded = Eth::Abi.decode([ + '(bytes32,(uint256,string,string,string,(string,string)[],bytes32[]))' + ], encoded)[0] + + collection_id = decoded[0] + item = decoded[1] + + expect(collection_id.unpack1('H*')).to eq(leader_id[2..]) + expect(item[0]).to eq(1) # item index + expect(item[1]).to eq('Item One') # name + expect(item[2]).to eq('') # background_color + end + + it 'parses normal content for add_self_to_collection (non-import)' do + content_json = { + 'p' => 'erc-721-ethscriptions-collection', + 'op' => 'add_self_to_collection', + 'collection_id' => leader_id, + 'item' => { + 'item_index' => '5', + 'name' => 'Normal Item', + 'background_color' => '#000000', + 'description' => 'desc', + 'attributes' => [], + 'merkle_proof' => [] + } + } + + protocol, operation, encoded = described_class.extract('data:,' + content_json.to_json) + + expect(protocol).to eq('erc-721-ethscriptions-collection'.b) + expect(operation).to eq('add_self_to_collection'.b) + + decoded = Eth::Abi.decode([ + '(bytes32,(uint256,string,string,string,(string,string)[],bytes32[]))' + ], encoded)[0] + + expect(decoded[0].unpack1('H*')).to eq(leader_id[2..]) + item = decoded[1] + expect(item[0]).to eq(5) # item_index + expect(item[1]).to eq('Normal Item') # name + expect(item[2]).to eq('#000000') # background_color + expect(item[3]).to eq('desc') # description + end + end + + describe 'Live JSON files import fallback' do + it 'builds create_collection_and_add_self for specific ethscription using live JSON files' do + # This ethscription should be the leader of a collection in the live JSON files + specific_id = '0x05aac415994e0e01e66c4970133a51a4cdcea1f3a967743b87e6eb08f2f4d9f9' + + protocol, operation, encoded = described_class.extract( + 'data:,{}', + ethscription_id: ByteString.from_hex(specific_id) + ) + expect(protocol).to eq('erc-721-ethscriptions-collection'.b) + expect(operation).to eq('create_collection_and_add_self'.b) + + # Decode to verify it's properly formed + decoded = Eth::Abi.decode([ + '((string,string,uint256,string,string,string,string,string,string,string,bytes32),(uint256,string,string,string,(string,string)[],bytes32[]))' + ], encoded)[0] + + metadata = decoded[0] + item = decoded[1] + + # Should have valid metadata + expect(metadata[0]).to be_a(String) # name + expect(metadata[0].length).to be > 0 + expect(metadata[1]).to be_a(String) # symbol + expect(metadata[2]).to be_a(Integer) # maxSupply + + # Should have valid item data + expect(item[0]).to be_a(Integer) # item_index + expect(item[1]).to be_a(String) # name + expect(item[2]).to be_a(String) # background_color + expect(item[3]).to be_a(String) # description + expect(item[4]).to be_an(Array) # attributes + expect(item[5]).to be_an(Array) # merkle_proof + end + end + + describe 'End-to-end collection creation with live JSON', type: :integration do + include EthscriptionsTestHelper + + let(:creator) { valid_address("creator") } + let(:specific_id) { '0x05aac415994e0e01e66c4970133a51a4cdcea1f3a967743b87e6eb08f2f4d9f9' } + + it 'creates collection and adds ethscription using import fallback for specific ID' do + # Create ethscription with empty content - should use import fallback + tx_spec = l1_tx( + creator: creator, + to: creator, + input: '0x646174613a696d6167652f706e673b6261736536342c6956424f5277304b47676f414141414e535568455567414141426741414141594341594141414467647a3334414141416d306c4551565234326d4e6747495467507854547876426c65546f30737742734f4b30732b4e38614a6b637a4331414d52374b414b7062387637327841593568467344346c466f434e2b6a35365a556f6c694262536f6b6c47495a6a7778526251416a5431594b37642b38326b4755426575516969354672415959724c38314e774370474651746f4555542f36526f485741796b6e51563053365a49355245364a7438435a494f4f48547547675239467135466b43663139514d33777835725a4b48457473525a517435716b6867554152366347615565684f443441414141415355564f524b35435949493d', + tx_hash: specific_id, + expect: :success + ) + + results = import_l1_block([tx_spec], esip_overrides: { esip6_is_enabled: true }) + + # Verify ethscription was created + expect(results[:ethscription_ids]).to include(specific_id) + expect(results[:l2_receipts].first[:status]).to eq('0x1'), "L2 transaction should succeed" + + # Parse events to verify collection creation and item addition + require_relative '../../lib/protocol_event_reader' + events = ProtocolEventReader.parse_receipt_events(results[:l2_receipts].first) + + # Should have CollectionCreated event + collection_created = events.find { |e| e[:event] == 'CollectionCreated' } + expect(collection_created).not_to be_nil, "Should emit CollectionCreated event" + expect(collection_created[:collection_id]).to eq(specific_id) + + # Should have ItemsAdded event + items_added = events.find { |e| e[:event] == 'ItemsAdded' } + expect(items_added).not_to be_nil, "Should emit ItemsAdded event" + expect(items_added[:collection_id]).to eq(specific_id) + expect(items_added[:count]).to eq(1), "Should add 1 item" + + # Should have protocol success + protocol_success = events.any? { |e| e[:event] == 'ProtocolHandlerSuccess' } + expect(protocol_success).to eq(true), "Protocol operation should succeed" + + # Verify collection state via eth_call + collection_state = get_collection_state(specific_id) + expect(collection_state[:collectionContract]).not_to eq('0x0000000000000000000000000000000000000000') + expect(collection_state[:currentSize]).to eq(1), "Collection should have 1 item" + end + end +end + diff --git a/spec/models/erc721_ethscriptions_collection_parser_spec.rb b/spec/models/erc721_ethscriptions_collection_parser_spec.rb index 0b90ef7..9501edc 100644 --- a/spec/models/erc721_ethscriptions_collection_parser_spec.rb +++ b/spec/models/erc721_ethscriptions_collection_parser_spec.rb @@ -57,12 +57,12 @@ # @generic-compatible it 'validates uint256 format - no leading zeros' do # Valid - valid_json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TEST","total_supply":"1000","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":""}' + valid_json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TEST","max_supply":"1000","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":""}' result = described_class.extract(valid_json) expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) # Invalid - leading zero - invalid_json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TEST","total_supply":"01000","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":""}' + invalid_json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TEST","max_supply":"01000","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":""}' result = described_class.extract(invalid_json) expect(result).to eq(default_params) end @@ -93,7 +93,7 @@ describe 'create_collection operation' do let(:valid_create_json) do - 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"My Collection","symbol":"MYC","total_supply":"10000","description":"A test collection","logo_image_uri":"esc://logo","banner_image_uri":"esc://banner","background_color":"#FF5733","website_link":"https://example.com","twitter_link":"https://twitter.com/test","discord_link":"https://discord.gg/test"}' + 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"My Collection","symbol":"MYC","max_supply":"10000","description":"A test collection","logo_image_uri":"esc://logo","banner_image_uri":"esc://banner","background_color":"#FF5733","website_link":"https://example.com","twitter_link":"https://twitter.com/test","discord_link":"https://discord.gg/test"}' end it 'encodes create_collection correctly' do @@ -104,7 +104,7 @@ # Decode and verify decoded = Eth::Abi.decode( - ['(string,string,uint256,string,string,string,string,string,string,string)'], + ['(string,string,uint256,string,string,string,string,string,string,string,bytes32)'], result[2] )[0] @@ -121,7 +121,7 @@ end it 'handles empty optional fields' do - json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TST","total_supply":"100","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":""}' + json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TST","max_supply":"100","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":""}' result = described_class.extract(json) expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) @@ -142,7 +142,7 @@ # Value that exceeds uint256 max too_large = (2**256).to_s # One more than max - json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TST","total_supply":"' + too_large + '","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":""}' + json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TST","max_supply":"' + too_large + '","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":""}' result = described_class.extract(json) # Should return default params due to validation failure @@ -153,7 +153,7 @@ # Maximum valid uint256 max_uint256 = (2**256 - 1).to_s - json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TST","total_supply":"' + max_uint256 + '","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":""}' + json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TST","max_supply":"' + max_uint256 + '","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":""}' result = described_class.extract(json) # Should succeed with max value @@ -169,80 +169,55 @@ end end - describe 'add_items_batch operation' do - let(:valid_add_items_json) do - 'data:,{"p":"erc-721-ethscriptions-collection","op":"add_items_batch","collection_id":"0x' + '1' * 64 + '","items":[{"item_index":"0","name":"Item 1","ethscription_id":"0x' + '2' * 64 + '","background_color":"#FF0000","description":"First item","attributes":[{"trait_type":"Rarity","value":"Common"},{"trait_type":"Level","value":"1"}]}]}' - end + describe 'add_self_to_collection operation' do + let(:collection_id_hex) { '0x' + '1' * 64 } + let(:current_item_id) { '0x' + 'a' * 64 } - it 'encodes add_items_batch correctly' do - result = described_class.extract(valid_add_items_json) + it 'encodes add_self_to_collection correctly' do + json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"add_self_to_collection","collection_id":"' + collection_id_hex + '","item":{"item_index":"0","name":"Item 1","background_color":"#FF0000","description":"First item","attributes":[{"trait_type":"Rarity","value":"Common"},{"trait_type":"Level","value":"1"}],"merkle_proof":[]}}' + + result = described_class.extract(json) expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) - expect(result[1]).to eq('add_items_batch'.b) + expect(result[1]).to eq('add_self_to_collection'.b) - # Decode and verify decoded = Eth::Abi.decode( - ['(bytes32,(uint256,string,bytes32,string,string,(string,string)[])[])'], + ['(bytes32,(uint256,string,string,string,(string,string)[],bytes32[]))'], result[2] )[0] - expect(decoded[0].unpack1('H*')).to eq('1' * 64) + expect(decoded[0].unpack1('H*')).to eq(collection_id_hex[2..]) - item = decoded[1][0] + item = decoded[1] expect(item[0]).to eq(0) # item_index - expect(item[1]).to eq("Item 1") - expect(item[2].unpack1('H*')).to eq('2' * 64) - expect(item[3]).to eq("#FF0000") - expect(item[4]).to eq("First item") - expect(item[5]).to eq([["Rarity", "Common"], ["Level", "1"]]) - end - - it 'validates item key order' do - # Wrong key order in item - json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"add_items_batch","collection_id":"0x' + '1' * 64 + '","items":[{"name":"Item 1","item_index":"0","ethscription_id":"0x' + '2' * 64 + '","background_color":"#FF0000","description":"First item","attributes":[]}]}' - result = described_class.extract(json) - expect(result).to eq(default_params) + expect(item[1]).to eq('Item 1') # name + expect(item[2]).to eq('#FF0000') # background_color + expect(item[3]).to eq('First item') # description + expect(item[4]).to eq([["Rarity", "Common"], ["Level", "1"]]) # attributes + expect(item[5]).to eq([]) # merkle_proof end it 'validates attribute key order' do # Wrong key order in attributes (value before trait_type) - json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"add_items_batch","collection_id":"0x' + '1' * 64 + '","items":[{"item_index":"0","name":"Item 1","ethscription_id":"0x' + '2' * 64 + '","background_color":"#FF0000","description":"First item","attributes":[{"value":"Common","trait_type":"Rarity"}]}]}' + json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"add_self_to_collection","collection_id":"' + collection_id_hex + '","item":{"item_index":"0","name":"Item 1","background_color":"#FF0000","description":"First item","attributes":[{"value":"Common","trait_type":"Rarity"}],"merkle_proof":[]}}' result = described_class.extract(json) expect(result).to eq(default_params) end it 'handles empty attributes array' do - json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"add_items_batch","collection_id":"0x' + '1' * 64 + '","items":[{"item_index":"0","name":"Item 1","ethscription_id":"0x' + '2' * 64 + '","background_color":"","description":"","attributes":[]}]}' + json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"add_self_to_collection","collection_id":"' + collection_id_hex + '","item":{"item_index":"0","name":"Item 1","background_color":"","description":"","attributes":[],"merkle_proof":[]}}' result = described_class.extract(json) expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) decoded = Eth::Abi.decode( - ['(bytes32,(uint256,string,bytes32,string,string,(string,string)[])[])'], + ['(bytes32,(uint256,string,string,string,(string,string)[],bytes32[]))'], result[2] )[0] - item = decoded[1][0] - expect(item[5]).to eq([]) # Empty attributes - end - - it 'handles multiple items' do - json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"add_items_batch","collection_id":"0x' + '1' * 64 + '","items":[' + - '{"item_index":"0","name":"Item 1","ethscription_id":"0x' + '2' * 64 + '","background_color":"","description":"","attributes":[]},' + - '{"item_index":"1","name":"Item 2","ethscription_id":"0x' + '3' * 64 + '","background_color":"","description":"","attributes":[]}' + - ']}' - result = described_class.extract(json) - - expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) - - decoded = Eth::Abi.decode( - ['(bytes32,(uint256,string,bytes32,string,string,(string,string)[])[])'], - result[2] - )[0] - - expect(decoded[1].length).to eq(2) - expect(decoded[1][0][0]).to eq(0) # First item index - expect(decoded[1][1][0]).to eq(1) # Second item index + item = decoded[1] + expect(item[4]).to eq([]) # Empty attributes + expect(item[5]).to eq([]) # Empty merkle_proof end end @@ -271,7 +246,7 @@ expect(result[1]).to eq('edit_collection'.b) decoded = Eth::Abi.decode( - ['(bytes32,string,string,string,string,string,string,string)'], + ['(bytes32,string,string,string,string,string,string,string,bytes32)'], result[2] )[0] @@ -320,27 +295,12 @@ end end - describe 'sync_ownership operation' do - it 'encodes sync_ownership correctly' do - json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"sync_ownership","collection_id":"0x' + '1' * 64 + '","ethscription_ids":["0x' + '2' * 64 + '"]}' - result = described_class.extract(json) - - expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) - expect(result[1]).to eq('sync_ownership'.b) - - decoded = Eth::Abi.decode(['(bytes32,bytes32[])'], result[2])[0] - - expect(decoded[0].unpack1('H*')).to eq('1' * 64) - expect(decoded[1][0].unpack1('H*')).to eq('2' * 64) - end - end - describe 'round-trip tests' do # @generic-compatible it 'preserves all data through encode/decode cycle' do test_cases = [ { - json: 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TST","total_supply":"100","description":"Desc","logo_image_uri":"logo","banner_image_uri":"banner","background_color":"#FFF","website_link":"http://test","twitter_link":"@test","discord_link":"discord"}', + json: 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TST","max_supply":"100","description":"Desc","logo_image_uri":"logo","banner_image_uri":"banner","background_color":"#FFF","website_link":"http://test","twitter_link":"@test","discord_link":"discord"}', abi_type: '(string,string,uint256,string,string,string,string,string,string,string)', expected: ["Test", "TST", 100, "Desc", "logo", "banner", "#FFF", "http://test", "@test", "discord"] }, @@ -386,12 +346,12 @@ it 'rejects null values in string fields (no silent coercion)' do # Test null in create_collection string fields - json_with_null = 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":null,"symbol":"TEST","total_supply":"100","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":""}' + json_with_null = 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":null,"symbol":"TEST","max_supply":"100","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":""}' result = described_class.extract(json_with_null) expect(result).to eq(default_params) # Test null in description field - json_with_null_desc = 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TEST","total_supply":"100","description":null,"logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":""}' + json_with_null_desc = 'data:,{"p":"erc-721-ethscriptions-collection","op":"create_collection","name":"Test","symbol":"TEST","max_supply":"100","description":null,"logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":""}' result = described_class.extract(json_with_null_desc) expect(result).to eq(default_params) @@ -414,4 +374,4 @@ end end end -end \ No newline at end of file +end